diff --git a/docs/formatting.md b/docs/formatting.md index 3c81893d6..6ad02c3e0 100644 --- a/docs/formatting.md +++ b/docs/formatting.md @@ -177,7 +177,7 @@ The macro options available correspond one-to-one with the preset formats define (Examples below given for `2014-08-06T13:07:04.054` considered as a local time in America/New_York). | Standalone token | Format token | Description | Example | -| ---------------- | ------------ | -------------------------------------------------------------- | ------------------------------------------------------------- | +|------------------| ------------ |----------------------------------------------------------------| ------------------------------------------------------------- | | S | | millisecond, no padding | `54` | | SSS | | millisecond, padded to 3 | `054` | | u | | fractional seconds, functionally identical to SSS | `054` | @@ -219,6 +219,10 @@ The macro options available correspond one-to-one with the preset formats define | kkkk | | ISO week year, padded to 4 | `2014` | | W | | ISO week number, unpadded | `32` | | WW | | ISO week number, padded to 2 | `32` | +| ii | | Local week year, unpadded | `14` | +| iiii | | Local week year, padded to 4 | `2014` | +| n | | Local week number, unpadded | `32` | +| nn | | Local week number, padded to 2 | `32` | | o | | ordinal (day of year), unpadded | `218` | | ooo | | ordinal (day of year), padded to 3 | `218` | | q | | quarter, no padding | `3` | diff --git a/docs/intl.md b/docs/intl.md index 775f293dd..8ed00497e 100644 --- a/docs/intl.md +++ b/docs/intl.md @@ -148,3 +148,71 @@ Similar to `locale`, you can set the default numbering system for new instances: ```js Settings.defaultNumberingSystem = "beng"; ``` + +## Locale-based weeks + +Most of Luxon uses the [ISO week date](https://en.wikipedia.org/wiki/ISO_week_date) system when working with week-related data. +This means that the week starts on Monday and the first week of the year is that week, which has 4 or more of its days in January. + +This definition works for most use-cases, however locales can define different rules. For example, in many English-speaking countries +the week is said to start on Sunday and the 1 January always defines the first week of the year. This information is +available through the Luxon as well. + +Note that your runtime needs to support [`Intl.Locale#getWeekInfo`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/Locale/getWeekInfo) for this to have an effect. If unsupported, Luxon will fall back +to using the ISO week dates. + +### Accessing locale-based week info + +The `Info` class exposes methods `getStartOfWeek`, `getMinimumDaysInFirstWeek` and `getWeekendWeekdays` for informational +purposes. + +### Accessing locale-based week data + +```js +const dt = DateTime.local(2022, 1, 1, { locale: "en-US" }); +dt.localWeekday // 7, because Saturday is the 7th day of the week in en-US +dt.localWeekNumber // 1, because 1 January is always in the first week in en-US +dt.localWeekYear // 2022, because 1 January is always in the first week in en-US +dt.weeksInLocalWeekYear // 53, because 2022 has 53 weeks in en-US +``` + +### Using locale-based week data when creating DateTimes + +When creating a DateTime instance using `fromObject`, you can use the `localWeekday`, `localWeekNumber` and `localWeekYear` +properties. They will use the same locale that the newly created DateTime will use, either explicitly provided or falling back +to the system default. + +```js +const dt = DateTime.fromObject({localWeekYear: 2022, localWeekNumber: 53, localWeekday: 7}); +dt.toISODate(); // 2022-12-31 +``` + +### Setting locale-based week data + +When modifying an existing DateTime instance using `set`, you can use the `localWeekday`, `localWeekNumber` and `localWeekYear` +properties. They will use the locale of the existing DateTime as reference. + +```js +const dt = DateTime.local(2022, 1, 2, { locale: "en-US" }); +const modified = dt.set({localWeekNumber: 2}); +modified.toISODate(); // 2022-01-08 +``` + +### Locale-based weeks with math + +The methods `startOf`, `endOf`, `hasSame` of `DateTime` as well as `count` of `Interval` accept an option `useLocaleWeeks`. +If enabled, the methods will treat the `week` unit according to the locale, respecting the correct start of the week. + +```js +const dt = DateTime.local(2022, 1, 6, { locale: "en-US" }); +const startOfWeek = dt.startOf('week', {useLocaleWeeks: true}); +startOfWeek.toISODate(); // 2022-01-02, a Sunday +``` + +### Overriding defaults + +You can override the runtime-provided defaults for the week settings using `Settings.defaultWeekSettings`: + +```js +Settings.defaultWeekSettings = { firstDay: 7, minimalDays: 1, weekend: [6, 7] } +``` \ No newline at end of file diff --git a/src/datetime.js b/src/datetime.js index 40c1e6ae1..80d96c260 100644 --- a/src/datetime.js +++ b/src/datetime.js @@ -38,6 +38,8 @@ import { hasInvalidWeekData, hasInvalidOrdinalData, hasInvalidTimeData, + usesLocalWeekValues, + isoWeekdayToLocal, } from "./impl/conversions.js"; import * as Formats from "./impl/formats.js"; import { @@ -56,6 +58,9 @@ function unsupportedZone(zone) { } // we cache week data on the DT object and this intermediates the cache +/** + * @param {DateTime} dt + */ function possiblyCachedWeekData(dt) { if (dt.weekData === null) { dt.weekData = gregorianToWeek(dt.c); @@ -63,6 +68,20 @@ function possiblyCachedWeekData(dt) { return dt.weekData; } +/** + * @param {DateTime} dt + */ +function possiblyCachedLocalWeekData(dt) { + if (dt.localWeekData === null) { + dt.localWeekData = gregorianToWeek( + dt.c, + dt.loc.getMinDaysInFirstWeek(), + dt.loc.getStartOfWeek() + ); + } + return dt.localWeekData; +} + // clone really means, "make a new object with these modifications". all "setters" really use this // to create a new object while only changing some of the properties function clone(inst, alts) { @@ -334,6 +353,22 @@ function normalizeUnit(unit) { return normalized; } +function normalizeUnitWithLocalWeeks(unit) { + switch (unit.toLowerCase()) { + case "localweekday": + case "localweekdays": + return "localWeekday"; + case "localweeknumber": + case "localweeknumbers": + return "localWeekNumber"; + case "localweekyear": + case "localweekyears": + return "localWeekYear"; + default: + return normalizeUnit(unit); + } +} + // this is a dumbed down version of fromObject() that runs about 60% faster // but doesn't do any validation, makes a bunch of assumptions about what units // are present, and so on. @@ -476,6 +511,10 @@ export default class DateTime { * @access private */ this.weekData = null; + /** + * @access private + */ + this.localWeekData = null; /** * @access private */ @@ -646,13 +685,16 @@ export default class DateTime { * @param {number} obj.weekYear - an ISO week year * @param {number} obj.weekNumber - an ISO week number, between 1 and 52 or 53, depending on the year * @param {number} obj.weekday - an ISO weekday, 1-7, where 1 is Monday and 7 is Sunday + * @param {number} obj.localWeekYear - a week year, according to the locale + * @param {number} obj.localWeekNumber - a week number, between 1 and 52 or 53, depending on the year, according to the locale + * @param {number} obj.localWeekday - a weekday, 1-7, where 1 is the first and 7 is the last day of the week, according to the locale * @param {number} obj.hour - hour of the day, 0-23 * @param {number} obj.minute - minute of the hour, 0-59 * @param {number} obj.second - second of the minute, 0-59 * @param {number} obj.millisecond - millisecond of the second, 0-999 * @param {Object} opts - options for creating this DateTime * @param {string|Zone} [opts.zone='local'] - interpret the numbers in the context of a particular zone. Can take any value taken as the first argument to setZone() - * @param {string} [opts.locale='system's locale'] - a locale to set on the resulting DateTime instance + * @param {string} [opts.locale='system\'s locale'] - a locale to set on the resulting DateTime instance * @param {string} opts.outputCalendar - the output calendar to set on the resulting DateTime instance * @param {string} opts.numberingSystem - the numbering system to set on the resulting DateTime instance * @example DateTime.fromObject({ year: 1982, month: 5, day: 25}).toISODate() //=> '1982-05-25' @@ -662,6 +704,7 @@ export default class DateTime { * @example DateTime.fromObject({ hour: 10, minute: 26, second: 6 }, { zone: 'local' }) * @example DateTime.fromObject({ hour: 10, minute: 26, second: 6 }, { zone: 'America/New_York' }) * @example DateTime.fromObject({ weekYear: 2016, weekNumber: 2, weekday: 3 }).toISODate() //=> '2016-01-13' + * @example DateTime.fromObject({ localWeekYear: 2022, localWeekNumber: 1, localWeekday: 1 }, { locale: "en-US" }).toISODate() //=> '2021-12-26' * @return {DateTime} */ static fromObject(obj, opts = {}) { @@ -671,17 +714,19 @@ export default class DateTime { return DateTime.invalid(unsupportedZone(zoneToUse)); } + const loc = Locale.fromObject(opts); + const normalized = normalizeObject(obj, normalizeUnitWithLocalWeeks); + const { minDaysInFirstWeek, startOfWeek } = usesLocalWeekValues(normalized, loc); + const tsNow = Settings.now(), offsetProvis = !isUndefined(opts.specificOffset) ? opts.specificOffset : zoneToUse.offset(tsNow), - normalized = normalizeObject(obj, normalizeUnit), containsOrdinal = !isUndefined(normalized.ordinal), containsGregorYear = !isUndefined(normalized.year), containsGregorMD = !isUndefined(normalized.month) || !isUndefined(normalized.day), containsGregor = containsGregorYear || containsGregorMD, - definiteWeekDef = normalized.weekYear || normalized.weekNumber, - loc = Locale.fromObject(opts); + definiteWeekDef = normalized.weekYear || normalized.weekNumber; // cases: // just a weekday -> this week's instance of that weekday, no worries @@ -708,7 +753,7 @@ export default class DateTime { if (useWeekData) { units = orderedWeekUnits; defaultValues = defaultWeekUnitValues; - objNow = gregorianToWeek(objNow); + objNow = gregorianToWeek(objNow, minDaysInFirstWeek, startOfWeek); } else if (containsOrdinal) { units = orderedOrdinalUnits; defaultValues = defaultOrdinalUnitValues; @@ -733,7 +778,7 @@ export default class DateTime { // make sure the values we have are in range const higherOrderInvalid = useWeekData - ? hasInvalidWeekData(normalized) + ? hasInvalidWeekData(normalized, minDaysInFirstWeek, startOfWeek) : containsOrdinal ? hasInvalidOrdinalData(normalized) : hasInvalidGregorianData(normalized), @@ -745,7 +790,7 @@ export default class DateTime { // compute the actual time const gregorian = useWeekData - ? weekToGregorian(normalized) + ? weekToGregorian(normalized, minDaysInFirstWeek, startOfWeek) : containsOrdinal ? ordinalToGregorian(normalized) : normalized, @@ -1129,6 +1174,43 @@ export default class DateTime { return this.isValid ? possiblyCachedWeekData(this).weekday : NaN; } + /** + * Returns true if this date is on a weekend according to the locale, false otherwise + * @returns {boolean} + */ + get isWeekend() { + return this.isValid && this.loc.getWeekendDays().includes(this.weekday); + } + + /** + * Get the day of the week according to the locale. + * 1 is the first day of the week and 7 is the last day of the week. + * If the locale assigns Sunday as the first day of the week, then a date which is a Sunday will return 1, + * @returns {number} + */ + get localWeekday() { + return this.isValid ? possiblyCachedLocalWeekData(this).weekday : NaN; + } + + /** + * Get the week number of the week year according to the locale. Different locales assign week numbers differently, + * because the week can start on different days of the week (see localWeekday) and because a different number of days + * is required for a week to count as the first week of a year. + * @returns {number} + */ + get localWeekNumber() { + return this.isValid ? possiblyCachedLocalWeekData(this).weekNumber : NaN; + } + + /** + * Get the week year according to the locale. Different locales assign week numbers (and therefor week years) + * differently, see localWeekNumber. + * @returns {number} + */ + get localWeekYear() { + return this.isValid ? possiblyCachedLocalWeekData(this).weekYear : NaN; + } + /** * Get the ordinal (meaning the day of the year) * @example DateTime.local(2017, 5, 25).ordinal //=> 145 @@ -1321,6 +1403,22 @@ export default class DateTime { return this.isValid ? weeksInWeekYear(this.weekYear) : NaN; } + /** + * Returns the number of weeks in this DateTime's local week year + * @example DateTime.local(2020, 6, {locale: 'en-US'}).weeksInLocalWeekYear //=> 52 + * @example DateTime.local(2020, 6, {locale: 'de-DE'}).weeksInLocalWeekYear //=> 53 + * @type {number} + */ + get weeksInLocalWeekYear() { + return this.isValid + ? weeksInWeekYear( + this.localWeekYear, + this.loc.getMinDaysInFirstWeek(), + this.loc.getStartOfWeek() + ) + : NaN; + } + /** * Returns the resolved Intl options for this DateTime. * This is useful in understanding the behavior of formatting methods @@ -1409,6 +1507,9 @@ export default class DateTime { /** * "Set" the values of specified units. Returns a newly-constructed DateTime. * You can only set units with this method; for "setting" metadata, see {@link DateTime#reconfigure} and {@link DateTime#setZone}. + * + * This method also supports setting locale-based week units, i.e. `localWeekday`, `localWeekNumber` and `localWeekYear`. + * They cannot be mixed with ISO-week units like `weekday`. * @param {Object} values - a mapping of units to numbers * @example dt.set({ year: 2017 }) * @example dt.set({ hour: 8, minute: 30 }) @@ -1419,8 +1520,10 @@ export default class DateTime { set(values) { if (!this.isValid) return this; - const normalized = normalizeObject(values, normalizeUnit), - settingWeekStuff = + const normalized = normalizeObject(values, normalizeUnitWithLocalWeeks); + const { minDaysInFirstWeek, startOfWeek } = usesLocalWeekValues(normalized, this.loc); + + const settingWeekStuff = !isUndefined(normalized.weekYear) || !isUndefined(normalized.weekNumber) || !isUndefined(normalized.weekday), @@ -1442,7 +1545,11 @@ export default class DateTime { let mixed; if (settingWeekStuff) { - mixed = weekToGregorian({ ...gregorianToWeek(this.c), ...normalized }); + mixed = weekToGregorian( + { ...gregorianToWeek(this.c, minDaysInFirstWeek, startOfWeek), ...normalized }, + minDaysInFirstWeek, + startOfWeek + ); } else if (!isUndefined(normalized.ordinal)) { mixed = ordinalToGregorian({ ...gregorianToOrdinal(this.c), ...normalized }); } else { @@ -1493,6 +1600,8 @@ export default class DateTime { /** * "Set" this DateTime to the beginning of a unit of time. * @param {string} unit - The unit to go to the beginning of. Can be 'year', 'quarter', 'month', 'week', 'day', 'hour', 'minute', 'second', or 'millisecond'. + * @param {Object} opts - options + * @param {boolean} [opts.useLocaleWeeks=false] - If true, use weeks based on the locale, i.e. use the locale-dependent start of the week * @example DateTime.local(2014, 3, 3).startOf('month').toISODate(); //=> '2014-03-01' * @example DateTime.local(2014, 3, 3).startOf('year').toISODate(); //=> '2014-01-01' * @example DateTime.local(2014, 3, 3).startOf('week').toISODate(); //=> '2014-03-03', weeks always start on Mondays @@ -1500,8 +1609,9 @@ export default class DateTime { * @example DateTime.local(2014, 3, 3, 5, 30).startOf('hour').toISOTime(); //=> '05:00:00.000-05:00' * @return {DateTime} */ - startOf(unit) { + startOf(unit, { useLocaleWeeks = false } = {}) { if (!this.isValid) return this; + const o = {}, normalizedUnit = Duration.normalizeUnit(unit); switch (normalizedUnit) { @@ -1531,7 +1641,16 @@ export default class DateTime { } if (normalizedUnit === "weeks") { - o.weekday = 1; + if (useLocaleWeeks) { + const startOfWeek = this.loc.getStartOfWeek(); + const { weekday } = this; + if (weekday < startOfWeek) { + o.weekNumber = this.weekNumber - 1; + } + o.weekday = startOfWeek; + } else { + o.weekday = 1; + } } if (normalizedUnit === "quarters") { @@ -1545,6 +1664,8 @@ export default class DateTime { /** * "Set" this DateTime to the end (meaning the last millisecond) of a unit of time * @param {string} unit - The unit to go to the end of. Can be 'year', 'quarter', 'month', 'week', 'day', 'hour', 'minute', 'second', or 'millisecond'. + * @param {Object} opts - options + * @param {boolean} [opts.useLocaleWeeks=false] - If true, use weeks based on the locale, i.e. use the locale-dependent start of the week * @example DateTime.local(2014, 3, 3).endOf('month').toISO(); //=> '2014-03-31T23:59:59.999-05:00' * @example DateTime.local(2014, 3, 3).endOf('year').toISO(); //=> '2014-12-31T23:59:59.999-05:00' * @example DateTime.local(2014, 3, 3).endOf('week').toISO(); // => '2014-03-09T23:59:59.999-05:00', weeks start on Mondays @@ -1552,10 +1673,10 @@ export default class DateTime { * @example DateTime.local(2014, 3, 3, 5, 30).endOf('hour').toISO(); //=> '2014-03-03T05:59:59.999-05:00' * @return {DateTime} */ - endOf(unit) { + endOf(unit, opts) { return this.isValid ? this.plus({ [unit]: 1 }) - .startOf(unit) + .startOf(unit, opts) .minus(1) : this; } @@ -1950,15 +2071,19 @@ export default class DateTime { * Note that time zones are **ignored** in this comparison, which compares the **local** calendar time. Use {@link DateTime#setZone} to convert one of the dates if needed. * @param {DateTime} otherDateTime - the other DateTime * @param {string} unit - the unit of time to check sameness on + * @param {Object} opts - options + * @param {boolean} [opts.useLocaleWeeks=false] - If true, use weeks based on the locale, i.e. use the locale-dependent start of the week; only the locale of this DateTime is used * @example DateTime.now().hasSame(otherDT, 'day'); //~> true if otherDT is in the same current calendar day * @return {boolean} */ - hasSame(otherDateTime, unit) { + hasSame(otherDateTime, unit, opts) { if (!this.isValid) return false; const inputMs = otherDateTime.valueOf(); const adjustedToZone = this.setZone(otherDateTime.zone, { keepLocalTime: true }); - return adjustedToZone.startOf(unit) <= inputMs && inputMs <= adjustedToZone.endOf(unit); + return ( + adjustedToZone.startOf(unit, opts) <= inputMs && inputMs <= adjustedToZone.endOf(unit, opts) + ); } /** diff --git a/src/impl/conversions.js b/src/impl/conversions.js index bfb355fb5..4c7d1170e 100644 --- a/src/impl/conversions.js +++ b/src/impl/conversions.js @@ -6,8 +6,10 @@ import { daysInMonth, weeksInWeekYear, isInteger, + isUndefined, } from "./util.js"; import Invalid from "./invalid.js"; +import { ConflictingSpecificationError } from "../errors.js"; const nonLeapLadder = [0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334], leapLadder = [0, 31, 60, 91, 121, 152, 182, 213, 244, 274, 305, 335]; @@ -19,7 +21,7 @@ function unitOutOfRange(unit, value) { ); } -function dayOfWeek(year, month, day) { +export function dayOfWeek(year, month, day) { const d = new Date(Date.UTC(year, month - 1, day)); if (year < 100 && year >= 0) { @@ -42,22 +44,26 @@ function uncomputeOrdinal(year, ordinal) { return { month: month0 + 1, day }; } +export function isoWeekdayToLocal(isoWeekday, startOfWeek) { + return ((isoWeekday - startOfWeek + 7) % 7) + 1; +} + /** * @private */ -export function gregorianToWeek(gregObj) { +export function gregorianToWeek(gregObj, minDaysInFirstWeek = 4, startOfWeek = 1) { const { year, month, day } = gregObj, ordinal = computeOrdinal(year, month, day), - weekday = dayOfWeek(year, month, day); + weekday = isoWeekdayToLocal(dayOfWeek(year, month, day), startOfWeek); - let weekNumber = Math.floor((ordinal - weekday + 10) / 7), + let weekNumber = Math.floor((ordinal - weekday + 14 - minDaysInFirstWeek) / 7), weekYear; if (weekNumber < 1) { weekYear = year - 1; - weekNumber = weeksInWeekYear(weekYear); - } else if (weekNumber > weeksInWeekYear(year)) { + weekNumber = weeksInWeekYear(weekYear, minDaysInFirstWeek, startOfWeek); + } else if (weekNumber > weeksInWeekYear(year, minDaysInFirstWeek, startOfWeek)) { weekYear = year + 1; weekNumber = 1; } else { @@ -67,12 +73,12 @@ export function gregorianToWeek(gregObj) { return { weekYear, weekNumber, weekday, ...timeObject(gregObj) }; } -export function weekToGregorian(weekData) { +export function weekToGregorian(weekData, minDaysInFirstWeek = 4, startOfWeek = 1) { const { weekYear, weekNumber, weekday } = weekData, - weekdayOfJan4 = dayOfWeek(weekYear, 1, 4), + weekdayOfJan4 = isoWeekdayToLocal(dayOfWeek(weekYear, 1, minDaysInFirstWeek), startOfWeek), yearInDays = daysInYear(weekYear); - let ordinal = weekNumber * 7 + weekday - weekdayOfJan4 - 3, + let ordinal = weekNumber * 7 + weekday - weekdayOfJan4 - 7 + minDaysInFirstWeek, year; if (ordinal < 1) { @@ -101,15 +107,54 @@ export function ordinalToGregorian(ordinalData) { return { year, month, day, ...timeObject(ordinalData) }; } -export function hasInvalidWeekData(obj) { +/** + * Check if local week units like localWeekday are used in obj. + * If so, validates that they are not mixed with ISO week units and then copies them to the normal week unit properties. + * Modifies obj in-place! + * @param obj the object values + */ +export function usesLocalWeekValues(obj, loc) { + const hasLocaleWeekData = + !isUndefined(obj.localWeekday) || + !isUndefined(obj.localWeekNumber) || + !isUndefined(obj.localWeekYear); + if (hasLocaleWeekData) { + const hasIsoWeekData = + !isUndefined(obj.weekday) || !isUndefined(obj.weekNumber) || !isUndefined(obj.weekYear); + + if (hasIsoWeekData) { + throw new ConflictingSpecificationError( + "Cannot mix locale-based week fields with ISO-based week fields" + ); + } + if (!isUndefined(obj.localWeekday)) obj.weekday = obj.localWeekday; + if (!isUndefined(obj.localWeekNumber)) obj.weekNumber = obj.localWeekNumber; + if (!isUndefined(obj.localWeekYear)) obj.weekYear = obj.localWeekYear; + delete obj.localWeekday; + delete obj.localWeekNumber; + delete obj.localWeekYear; + return { + minDaysInFirstWeek: loc.getMinDaysInFirstWeek(), + startOfWeek: loc.getStartOfWeek(), + }; + } else { + return { minDaysInFirstWeek: 4, startOfWeek: 1 }; + } +} + +export function hasInvalidWeekData(obj, minDaysInFirstWeek = 4, startOfWeek = 1) { const validYear = isInteger(obj.weekYear), - validWeek = integerBetween(obj.weekNumber, 1, weeksInWeekYear(obj.weekYear)), + validWeek = integerBetween( + obj.weekNumber, + 1, + weeksInWeekYear(obj.weekYear, minDaysInFirstWeek, startOfWeek) + ), validWeekday = integerBetween(obj.weekday, 1, 7); if (!validYear) { return unitOutOfRange("weekYear", obj.weekYear); } else if (!validWeek) { - return unitOutOfRange("week", obj.week); + return unitOutOfRange("week", obj.weekNumber); } else if (!validWeekday) { return unitOutOfRange("weekday", obj.weekday); } else return false; diff --git a/src/impl/formatter.js b/src/impl/formatter.js index 0b8741025..eb0bdb61b 100644 --- a/src/impl/formatter.js +++ b/src/impl/formatter.js @@ -337,6 +337,14 @@ export default class Formatter { return this.num(dt.weekNumber); case "WW": return this.num(dt.weekNumber, 2); + case "n": + return this.num(dt.localWeekNumber); + case "nn": + return this.num(dt.localWeekNumber, 2); + case "ii": + return this.num(dt.localWeekYear.toString().slice(-2), 2); + case "iiii": + return this.num(dt.localWeekYear, 4); case "o": return this.num(dt.ordinal); case "ooo": diff --git a/src/impl/locale.js b/src/impl/locale.js index fe7dd57d6..f1caf1495 100644 --- a/src/impl/locale.js +++ b/src/impl/locale.js @@ -1,4 +1,4 @@ -import { padStart, roundTo, hasRelative, formatOffset } from "./util.js"; +import { hasLocaleWeekInfo, hasRelative, padStart, roundTo, validateWeekSettings } from "./util.js"; import * as English from "./english.js"; import Settings from "../settings.js"; import DateTime from "../datetime.js"; @@ -61,6 +61,18 @@ function systemLocale() { } } +let weekInfoCache = {}; +function getCachedWeekInfo(locString) { + let data = weekInfoCache[locString]; + if (!data) { + const locale = new Intl.Locale(locString); + // browsers currently implement this as a property, but spec says it should be a getter function + data = "getWeekInfo" in locale ? locale.getWeekInfo() : locale.weekInfo; + weekInfoCache[locString] = data; + } + return data; +} + function parseLocaleString(localeStr) { // I really want to avoid writing a BCP 47 parser // see, e.g. https://github.com/wooorm/bcp-47 @@ -305,22 +317,35 @@ class PolyRelFormatter { } } +const fallbackWeekSettings = { + firstDay: 1, + minimalDays: 4, + weekend: [6, 7], +}; + /** * @private */ export default class Locale { static fromOpts(opts) { - return Locale.create(opts.locale, opts.numberingSystem, opts.outputCalendar, opts.defaultToEN); + return Locale.create( + opts.locale, + opts.numberingSystem, + opts.outputCalendar, + opts.weekSettings, + opts.defaultToEN + ); } - static create(locale, numberingSystem, outputCalendar, defaultToEN = false) { + static create(locale, numberingSystem, outputCalendar, weekSettings, defaultToEN = false) { const specifiedLocale = locale || Settings.defaultLocale; // the system locale is useful for human readable strings but annoying for parsing/formatting known formats const localeR = specifiedLocale || (defaultToEN ? "en-US" : systemLocale()); const numberingSystemR = numberingSystem || Settings.defaultNumberingSystem; const outputCalendarR = outputCalendar || Settings.defaultOutputCalendar; - return new Locale(localeR, numberingSystemR, outputCalendarR, specifiedLocale); + const weekSettingsR = validateWeekSettings(weekSettings) || Settings.defaultWeekSettings; + return new Locale(localeR, numberingSystemR, outputCalendarR, weekSettingsR, specifiedLocale); } static resetCache() { @@ -330,16 +355,17 @@ export default class Locale { intlRelCache = {}; } - static fromObject({ locale, numberingSystem, outputCalendar } = {}) { - return Locale.create(locale, numberingSystem, outputCalendar); + static fromObject({ locale, numberingSystem, outputCalendar, weekSettings } = {}) { + return Locale.create(locale, numberingSystem, outputCalendar, weekSettings); } - constructor(locale, numbering, outputCalendar, specifiedLocale) { + constructor(locale, numbering, outputCalendar, weekSettings, specifiedLocale) { const [parsedLocale, parsedNumberingSystem, parsedOutputCalendar] = parseLocaleString(locale); this.locale = parsedLocale; this.numberingSystem = numbering || parsedNumberingSystem || null; this.outputCalendar = outputCalendar || parsedOutputCalendar || null; + this.weekSettings = weekSettings; this.intl = intlConfigString(this.locale, this.numberingSystem, this.outputCalendar); this.weekdaysCache = { format: {}, standalone: {} }; @@ -375,6 +401,7 @@ export default class Locale { alts.locale || this.specifiedLocale, alts.numberingSystem || this.numberingSystem, alts.outputCalendar || this.outputCalendar, + validateWeekSettings(alts.weekSettings) || this.weekSettings, alts.defaultToEN || false ); } @@ -483,6 +510,28 @@ export default class Locale { ); } + getWeekSettings() { + if (this.weekSettings) { + return this.weekSettings; + } else if (!hasLocaleWeekInfo()) { + return fallbackWeekSettings; + } else { + return getCachedWeekInfo(this.locale); + } + } + + getStartOfWeek() { + return this.getWeekSettings().firstDay; + } + + getMinDaysInFirstWeek() { + return this.getWeekSettings().minimalDays; + } + + getWeekendDays() { + return this.getWeekSettings().weekend; + } + equals(other) { return ( this.locale === other.locale && diff --git a/src/impl/util.js b/src/impl/util.js index 51f756ea4..405192444 100644 --- a/src/impl/util.js +++ b/src/impl/util.js @@ -6,6 +6,7 @@ import { InvalidArgumentError } from "../errors.js"; import Settings from "../settings.js"; +import { dayOfWeek, isoWeekdayToLocal } from "./conversions.js"; /** * @private @@ -43,6 +44,18 @@ export function hasRelative() { } } +export function hasLocaleWeekInfo() { + try { + return ( + typeof Intl !== "undefined" && + !!Intl.Locale && + ("weekInfo" in Intl.Locale.prototype || "getWeekInfo" in Intl.Locale.prototype) + ); + } catch (e) { + return false; + } +} + // OBJECTS AND ARRAYS export function maybeArray(thing) { @@ -76,6 +89,28 @@ export function hasOwnProperty(obj, prop) { return Object.prototype.hasOwnProperty.call(obj, prop); } +export function validateWeekSettings(settings) { + if (settings == null) { + return null; + } else if (typeof settings !== "object") { + throw new InvalidArgumentError("Week settings must be an object"); + } else { + if ( + !integerBetween(settings.firstDay, 1, 7) || + !integerBetween(settings.minimalDays, 1, 7) || + !Array.isArray(settings.weekend) || + settings.weekend.some((v) => !integerBetween(v, 1, 7)) + ) { + throw new InvalidArgumentError("Invalid week settings"); + } + return { + firstDay: settings.firstDay, + minimalDays: settings.minimalDays, + weekend: Array.from(settings.weekend), + }; + } +} + // NUMBERS AND STRINGS export function integerBetween(thing, bottom, top) { @@ -174,16 +209,16 @@ export function objToLocalTS(obj) { return +d; } -export function weeksInWeekYear(weekYear) { - const p1 = - (weekYear + - Math.floor(weekYear / 4) - - Math.floor(weekYear / 100) + - Math.floor(weekYear / 400)) % - 7, - last = weekYear - 1, - p2 = (last + Math.floor(last / 4) - Math.floor(last / 100) + Math.floor(last / 400)) % 7; - return p1 === 4 || p2 === 3 ? 53 : 52; +// adapted from moment.js: https://github.com/moment/moment/blob/000ac1800e620f770f4eb31b5ae908f6167b0ab2/src/lib/units/week-calendar-utils.js +function firstWeekOffset(year, minDaysInFirstWeek, startOfWeek) { + const fwdlw = isoWeekdayToLocal(dayOfWeek(year, 1, minDaysInFirstWeek), startOfWeek); + return -fwdlw + minDaysInFirstWeek - 1; +} + +export function weeksInWeekYear(weekYear, minDaysInFirstWeek = 4, startOfWeek = 1) { + const weekOffset = firstWeekOffset(weekYear, minDaysInFirstWeek, startOfWeek); + const weekOffsetNext = firstWeekOffset(weekYear + 1, minDaysInFirstWeek, startOfWeek); + return (daysInYear(weekYear) - weekOffset + weekOffsetNext) / 7; } export function untruncateYear(year) { diff --git a/src/info.js b/src/info.js index d8c8e08a9..72124b470 100644 --- a/src/info.js +++ b/src/info.js @@ -4,7 +4,7 @@ import Locale from "./impl/locale.js"; import IANAZone from "./zones/IANAZone.js"; import { normalizeZone } from "./impl/zoneUtil.js"; -import { hasRelative } from "./impl/util.js"; +import { hasLocaleWeekInfo, hasRelative } from "./impl/util.js"; /** * The Info class contains static methods for retrieving general time and date related data. For example, it has methods for finding out if a time zone has a DST, for listing the months in any supported locale, and for discovering which of Luxon features are available in the current environment. @@ -48,6 +48,41 @@ export default class Info { return normalizeZone(input, Settings.defaultZone); } + /** + * Get the weekday on which the week starts according to the given locale. + * @param {Object} opts - options + * @param {string} [opts.locale] - the locale code + * @param {string} [opts.locObj=null] - an existing locale object to use + * @returns {number} the start of the week, 1 for Monday through 7 for Sunday + */ + static getStartOfWeek({ locale = null, locObj = null } = {}) { + return (locObj || Locale.create(locale)).getStartOfWeek(); + } + + /** + * Get the minimum number of days necessary in a week before it is considered part of the next year according + * to the given locale. + * @param {Object} opts - options + * @param {string} [opts.locale] - the locale code + * @param {string} [opts.locObj=null] - an existing locale object to use + * @returns {number} + */ + static getMinimumDaysInFirstWeek({ locale = null, locObj = null } = {}) { + return (locObj || Locale.create(locale)).getMinDaysInFirstWeek(); + } + + /** + * Get the weekdays, which are considered the weekend according to the given locale + * @param {Object} opts - options + * @param {string} [opts.locale] - the locale code + * @param {string} [opts.locObj=null] - an existing locale object to use + * @returns {number[]} an array of weekdays, 1 for Monday through 7 for Sunday + */ + static getWeekendWeekdays({ locale = null, locObj = null } = {}) { + // copy the array, because we cache it internally + return (locObj || Locale.create(locale)).getWeekendDays().slice(); + } + /** * Return an array of standalone month names. * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/DateTimeFormat @@ -160,10 +195,11 @@ export default class Info { * Some features of Luxon are not available in all environments. For example, on older browsers, relative time formatting support is not available. Use this function to figure out if that's the case. * Keys: * * `relative`: whether this environment supports relative time formatting - * @example Info.features() //=> { relative: false } + * * `localeWeek`: whether this environment supports different weekdays for the start of the week based on the locale + * @example Info.features() //=> { relative: false, localeWeek: true } * @return {Object} */ static features() { - return { relative: hasRelative() }; + return { relative: hasRelative(), localeWeek: hasLocaleWeekInfo() }; } } diff --git a/src/interval.js b/src/interval.js index 33caf6d4d..594bf2c7b 100644 --- a/src/interval.js +++ b/src/interval.js @@ -234,12 +234,20 @@ export default class Interval { * Unlike {@link Interval#length} this counts sections of the calendar, not periods of time, e.g. specifying 'day' * asks 'what dates are included in this interval?', not 'how many days long is this interval?' * @param {string} [unit='milliseconds'] - the unit of time to count. + * @param {Object} opts - options + * @param {boolean} [opts.useLocaleWeeks=false] - If true, use weeks based on the locale, i.e. use the locale-dependent start of the week; this operation will always use the locale of the start DateTime * @return {number} */ - count(unit = "milliseconds") { + count(unit = "milliseconds", opts) { if (!this.isValid) return NaN; - const start = this.start.startOf(unit), - end = this.end.startOf(unit); + const start = this.start.startOf(unit, opts); + let end; + if (opts?.useLocaleWeeks) { + end = this.end.reconfigure({ locale: start.locale }); + } else { + end = this.end; + } + end = end.startOf(unit, opts); return Math.floor(end.diff(start, unit).get(unit)) + (end.valueOf() !== this.end.valueOf()); } diff --git a/src/settings.js b/src/settings.js index a36c371d3..462e71e61 100644 --- a/src/settings.js +++ b/src/settings.js @@ -3,6 +3,7 @@ import IANAZone from "./zones/IANAZone.js"; import Locale from "./impl/locale.js"; import { normalizeZone } from "./impl/zoneUtil.js"; +import { validateWeekSettings } from "./impl/util.js"; let now = () => Date.now(), defaultZone = "system", @@ -10,7 +11,8 @@ let now = () => Date.now(), defaultNumberingSystem = null, defaultOutputCalendar = null, twoDigitCutoffYear = 60, - throwOnInvalid; + throwOnInvalid, + defaultWeekSettings = null; /** * Settings contains static getters and setters that control Luxon's overall behavior. Luxon is a simple library with few options, but the ones it does have live here. @@ -101,6 +103,31 @@ export default class Settings { defaultOutputCalendar = outputCalendar; } + /** + * @typedef {Object} WeekSettings + * @property {number} firstDay + * @property {number} minimalDays + * @property {number[]} weekend + */ + + /** + * @return {WeekSettings|null} + */ + static get defaultWeekSettings() { + return defaultWeekSettings; + } + + /** + * Allows overriding the default locale week settings, i.e. the start of the week, the weekend and + * how many days are required in the first week of a year. + * Does not affect existing instances. + * + * @param {WeekSettings|null} weekSettings + */ + static set defaultWeekSettings(weekSettings) { + defaultWeekSettings = validateWeekSettings(weekSettings); + } + /** * Get the cutoff year after which a string encoding a year as two digits is interpreted to occur in the current century. * @type {number} diff --git a/test/datetime/create.test.js b/test/datetime/create.test.js index 016266a69..67613a610 100644 --- a/test/datetime/create.test.js +++ b/test/datetime/create.test.js @@ -595,6 +595,77 @@ test("DateTime.fromObject() w/weeks defaults low-order values to their minimums" expect(dt.millisecond).toBe(0); }); +test("DateTime.fromObject() w/locale weeks defaults low-order values to their minimums", () => { + const dt = DateTime.fromObject({ localWeekYear: 2016 }, { locale: "en-US" }); + + expect(dt.localWeekYear).toBe(2016); + expect(dt.localWeekNumber).toBe(1); + expect(dt.localWeekday).toBe(1); + expect(dt.hour).toBe(0); + expect(dt.minute).toBe(0); + expect(dt.second).toBe(0); + expect(dt.millisecond).toBe(0); +}); + +test("DateTime.fromObject() w/locale weeks defaults high-order values to the current date", () => { + const dt = DateTime.fromObject({ localWeekday: 2 }, { locale: "en-US" }), + now = DateTime.local({ locale: "en-US" }); + + expect(dt.localWeekYear).toBe(now.localWeekYear); + expect(dt.localWeekNumber).toBe(now.localWeekNumber); + expect(dt.localWeekday).toBe(2); +}); + +test("DateTime.fromObject() w/locale weeks handles fully specified dates", () => { + const dt = DateTime.fromObject( + { + localWeekYear: 2022, + localWeekNumber: 2, + localWeekday: 3, + hour: 9, + minute: 23, + second: 54, + millisecond: 123, + }, + { locale: "en-US" } + ); + expect(dt.localWeekYear).toBe(2022); + expect(dt.localWeekNumber).toBe(2); + expect(dt.localWeekday).toBe(3); + expect(dt.year).toBe(2022); + expect(dt.month).toBe(1); + expect(dt.day).toBe(4); +}); + +test("DateTime.fromObject() w/localWeekYears handles skew with Gregorian years", () => { + let dt = DateTime.fromObject( + { localWeekYear: 2022, localWeekNumber: 1, localWeekday: 1 }, + { locale: "en-US" } + ); + expect(dt.localWeekYear).toBe(2022); + expect(dt.localWeekNumber).toBe(1); + expect(dt.localWeekday).toBe(1); + expect(dt.year).toBe(2021); + expect(dt.month).toBe(12); + expect(dt.day).toBe(26); + + dt = DateTime.fromObject( + { localWeekYear: 2009, localWeekNumber: 53, localWeekday: 5 }, + { locale: "de-DE" } + ); + expect(dt.localWeekYear).toBe(2009); + expect(dt.localWeekNumber).toBe(53); + expect(dt.localWeekday).toBe(5); + expect(dt.year).toBe(2010); + expect(dt.month).toBe(1); + expect(dt.day).toBe(1); +}); + +test("DateTime.fromObject throws when both locale based weeks and ISO-weeks are specified", () => { + expect(() => DateTime.fromObject({ localWeekYear: 2022, weekNumber: 12 })).toThrow(); + expect(() => DateTime.fromObject({ localWeekYear: 2022, weekday: 2 })).toThrow(); +}); + test("DateTime.fromObject() w/ordinals handles fully specified dates", () => { const dt = DateTime.fromObject({ year: 2016, diff --git a/test/datetime/localeWeek.test.js b/test/datetime/localeWeek.test.js new file mode 100644 index 000000000..889bc0ffa --- /dev/null +++ b/test/datetime/localeWeek.test.js @@ -0,0 +1,269 @@ +/* global test expect */ + +import { DateTime, Info } from "../../src/luxon"; +import Helpers from "../helpers"; + +const withDefaultWeekSettings = Helpers.setUnset("defaultWeekSettings"); + +//------ +// .startOf() with useLocaleWeeks +//------ +test("startOf(week) with useLocaleWeeks adheres to the locale", () => { + const dt = DateTime.fromISO("2023-06-14T13:00:00Z", { setZone: true }); + expect( + dt.reconfigure({ locale: "de-DE" }).startOf("week", { useLocaleWeeks: true }).toISO() + ).toBe("2023-06-12T00:00:00.000Z"); + expect( + dt.reconfigure({ locale: "en-US" }).startOf("week", { useLocaleWeeks: true }).toISO() + ).toBe("2023-06-11T00:00:00.000Z"); +}); + +test("startOf(week) with useLocaleWeeks handles crossing into the previous year", () => { + const dt = DateTime.fromISO("2023-01-01T13:00:00Z", { setZone: true }); + expect( + dt.reconfigure({ locale: "de-DE" }).startOf("week", { useLocaleWeeks: true }).toISO() + ).toBe("2022-12-26T00:00:00.000Z"); +}); + +//------ +// .endOf() with useLocaleWeeks +//------ +test("endOf(week) with useLocaleWeeks adheres to the locale", () => { + const dt = DateTime.fromISO("2023-06-14T13:00:00Z", { setZone: true }); + expect(dt.reconfigure({ locale: "de-DE" }).endOf("week", { useLocaleWeeks: true }).toISO()).toBe( + "2023-06-18T23:59:59.999Z" + ); + expect(dt.reconfigure({ locale: "en-US" }).endOf("week", { useLocaleWeeks: true }).toISO()).toBe( + "2023-06-17T23:59:59.999Z" + ); +}); + +test("endOf(week) with useLocaleWeeks handles crossing into the next year", () => { + const dt = DateTime.fromISO("2022-12-31T13:00:00Z", { setZone: true }); + expect(dt.reconfigure({ locale: "de-DE" }).endOf("week", { useLocaleWeeks: true }).toISO()).toBe( + "2023-01-01T23:59:59.999Z" + ); +}); + +//------ +// .hasSame() with useLocaleWeeks +//------ +test("hasSame(week) with useLocaleWeeks adheres to the locale", () => { + const dt1 = DateTime.fromISO("2023-06-11T03:00:00Z", { setZone: true, locale: "en-US" }); + const dt2 = DateTime.fromISO("2023-06-14T03:00:00Z", { setZone: true, locale: "en-US" }); + expect(dt1.hasSame(dt2, "week", { useLocaleWeeks: true })).toBe(true); + + const dt3 = DateTime.fromISO("2023-06-14T03:00:00Z", { setZone: true, locale: "en-US" }); + const dt4 = DateTime.fromISO("2023-06-18T03:00:00Z", { setZone: true, locale: "en-US" }); + expect(dt3.hasSame(dt4, "week", { useLocaleWeeks: true })).toBe(false); +}); + +test("hasSame(week) with useLocaleWeeks ignores the locale of otherDateTime", () => { + const dt1 = DateTime.fromISO("2023-06-11T03:00:00Z", { setZone: true, locale: "en-US" }); + const dt2 = DateTime.fromISO("2023-06-14T03:00:00Z", { setZone: true, locale: "de-DE" }); + expect(dt1.hasSame(dt2, "week", { useLocaleWeeks: true })).toBe(true); + expect(dt2.hasSame(dt1, "week", { useLocaleWeeks: true })).toBe(false); +}); + +//------ +// .isWeekend +//------ + +const week = [ + "2023-07-31T00:00:00Z", // Monday + "2023-08-01T00:00:00Z", + "2023-08-02T00:00:00Z", + "2023-08-03T00:00:00Z", + "2023-08-04T00:00:00Z", + "2023-08-05T00:00:00Z", + "2023-08-06T00:00:00Z", // Sunday +]; +test("isWeekend in locale en-US reports Saturday and Sunday as weekend", () => { + const dates = week.map( + (iso) => DateTime.fromISO(iso, { setZone: true, locale: "en-US" }).isWeekend + ); + expect(dates).toStrictEqual([false, false, false, false, false, true, true]); +}); + +test("isWeekend in locale he reports Friday and Saturday as weekend", () => { + const dates = week.map((iso) => DateTime.fromISO(iso, { setZone: true, locale: "he" }).isWeekend); + expect(dates).toStrictEqual([false, false, false, false, true, true, false]); +}); + +//------ +// .localWeekNumber / .localWeekYear +//------ +describe("localWeekNumber in locale de-DE", () => { + test("Jan 1 2012 should be week 52, year 2011", () => { + const dt = DateTime.fromISO("2012-01-01", { locale: "de-DE" }); + expect(dt.localWeekNumber).toBe(52); + expect(dt.localWeekYear).toBe(2011); + }); + test("Jan 2 2012 should be week 1, year 2012", () => { + const dt = DateTime.fromISO("2012-01-02", { locale: "de-DE" }); + expect(dt.localWeekNumber).toBe(1); + expect(dt.localWeekYear).toBe(2012); + }); + test("Jan 8 2012 should be week 1, year 2012", () => { + const dt = DateTime.fromISO("2012-01-08", { locale: "de-DE" }); + expect(dt.localWeekNumber).toBe(1); + expect(dt.localWeekYear).toBe(2012); + }); + test("Jan 9 2012 should be week 2, year 2012", () => { + const dt = DateTime.fromISO("2012-01-09", { locale: "de-DE" }); + expect(dt.localWeekNumber).toBe(2); + expect(dt.localWeekYear).toBe(2012); + }); + test("Jan 15 2012 should be week 2, year 2012", () => { + const dt = DateTime.fromISO("2012-01-15", { locale: "de-DE" }); + expect(dt.localWeekNumber).toBe(2); + expect(dt.localWeekYear).toBe(2012); + }); +}); + +describe("localWeekNumber in locale en-US", () => { + test("Jan 1 2012 should be week 1, year 2012", () => { + const dt = DateTime.fromISO("2012-01-01", { locale: "en-US" }); + expect(dt.localWeekNumber).toBe(1); + expect(dt.localWeekYear).toBe(2012); + }); + test("Jan 7 2012 should be week 1, year 2012", () => { + const dt = DateTime.fromISO("2012-01-07", { locale: "en-US" }); + expect(dt.localWeekNumber).toBe(1); + expect(dt.localWeekYear).toBe(2012); + }); + test("Jan 8 2012 should be week 2, year 2012", () => { + const dt = DateTime.fromISO("2012-01-08", { locale: "en-US" }); + expect(dt.localWeekNumber).toBe(2); + expect(dt.localWeekYear).toBe(2012); + }); + test("Jan 14 2012 should be week 2, year 2012", () => { + const dt = DateTime.fromISO("2012-01-14", { locale: "en-US" }); + expect(dt.localWeekNumber).toBe(2); + expect(dt.localWeekYear).toBe(2012); + }); + test("Jan 15 2012 should be week 3, year 2012", () => { + const dt = DateTime.fromISO("2012-01-15", { locale: "en-US" }); + expect(dt.localWeekNumber).toBe(3); + expect(dt.localWeekYear).toBe(2012); + }); +}); + +//------ +// .localWeekday +//------ +describe("localWeekday in locale en-US", () => { + test("Sunday should be reported as the 1st day of the week", () => { + const dt = DateTime.fromISO("2023-08-06", { locale: "en-US" }); + expect(dt.localWeekday).toBe(1); + }); + test("Monday should be reported as the 2nd day of the week", () => { + const dt = DateTime.fromISO("2023-08-07", { locale: "en-US" }); + expect(dt.localWeekday).toBe(2); + }); + test("Tuesday should be reported as the 3rd day of the week", () => { + const dt = DateTime.fromISO("2023-08-08", { locale: "en-US" }); + expect(dt.localWeekday).toBe(3); + }); + test("Wednesday should be reported as the 4th day of the week", () => { + const dt = DateTime.fromISO("2023-08-09", { locale: "en-US" }); + expect(dt.localWeekday).toBe(4); + }); + test("Thursday should be reported as the 5th day of the week", () => { + const dt = DateTime.fromISO("2023-08-10", { locale: "en-US" }); + expect(dt.localWeekday).toBe(5); + }); + test("Friday should be reported as the 6th day of the week", () => { + const dt = DateTime.fromISO("2023-08-11", { locale: "en-US" }); + expect(dt.localWeekday).toBe(6); + }); + test("Saturday should be reported as the 7th day of the week", () => { + const dt = DateTime.fromISO("2023-08-12", { locale: "en-US" }); + expect(dt.localWeekday).toBe(7); + }); +}); + +describe("localWeekday in locale de-DE", () => { + test("Monday should be reported as the 1st day of the week", () => { + const dt = DateTime.fromISO("2023-08-07", { locale: "de-DE" }); + expect(dt.localWeekday).toBe(1); + }); + test("Tuesday should be reported as the 2nd day of the week", () => { + const dt = DateTime.fromISO("2023-08-08", { locale: "de-DE" }); + expect(dt.localWeekday).toBe(2); + }); + test("Wednesday should be reported as the 3rd day of the week", () => { + const dt = DateTime.fromISO("2023-08-09", { locale: "de-DE" }); + expect(dt.localWeekday).toBe(3); + }); + test("Thursday should be reported as the 4th day of the week", () => { + const dt = DateTime.fromISO("2023-08-10", { locale: "de-DE" }); + expect(dt.localWeekday).toBe(4); + }); + test("Friday should be reported as the 5th day of the week", () => { + const dt = DateTime.fromISO("2023-08-11", { locale: "de-DE" }); + expect(dt.localWeekday).toBe(5); + }); + test("Saturday should be reported as the 6th day of the week", () => { + const dt = DateTime.fromISO("2023-08-12", { locale: "de-DE" }); + expect(dt.localWeekday).toBe(6); + }); + test("Sunday should be reported as the 7th day of the week", () => { + const dt = DateTime.fromISO("2023-08-13", { locale: "de-DE" }); + expect(dt.localWeekday).toBe(7); + }); +}); + +describe("weeksInLocalWeekYear", () => { + test("2018 should have 53 weeks in en-US", () => { + expect(DateTime.local(2018, 6, 1, { locale: "en-US" }).weeksInLocalWeekYear).toBe(52); + }); + test("2022 should have 53 weeks in en-US", () => { + expect(DateTime.local(2022, 6, 1, { locale: "en-US" }).weeksInLocalWeekYear).toBe(53); + }); + test("2022 should have 52 weeks in de-DE", () => { + expect(DateTime.local(2022, 6, 1, { locale: "de-DE" }).weeksInLocalWeekYear).toBe(52); + }); + test("2020 should have 53 weeks in de-DE", () => { + expect(DateTime.local(2020, 6, 1, { locale: "de-DE" }).weeksInLocalWeekYear).toBe(53); + }); +}); + +describe("Week settings can be overridden", () => { + test("Overridden week info should be reported by Info", () => { + withDefaultWeekSettings({ firstDay: 3, minimalDays: 5, weekend: [4, 6] }, () => { + expect(Info.getStartOfWeek()).toBe(3); + expect(Info.getMinimumDaysInFirstWeek()).toBe(5); + expect(Info.getWeekendWeekdays()).toEqual([4, 6]); + }); + }); + + test("Overridden week info should be reported by DateTime#isWeekend", () => { + withDefaultWeekSettings({ firstDay: 7, minimalDays: 1, weekend: [1, 3] }, () => { + expect(DateTime.local(2022, 1, 31).isWeekend).toBe(true); + expect(DateTime.local(2022, 2, 1).isWeekend).toBe(false); + expect(DateTime.local(2022, 2, 2).isWeekend).toBe(true); + expect(DateTime.local(2022, 2, 3).isWeekend).toBe(false); + expect(DateTime.local(2022, 2, 4).isWeekend).toBe(false); + expect(DateTime.local(2022, 2, 5).isWeekend).toBe(false); + expect(DateTime.local(2022, 2, 6).isWeekend).toBe(false); + }); + }); + test("Overridden week info should be respected by DateTime accessors", () => { + withDefaultWeekSettings({ firstDay: 7, minimalDays: 1, weekend: [6, 7] }, () => { + const dt = DateTime.local(2022, 1, 1, { locale: "de-DE" }); + expect(dt.localWeekday).toBe(7); + expect(dt.localWeekNumber).toBe(1); + expect(dt.localWeekYear).toBe(2022); + }); + }); + test("Overridden week info should be respected by DateTime#set", () => { + withDefaultWeekSettings({ firstDay: 7, minimalDays: 1, weekend: [6, 7] }, () => { + const dt = DateTime.local(2022, 1, 1, { locale: "de-DE" }); + const modified = dt.set({ localWeekday: 1 }); + expect(modified.year).toBe(2021); + expect(modified.month).toBe(12); + expect(modified.day).toBe(26); + }); + }); +}); diff --git a/test/datetime/set.test.js b/test/datetime/set.test.js index 9da2c52c9..bbc070e1c 100644 --- a/test/datetime/set.test.js +++ b/test/datetime/set.test.js @@ -86,6 +86,120 @@ test("DateTime#set({ weekday }) handles week year edge cases", () => { endOfWeekIs("2028-01-01", "2028-01-02"); }); +//------ +// locale-based week units +//------ + +test("DateTime#set({ localWeekday }) sets the weekday to this week's matching day based on the locale (en-US)", () => { + const modified = dt.reconfigure({ locale: "en-US" }).set({ localWeekday: 1 }); + expect(modified.localWeekday).toBe(1); + expect(modified.weekday).toBe(7); + expect(modified.year).toBe(1982); + expect(modified.month).toBe(5); + expect(modified.day).toBe(23); + expect(modified.hour).toBe(9); + expect(modified.minute).toBe(23); + expect(modified.second).toBe(54); + expect(modified.millisecond).toBe(123); +}); + +test("DateTime#set({ localWeekday }) sets the weekday to this week's matching day based on the locale (de-DE)", () => { + const modified = dt.reconfigure({ locale: "de-DE" }).set({ localWeekday: 1 }); + expect(modified.localWeekday).toBe(1); + expect(modified.weekday).toBe(1); + expect(modified.year).toBe(1982); + expect(modified.month).toBe(5); + expect(modified.day).toBe(24); + expect(modified.hour).toBe(9); + expect(modified.minute).toBe(23); + expect(modified.second).toBe(54); + expect(modified.millisecond).toBe(123); +}); + +test("DateTime#set({ localWeekday }) handles crossing over into the previous year", () => { + const modified = DateTime.local(2022, 1, 1, 9, 23, 54, 123, { locale: "en-US" }).set({ + localWeekday: 2, + }); + expect(modified.localWeekday).toBe(2); + expect(modified.weekday).toBe(1); + expect(modified.year).toBe(2021); + expect(modified.month).toBe(12); + expect(modified.day).toBe(27); + expect(modified.hour).toBe(9); + expect(modified.minute).toBe(23); + expect(modified.second).toBe(54); + expect(modified.millisecond).toBe(123); +}); + +test("DateTime#set({ localWeekday }) handles crossing over into the previous year", () => { + const modified = DateTime.local(2022, 1, 1, 9, 23, 54, 123, { locale: "en-US" }).set({ + localWeekday: 2, + }); + expect(modified.localWeekday).toBe(2); + expect(modified.weekday).toBe(1); + expect(modified.year).toBe(2021); + expect(modified.month).toBe(12); + expect(modified.day).toBe(27); + expect(modified.hour).toBe(9); + expect(modified.minute).toBe(23); + expect(modified.second).toBe(54); + expect(modified.millisecond).toBe(123); +}); + +test("DateTime#set({ localWeekNumber }) sets the date to the same weekday of the target weekNumber (en-US)", () => { + const modified = dt.reconfigure({ locale: "en-US" }).set({ localWeekNumber: 2 }); + expect(modified.weekday).toBe(2); // still tuesday + expect(modified.localWeekNumber).toBe(2); + expect(modified.year).toBe(1982); + expect(modified.month).toBe(1); + expect(modified.day).toBe(5); + expect(modified.hour).toBe(9); + expect(modified.minute).toBe(23); + expect(modified.second).toBe(54); + expect(modified.millisecond).toBe(123); +}); + +test("DateTime#set({ localWeekNumber }) sets the date to the same weekday of the target weekNumber (de-DE)", () => { + const modified = dt.reconfigure({ locale: "de-DE" }).set({ localWeekNumber: 2 }); + expect(modified.weekday).toBe(2); // still tuesday + expect(modified.localWeekNumber).toBe(2); + expect(modified.year).toBe(1982); + expect(modified.month).toBe(1); + expect(modified.day).toBe(12); + expect(modified.hour).toBe(9); + expect(modified.minute).toBe(23); + expect(modified.second).toBe(54); + expect(modified.millisecond).toBe(123); +}); + +test("DateTime#set({ localWeekYear }) sets the date to the same weekNumber/weekday of the target weekYear (en-US)", () => { + const modified = dt.reconfigure({ locale: "en-US" }).set({ localWeekYear: 2017 }); + expect(modified.localWeekday).toBe(3); // still tuesday + expect(modified.localWeekNumber).toBe(22); // still week 22 + expect(modified.localWeekYear).toBe(2017); + expect(modified.year).toBe(2017); + expect(modified.month).toBe(5); + expect(modified.day).toBe(30); // 2017-W22-3 is the 30 + expect(modified.hour).toBe(9); + expect(modified.minute).toBe(23); + expect(modified.second).toBe(54); + expect(modified.millisecond).toBe(123); +}); + +test("DateTime#set({ localWeekYear }) sets the date to the same weekNumber/weekday of the target weekYear (de-DE)", () => { + const modified = dt.reconfigure({ locale: "de-DE" }).set({ localWeekYear: 2017 }); + expect(modified.localWeekday).toBe(2); // still tuesday + expect(modified.localWeekNumber).toBe(21); // still week 21 + expect(modified.localWeekYear).toBe(2017); + expect(modified.year).toBe(2017); + expect(modified.month).toBe(5); + expect(modified.day).toBe(23); // 2017-W21-2 is the 30 + expect(modified.hour).toBe(9); + expect(modified.minute).toBe(23); + expect(modified.second).toBe(54); + expect(modified.millisecond).toBe(123); +}); + //------ // year/ordinal //------ @@ -127,6 +241,10 @@ test("DateTime#set throws for mixing incompatible units", () => { expect(() => dt.set({ year: 2020, weekNumber: 22 })).toThrow(); expect(() => dt.set({ ordinal: 200, weekNumber: 22 })).toThrow(); expect(() => dt.set({ ordinal: 200, month: 8 })).toThrow(); + expect(() => dt.set({ year: 2020, localWeekNumber: 22 })).toThrow(); + expect(() => dt.set({ ordinal: 200, localWeekNumber: 22 })).toThrow(); + expect(() => dt.set({ weekday: 2, localWeekNumber: 22 })).toThrow(); + expect(() => dt.set({ weekday: 2, localWeekYear: 2022 })).toThrow(); }); test("DateTime#set maintains invalidity", () => { diff --git a/test/datetime/toFormat.test.js b/test/datetime/toFormat.test.js index 7f5b20b7e..3126ccf49 100644 --- a/test/datetime/toFormat.test.js +++ b/test/datetime/toFormat.test.js @@ -553,3 +553,23 @@ test("DateTime#toFormat('X') rounds down", () => { test("DateTime#toFormat('x') returns a Unix timestamp in milliseconds", () => { expect(dt.toFormat("x")).toBe("391166634123"); }); + +test("DateTime#toFormat('n')", () => { + expect(DateTime.fromISO("2012-01-01", { locale: "de-DE" }).toFormat("n")).toBe("52"); + expect(DateTime.fromISO("2012-01-01", { locale: "en-US" }).toFormat("n")).toBe("1"); +}); + +test("DateTime#toFormat('nn')", () => { + expect(DateTime.fromISO("2012-01-01", { locale: "de-DE" }).toFormat("nn")).toBe("52"); + expect(DateTime.fromISO("2012-01-01", { locale: "en-US" }).toFormat("nn")).toBe("01"); +}); + +test("DateTime#toFormat('ii')", () => { + expect(DateTime.fromISO("2012-01-01", { locale: "de-DE" }).toFormat("ii")).toBe("11"); + expect(DateTime.fromISO("2012-01-01", { locale: "en-US" }).toFormat("ii")).toBe("12"); +}); + +test("DateTime#toFormat('iiii')", () => { + expect(DateTime.fromISO("2012-01-01", { locale: "de-DE" }).toFormat("iiii")).toBe("2011"); + expect(DateTime.fromISO("2012-01-01", { locale: "en-US" }).toFormat("iiii")).toBe("2012"); +}); diff --git a/test/helpers.js b/test/helpers.js index abc2fbe42..b3258e944 100644 --- a/test/helpers.js +++ b/test/helpers.js @@ -15,6 +15,20 @@ exports.withoutRTF = function (name, f) { }); }; +exports.withoutLocaleWeekInfo = function (name, f) { + const fullName = `With no Intl.Locale.weekInfo support, ${name}`; + test(fullName, () => { + const l = Intl.Locale; + try { + Intl.Locale = undefined; + Settings.resetCaches(); + f(); + } finally { + Intl.Locale = l; + } + }); +}; + exports.withNow = function (name, dt, f) { test(name, () => { const oldNow = Settings.now; diff --git a/test/info/features.test.js b/test/info/features.test.js index f31b478c3..53456d419 100644 --- a/test/info/features.test.js +++ b/test/info/features.test.js @@ -5,8 +5,13 @@ const Helpers = require("../helpers"); test("Info.features shows this environment supports all the features", () => { expect(Info.features().relative).toBe(true); + expect(Info.features().localeWeek).toBe(true); }); Helpers.withoutRTF("Info.features shows no support", () => { expect(Info.features().relative).toBe(false); }); + +Helpers.withoutLocaleWeekInfo("Info.features shows no support", () => { + expect(Info.features().localeWeek).toBe(false); +}); diff --git a/test/info/localeWeek.test.js b/test/info/localeWeek.test.js new file mode 100644 index 000000000..a7882d73d --- /dev/null +++ b/test/info/localeWeek.test.js @@ -0,0 +1,54 @@ +/* global test expect */ +import { Info } from "../../src/luxon"; + +const Helpers = require("../helpers"); + +test("Info.getStartOfWeek reports the correct start of the week", () => { + expect(Info.getStartOfWeek({ locale: "en-US" })).toBe(7); + expect(Info.getStartOfWeek({ locale: "de-DE" })).toBe(1); +}); + +Helpers.withoutLocaleWeekInfo("Info.getStartOfWeek reports Monday as the start of the week", () => { + expect(Info.getStartOfWeek({ locale: "en-US" })).toBe(1); + expect(Info.getStartOfWeek({ locale: "de-DE" })).toBe(1); +}); + +test("Info.getMinimumDaysInFirstWeek reports the correct value", () => { + expect(Info.getMinimumDaysInFirstWeek({ locale: "en-US" })).toBe(1); + expect(Info.getMinimumDaysInFirstWeek({ locale: "de-DE" })).toBe(4); +}); + +Helpers.withoutLocaleWeekInfo("Info.getMinimumDaysInFirstWeek reports 4", () => { + expect(Info.getMinimumDaysInFirstWeek({ locale: "en-US" })).toBe(4); + expect(Info.getMinimumDaysInFirstWeek({ locale: "de-DE" })).toBe(4); +}); + +test("Info.getWeekendWeekdays reports the correct value", () => { + expect(Info.getWeekendWeekdays({ locale: "en-US" })).toStrictEqual([6, 7]); + expect(Info.getWeekendWeekdays({ locale: "he" })).toStrictEqual([5, 6]); +}); + +Helpers.withoutLocaleWeekInfo("Info.getWeekendWeekdays reports [6, 7]", () => { + expect(Info.getWeekendWeekdays({ locale: "en-US" })).toStrictEqual([6, 7]); + expect(Info.getWeekendWeekdays({ locale: "he" })).toStrictEqual([6, 7]); +}); + +test("Info.getStartOfWeek honors the default locale", () => { + Helpers.withDefaultLocale("en-US", () => { + expect(Info.getStartOfWeek()).toBe(7); + expect(Info.getMinimumDaysInFirstWeek()).toBe(1); + expect(Info.getWeekendWeekdays()).toStrictEqual([6, 7]); + }); + + Helpers.withDefaultLocale("de-DE", () => { + expect(Info.getStartOfWeek()).toBe(1); + }); + + Helpers.withDefaultLocale("he", () => { + expect(Info.getWeekendWeekdays()).toStrictEqual([5, 6]); + }); + + Helpers.withDefaultLocale("he", () => { + expect(Info.getWeekendWeekdays()).toStrictEqual([5, 6]); + }); +}); diff --git a/test/interval/localeWeek.test.js b/test/interval/localeWeek.test.js new file mode 100644 index 000000000..4c7f95756 --- /dev/null +++ b/test/interval/localeWeek.test.js @@ -0,0 +1,22 @@ +/* global test expect */ + +import { DateTime, Interval } from "../../src/luxon"; + +//------ +// .count() with useLocaleWeeks +//------ +test("count(weeks) with useLocaleWeeks adheres to the locale", () => { + const start = DateTime.fromISO("2023-06-04T13:00:00Z", { setZone: true, locale: "en-US" }); + const end = DateTime.fromISO("2023-06-23T13:00:00Z", { setZone: true, locale: "en-US" }); + const interval = Interval.fromDateTimes(start, end); + + expect(interval.count("weeks", { useLocaleWeeks: true })).toBe(3); +}); + +test("count(weeks) with useLocaleWeeks uses the start locale", () => { + const start = DateTime.fromISO("2023-06-04T13:00:00Z", { setZone: true, locale: "de-DE" }); + const end = DateTime.fromISO("2023-06-23T13:00:00Z", { setZone: true, locale: "en-US" }); + const interval = Interval.fromDateTimes(start, end); + + expect(interval.count("weeks", { useLocaleWeeks: true })).toBe(4); +});