Repository: moment/luxon
Pull request #1320 (2022-11-09 08:52:37)

Add Interval.toLocaleString()
Hi,

`Interval.toLocaleString(formatOpts, opts)` works like `DateTime.toLocaleString(formatOpts, opts)`. Example:

```js
Interval.fromISO('2022-11-07T09:00Z/2022-11-08T09:00Z').toLocaleString(DateTime.DATE_FULL, { locale: 'fr-FR' });
//=> 7–8 novembre 2022
```

It relies on `Intl.DateTimeFormat.formatRange()`, now [well supported by browsers](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat/formatRange#browser_compatibility).
Partly closes [luxon/issues#1237](https://github.com/moment/luxon/issues/1237) (I can add `toLocaleParts` in this PR or a different one). Closes [luxon/issues#691](https://github.com/moment/luxon/issues/325).

linux-foundation-easycla[bot]:  <a href="https://api.easycla.lfx.linuxfoundation.org/v2/repository-provider/github/sign/19772872/47118129/1320/#/?version=2"><img src="https://s3.amazonaws.com/cla-project-logo-prod/cla-not-signed.svg" alt="CLA Not Signed" align="left" height="28" width="328"></a><br/><br /><ul><li><a href='https://api.easycla.lfx.linuxfoundation.org/v2/repository-provider/github/sign/19772872/47118129/1320/#/?version=2' target='_blank'>:x:</a> - login: @fodier / name: Florent Odier . The commit (c932b3cab9ddaddaffac05d6fcfc9d6a01b3400d, bbe663fde959d28524e06b1176468d7d0a38da55) is not authorized under a signed CLA. <a href='https://api.easycla.lfx.linuxfoundation.org/v2/repository-provider/github/sign/19772872/47118129/1320/#/?version=2' target='_blank'>Please click here to be authorized</a>. For further assistance with EasyCLA, <a href='https://jira.linuxfoundation.org/servicedesk/customer/portal/4' target='_blank'>please submit a support request ticket</a>.</li></ul>

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f2182944..e21fad5e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,9 @@
 # Changelog
 
+# 3.1.1 (2022-11-28)
+
+* Add Settings.twoDigitCutoffYear
+
 # 3.1.0 (2022-10-31)
 
 * Add Duration.rescale
diff --git a/docs/parsing.md b/docs/parsing.md
index d5aa70a9..2b2607a1 100644
--- a/docs/parsing.md
+++ b/docs/parsing.md
@@ -125,6 +125,10 @@ Not every token supported by `DateTime#toFormat` is supported in the parser. For
 - Some things are ambiguous. There are several Eastern Standard Times in different countries and Luxon has no way to know which one you mean without additional information (such as that the zone is America/New_York) that would make EST superfluous anyway. Similarly, the single-letter month and weekday formats (EEEEE) that are useful in displaying calendars graphically can't be parsed because of their ambiguity.
 - Because of the limitations above, Luxon also doesn't support the "macro" tokens that include offset names, such as "ttt" and "FFFF".
 
+### Parsing Two Digit Years
+
+In order to parse a two digit string as a year (or ISO week year), the `yy` (and `kk`) token must use a cutoff year after which the year being parsed is interpreted as referring to the current century. By default, the cutoff year is set to 60, meaning that the string "60" is parsed as the year 2060, and the string "61" is parsed as the year 1961. The cutoff year can be configured using `Settings.twoDigitCutoffYear`.
+
 ### Debugging
 
 There are two kinds of things that can go wrong when parsing a string: a) you make a mistake with the tokens or b) the information parsed from the string does not correspond to a valid date. To help you sort that out, Luxon provides a method called `fromFormatExplain`. It takes the same arguments as `fromFormat` but returns a map of information about the parse that can be useful in debugging.
@@ -180,60 +184,60 @@ Because Luxon was able to parse the string without difficulty, the output is a l
 (Examples below given for `2014-08-06T13:07:04.054` considered as a local time in America/New_York). Note that many tokens supported by the [formatter](formatting.md) are **not** supported by the parser.
 
 | Standalone token | Format token | Description                                                    | Example                     |
-| ---------------- | ------------ | -------------------------------------------------------------- | --------------------------- |
-| S                |              | millisecond, no padding                                        | `54`                        |
-| SSS              |              | millisecond, padded to 3                                       | `054`                       |
-| u                |              | fractional seconds, (5 is a half second, 54 is slightly more)  | `54`                        |
-| uu               |              | fractional seconds, (one or two digits)                        | `05`                        |
-| uuu              |              | fractional seconds, (only one digit)                           | `5`                         |
-| s                |              | second, no padding                                             | `4`                         |
-| ss               |              | second, padded to 2 padding                                    | `04`                        |
-| m                |              | minute, no padding                                             | `7`                         |
-| mm               |              | minute, padded to 2                                            | `07`                        |
-| h                |              | hour in 12-hour time, no padding                               | `1`                         |
-| hh               |              | hour in 12-hour time, padded to 2                              | `01`                        |
-| H                |              | hour in 24-hour time, no padding                               | `13`                        |
-| HH               |              | hour in 24-hour time, padded to 2                              | `13`                        |
-| Z                |              | narrow offset                                                  | `+5`                        |
-| ZZ               |              | short offset                                                   | `+05:00`                    |
-| ZZZ              |              | techie offset                                                  | `+0500`                     |
-| z                |              | IANA zone                                                      | `America/New_York`          |
-| a                |              | meridiem                                                       | `AM`                        |
-| d                |              | day of the month, no padding                                   | `6`                         |
-| dd               |              | day of the month, padded to 2                                  | `06`                        |
-| E                | c            | day of the week, as number from 1-7 (Monday is 1, Sunday is 7) | `3`                         |
-| EEE              | ccc          | day of the week, as an abbreviate localized string             | `Wed`                       |
-| EEEE             | cccc         | day of the week, as an unabbreviated localized string          | `Wednesday`                 |
-| M                | L            | month as an unpadded number                                    | `8`                         |
-| MM               | LL           | month as an padded number                                      | `08`                        |
-| MMM              | LLL          | month as an abbreviated localized string                       | `Aug`                       |
-| MMMM             | LLLL         | month as an unabbreviated localized string                     | `August`                    |
-| y                |              | year, 1-6 digits, very literally                               | `2014`                      |
-| yy               |              | two-digit year, interpreted as > 1960 (also accepts 4)         | `14`                        |
-| yyyy             |              | four-digit year                                                | `2014`                      |
-| yyyyy            |              | four- to six-digit years                                       | `10340`                     |
-| yyyyyy           |              | six-digit years                                                | `010340`                    |
-| G                |              | abbreviated localized era                                      | `AD`                        |
-| GG               |              | unabbreviated localized era                                    | `Anno Domini`               |
-| GGGGG            |              | one-letter localized era                                       | `A`                         |
-| kk               |              | ISO week year, unpadded                                        | `17`                        |
-| kkkk             |              | ISO week year, padded to 4                                     | `2014`                      |
-| W                |              | ISO week number, unpadded                                      | `32`                        |
-| WW               |              | ISO 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`                         |
-| D                |              | localized numeric date                                         | `9/6/2014`                  |
-| DD               |              | localized date with abbreviated month                          | `Aug 6, 2014`               |
-| DDD              |              | localized date with full month                                 | `August 6, 2014`            |
-| DDDD             |              | localized date with full month and weekday                     | `Wednesday, August 6, 2014` |
-| t                |              | localized time                                                 | `1:07 AM`                   |
-| tt               |              | localized time with seconds                                    | `1:07:04 PM`                |
-| T                |              | localized 24-hour time                                         | `13:07`                     |
-| TT               |              | localized 24-hour time with seconds                            | `13:07:04`                  |
-| TTT              |              | localized 24-hour time with seconds and abbreviated offset     | `13:07:04 EDT`              |
-| f                |              | short localized date and time                                  | `8/6/2014, 1:07 PM`         |
-| ff               |              | less short localized date and time                             | `Aug 6, 2014, 1:07 PM`      |
-| F                |              | short localized date and time with seconds                     | `8/6/2014, 1:07:04 PM`      |
-| FF               |              | less short localized date and time with seconds                | `Aug 6, 2014, 1:07:04 PM`   |
-| '                |              | literal start/end, characters between are not tokenized        | `'T'`                       |
+| ---------------- | ------------ | ----------------------------------------------------------------- | --------------------------- |
+| S                |              | millisecond, no padding                                           | `54`                        |
+| SSS              |              | millisecond, padded to 3                                          | `054`                       |
+| u                |              | fractional seconds, (5 is a half second, 54 is slightly more)     | `54`                        |
+| uu               |              | fractional seconds, (one or two digits)                           | `05`                        |
+| uuu              |              | fractional seconds, (only one digit)                              | `5`                         |
+| s                |              | second, no padding                                                | `4`                         |
+| ss               |              | second, padded to 2 padding                                       | `04`                        |
+| m                |              | minute, no padding                                                | `7`                         |
+| mm               |              | minute, padded to 2                                               | `07`                        |
+| h                |              | hour in 12-hour time, no padding                                  | `1`                         |
+| hh               |              | hour in 12-hour time, padded to 2                                 | `01`                        |
+| H                |              | hour in 24-hour time, no padding                                  | `13`                        |
+| HH               |              | hour in 24-hour time, padded to 2                                 | `13`                        |
+| Z                |              | narrow offset                                                     | `+5`                        |
+| ZZ               |              | short offset                                                      | `+05:00`                    |
+| ZZZ              |              | techie offset                                                     | `+0500`                     |
+| z                |              | IANA zone                                                         | `America/New_York`          |
+| a                |              | meridiem                                                          | `AM`                        |
+| d                |              | day of the month, no padding                                      | `6`                         |
+| dd               |              | day of the month, padded to 2                                     | `06`                        |
+| E                | c            | day of the week, as number from 1-7 (Monday is 1, Sunday is 7)    | `3`                         |
+| EEE              | ccc          | day of the week, as an abbreviate localized string                | `Wed`                       |
+| EEEE             | cccc         | day of the week, as an unabbreviated localized string             | `Wednesday`                 |
+| M                | L            | month as an unpadded number                                       | `8`                         |
+| MM               | LL           | month as an padded number                                         | `08`                        |
+| MMM              | LLL          | month as an abbreviated localized string                          | `Aug`                       |
+| MMMM             | LLLL         | month as an unabbreviated localized string                        | `August`                    |
+| y                |              | year, 1-6 digits, very literally                                  | `2014`                      |
+| yy               |              | two-digit year, interpreted as > 1960 by default (also accepts 4) | `14`                        |
+| yyyy             |              | four-digit year                                                   | `2014`                      |
+| yyyyy            |              | four- to six-digit years                                          | `10340`                     |
+| yyyyyy           |              | six-digit years                                                   | `010340`                    |
+| G                |              | abbreviated localized era                                         | `AD`                        |
+| GG               |              | unabbreviated localized era                                       | `Anno Domini`               |
+| GGGGG            |              | one-letter localized era                                          | `A`                         |
+| kk               |              | ISO week year, unpadded                                           | `17`                        |
+| kkkk             |              | ISO week year, padded to 4                                        | `2014`                      |
+| W                |              | ISO week number, unpadded                                         | `32`                        |
+| WW               |              | ISO 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`                         |
+| D                |              | localized numeric date                                            | `9/6/2014`                  |
+| DD               |              | localized date with abbreviated month                             | `Aug 6, 2014`               |
+| DDD              |              | localized date with full month                                    | `August 6, 2014`            |
+| DDDD             |              | localized date with full month and weekday                        | `Wednesday, August 6, 2014` |
+| t                |              | localized time                                                    | `1:07 AM`                   |
+| tt               |              | localized time with seconds                                       | `1:07:04 PM`                |
+| T                |              | localized 24-hour time                                            | `13:07`                     |
+| TT               |              | localized 24-hour time with seconds                               | `13:07:04`                  |
+| TTT              |              | localized 24-hour time with seconds and abbreviated offset        | `13:07:04 EDT`              |
+| f                |              | short localized date and time                                     | `8/6/2014, 1:07 PM`         |
+| ff               |              | less short localized date and time                                | `Aug 6, 2014, 1:07 PM`      |
+| F                |              | short localized date and time with seconds                        | `8/6/2014, 1:07:04 PM`      |
+| FF               |              | less short localized date and time with seconds                   | `Aug 6, 2014, 1:07:04 PM`   |
+| '                |              | literal start/end, characters between are not tokenized           | `'T'`                       |
diff --git a/package-lock.json b/package-lock.json
index 2b892d58..de691e1f 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
 {
   "name": "luxon",
-  "version": "3.1.0",
+  "version": "3.1.1",
   "lockfileVersion": 2,
   "requires": true,
   "packages": {
     "": {
       "name": "luxon",
-      "version": "3.1.0",
+      "version": "3.1.1",
       "license": "MIT",
       "devDependencies": {
         "@babel/core": "^7.18.6",
diff --git a/package.json b/package.json
index 0517ad94..33a8e6bb 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
   "name": "luxon",
-  "version": "3.1.0",
+  "version": "3.1.1",
   "description": "Immutable date wrapper",
   "author": "Isaac Cambron",
   "keywords": [
diff --git a/src/impl/formatter.js b/src/impl/formatter.js
index b3486301..4dcd6be7 100644
--- a/src/impl/formatter.js
+++ b/src/impl/formatter.js
@@ -108,6 +108,11 @@ export default class Formatter {
     return df.formatToParts();
   }
 
+  formatInterval(interval, opts = {}) {
+    const df = this.loc.dtFormatter(interval.start, { ...this.opts, ...opts });
+    return df.dtf.formatRange(interval.start.toJSDate(), interval.end.toJSDate());
+  }
+
   resolvedOptions(dt, opts = {}) {
     const df = this.loc.dtFormatter(dt, { ...this.opts, ...opts });
     return df.resolvedOptions();
diff --git a/src/impl/regexParser.js b/src/impl/regexParser.js
index 43689a45..20c9f814 100644
--- a/src/impl/regexParser.js
+++ b/src/impl/regexParser.js
@@ -3,7 +3,6 @@ import {
   signedOffset,
   parseInteger,
   parseMillis,
-  ianaRegex,
   isUndefined,
   parseFloating,
 } from "./util.js";
@@ -21,6 +20,8 @@ import IANAZone from "../zones/IANAZone.js";
  * Some extractions are super dumb and simpleParse and fromStrings help DRY them.
  */
 
+const ianaRegex = /[A-Za-z_+-]{1,256}(?::?\/[A-Za-z0-9_+-]{1,256}(?:\/[A-Za-z0-9_+-]{1,256})?)?/;
+
 function combineRegexes(...regexes) {
   const full = regexes.reduce((f, r) => f + r.source, "");
   return RegExp(`^${full}$`);
diff --git a/src/impl/util.js b/src/impl/util.js
index 57761db5..c07136f6 100644
--- a/src/impl/util.js
+++ b/src/impl/util.js
@@ -5,6 +5,7 @@
 */
 
 import { InvalidArgumentError } from "../errors.js";
+import Settings from "../settings.js";
 
 /**
  * @private
@@ -185,7 +186,7 @@ export function weeksInWeekYear(weekYear) {
 export function untruncateYear(year) {
   if (year > 99) {
     return year;
-  } else return year > 60 ? 1900 + year : 2000 + year;
+  } else return year > Settings.twoDigitCutoffYear ? 1900 + year : 2000 + year;
 }
 
 // PARSING
@@ -268,6 +269,3 @@ export function formatOffset(offset, format) {
 export function timeObject(obj) {
   return pick(obj, ["hour", "minute", "second", "millisecond"]);
 }
-
-export const ianaRegex =
-  /[A-Za-z_+-]{1,256}(?::?\/[A-Za-z0-9_+-]{1,256}(?:\/[A-Za-z0-9_+-]{1,256})?)?/;
diff --git a/src/interval.js b/src/interval.js
index c68a3e56..a4c177ae 100644
--- a/src/interval.js
+++ b/src/interval.js
@@ -3,6 +3,8 @@ import Duration from "./duration.js";
 import Settings from "./settings.js";
 import { InvalidArgumentError, InvalidIntervalError } from "./errors.js";
 import Invalid from "./impl/invalid.js";
+import Formatter from "./impl/formatter.js";
+import * as Formats from "./impl/formats.js";
 
 const INVALID = "Invalid Interval";
 
@@ -32,7 +34,7 @@ function validateStartEnd(start, end) {
  * * **Interrogation** To analyze the Interval, use {@link Interval#count}, {@link Interval#length}, {@link Interval#hasSame}, {@link Interval#contains}, {@link Interval#isAfter}, or {@link Interval#isBefore}.
  * * **Transformation** To create other Intervals out of this one, use {@link Interval#set}, {@link Interval#splitAt}, {@link Interval#splitBy}, {@link Interval#divideEqually}, {@link Interval.merge}, {@link Interval.xor}, {@link Interval#union}, {@link Interval#intersection}, or {@link Interval#difference}.
  * * **Comparison** To compare this Interval to another one, use {@link Interval#equals}, {@link Interval#overlaps}, {@link Interval#abutsStart}, {@link Interval#abutsEnd}, {@link Interval#engulfs}
- * * **Output** To convert the Interval into other representations, see {@link Interval#toString}, {@link Interval#toISO}, {@link Interval#toISODate}, {@link Interval#toISOTime}, {@link Interval#toFormat}, and {@link Interval#toDuration}.
+ * * **Output** To convert the Interval into other representations, see {@link Interval#toString}, {@link Interval#toLocaleString}, {@link Interval#toISO}, {@link Interval#toISODate}, {@link Interval#toISOTime}, {@link Interval#toFormat}, and {@link Interval#toDuration}.
  */
 export default class Interval {
   /**
@@ -529,6 +531,30 @@ export default class Interval {
     return `[${this.s.toISO()} – ${this.e.toISO()})`;
   }
 
+  /**
+   * Returns a localized string representing this Interval. Accepts the same options as the
+   * Intl.DateTimeFormat constructor and any presets defined by Luxon, such as
+   * {@link DateTime.DATE_FULL} or {@link DateTime.TIME_SIMPLE}. The exact behavior of this method
+   * is browser-specific, but in general it will return an appropriate representation of the
+   * Interval in the assigned locale. Defaults to the system's locale if no locale has been
+   * specified.
+   * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/DateTimeFormat
+   * @param {Object} [formatOpts=DateTime.DATE_SHORT] - Either a DateTime preset or
+   * Intl.DateTimeFormat constructor options.
+   * @param {Object} opts - Options to override the configuration of the start DateTime.
+   * @example Interval.fromISO('2022-11-07T09:00Z/2022-11-08T09:00Z').toLocaleString(); //=> 11/7/2022 – 11/8/2022
+   * @example Interval.fromISO('2022-11-07T09:00Z/2022-11-08T09:00Z').toLocaleString(DateTime.DATE_FULL); //=> November 7 – 8, 2022
+   * @example Interval.fromISO('2022-11-07T09:00Z/2022-11-08T09:00Z').toLocaleString(DateTime.DATE_FULL, { locale: 'fr-FR' }); //=> 7–8 novembre 2022
+   * @example Interval.fromISO('2022-11-07T17:00Z/2022-11-07T19:00Z').toLocaleString(DateTime.TIME_SIMPLE); //=> 6:00 – 8:00 PM
+   * @example Interval.fromISO('2022-11-07T17:00Z/2022-11-07T19:00Z').toLocaleString({ weekday: 'short', month: 'short', day: '2-digit', hour: '2-digit', minute: '2-digit' }); //=> Mon, Nov 07, 6:00 – 8:00 p
+   * @return {string}
+   */
+  toLocaleString(formatOpts = Formats.DATE_SHORT, opts = {}) {
+    return this.isValid
+      ? Formatter.create(this.s.loc.clone(opts), formatOpts).formatInterval(this)
+      : INVALID;
+  }
+
   /**
    * Returns an ISO 8601-compliant string representation of this Interval.
    * @see https://en.wikipedia.org/wiki/ISO_8601#Time_intervals
@@ -564,10 +590,14 @@ export default class Interval {
   }
 
   /**
-   * Returns a string representation of this Interval formatted according to the specified format string.
-   * @param {string} dateFormat - the format string. This string formats the start and end time. See {@link DateTime#toFormat} for details.
-   * @param {Object} opts - options
-   * @param {string} [opts.separator =  ' – '] - a separator to place between the start and end representations
+   * Returns a string representation of this Interval formatted according to the specified format
+   * string. **You may not want this.** See {@link Interval#toLocaleString} for a more flexible
+   * formatting tool.
+   * @param {string} dateFormat - The format string. This string formats the start and end time.
+   * See {@link DateTime#toFormat} for details.
+   * @param {Object} opts - Options.
+   * @param {string} [opts.separator =  ' – '] - A separator to place between the start and end
+   * representations.
    * @return {string}
    */
   toFormat(dateFormat, { separator = " – " } = {}) {
diff --git a/src/luxon.js b/src/luxon.js
index 0562b0ee..45e6d764 100644
--- a/src/luxon.js
+++ b/src/luxon.js
@@ -9,7 +9,7 @@ import InvalidZone from "./zones/invalidZone.js";
 import SystemZone from "./zones/systemZone.js";
 import Settings from "./settings.js";
 
-const VERSION = "3.1.0";
+const VERSION = "3.1.1";
 
 export {
   VERSION,
diff --git a/src/settings.js b/src/settings.js
index bfcdda2a..e119e97c 100644
--- a/src/settings.js
+++ b/src/settings.js
@@ -9,6 +9,7 @@ let now = () => Date.now(),
   defaultLocale = null,
   defaultNumberingSystem = null,
   defaultOutputCalendar = null,
+  twoDigitCutoffYear = 60,
   throwOnInvalid;
 
 /**
@@ -100,6 +101,26 @@ export default class Settings {
     defaultOutputCalendar = outputCalendar;
   }
 
+  /**
+   * Get the cutoff year after which a string encoding a year as two digits is interpreted to occur in the current century.
+   * @type {number}
+   */
+  static get twoDigitCutoffYear() {
+    return twoDigitCutoffYear;
+  }
+
+  /**
+   * Set the cutoff year after which a string encoding a year as two digits is interpreted to occur in the current century.
+   * @type {number}
+   * @example Settings.twoDigitCutoffYear = 0 // cut-off year is 0, so all 'yy' are interpretted as current century
+   * @example Settings.twoDigitCutoffYear = 50 // '49' -> 1949; '50' -> 2050
+   * @example Settings.twoDigitCutoffYear = 1950 // interpretted as 50
+   * @example Settings.twoDigitCutoffYear = 2050 // ALSO interpretted as 50
+   */
+  static set twoDigitCutoffYear(cutoffYear) {
+    twoDigitCutoffYear = cutoffYear % 100;
+  }
+
   /**
    * Get whether Luxon will throw when it encounters invalid DateTimes, Durations, or Intervals
    * @type {boolean}


Test patch:
diff --git a/test/datetime/tokenParse.test.js b/test/datetime/tokenParse.test.js
index ac480112..45095c55 100644
--- a/test/datetime/tokenParse.test.js
+++ b/test/datetime/tokenParse.test.js
@@ -1,7 +1,7 @@
 /* global test expect */
 import { DateTime } from "../../src/luxon";
 import Helpers from "../helpers";
-
+import Settings from "../../src/settings";
 import { ConflictingSpecificationError } from "../../src/errors";
 
 //------
@@ -136,9 +136,22 @@ test("DateTime.fromFormat() with yyyyyy strictly parses extended years", () => {
 });
 
 test("DateTime.fromFormat() defaults yy to the right century", () => {
-  expect(DateTime.fromFormat("55", "yy").year).toBe(2055);
-  expect(DateTime.fromFormat("70", "yy").year).toBe(1970);
-  expect(DateTime.fromFormat("1970", "yy").year).toBe(1970);
+  expect(DateTime.fromFormat("60", "yy").year).toBe(2060);
+  expect(DateTime.fromFormat("61", "yy").year).toBe(1961);
+  expect(DateTime.fromFormat("1960", "yy").year).toBe(1960);
+});
+
+test("DateTime.fromFormat() respects Settings.twoDigitCutoffYear when parsing yy to the right century", () => {
+  const oldTwoDigitCutoffYear = Settings.twoDigitCutoffYear;
+
+  try {
+    Settings.twoDigitCutoffYear = 50;
+    expect(DateTime.fromFormat("50", "yy").year).toBe(2050);
+    expect(DateTime.fromFormat("51", "yy").year).toBe(1951);
+    expect(DateTime.fromFormat("1950", "yy").year).toBe(1950);
+  } finally {
+    Settings.twoDigitCutoffYear = oldTwoDigitCutoffYear;
+  }
 });
 
 test("DateTime.fromFormat() parses hours", () => {
@@ -417,6 +430,25 @@ test("DateTime.fromFormat() accepts weekYear by itself", () => {
   expect(d.weekday).toBe(1);
 });
 
+test("DateTime.fromFormat() defaults kk to the right century", () => {
+  expect(DateTime.fromFormat("60", "kk").weekYear).toBe(2060);
+  expect(DateTime.fromFormat("61", "kk").weekYear).toBe(1961);
+  expect(DateTime.fromFormat("1960", "kk").weekYear).toBe(1960);
+});
+
+test("DateTime.fromFormat() respects Settings.twoDigitCutoffYear when parsing two digit weekYear to the right century", () => {
+  const oldTwoDigitCutoffYear = Settings.twoDigitCutoffYear;
+
+  try {
+    Settings.twoDigitCutoffYear = 50;
+    expect(DateTime.fromFormat("50", "kk").weekYear).toBe(2050);
+    expect(DateTime.fromFormat("51", "kk").weekYear).toBe(1951);
+    expect(DateTime.fromFormat("1950", "kk").weekYear).toBe(1950);
+  } finally {
+    Settings.twoDigitCutoffYear = oldTwoDigitCutoffYear;
+  }
+});
+
 test("DateTime.fromFormat() accepts weekNumber by itself", () => {
   const now = DateTime.now();
 
diff --git a/test/interval/format.test.js b/test/interval/format.test.js
index ca4658a4..c983823b 100644
--- a/test/interval/format.test.js
+++ b/test/interval/format.test.js
@@ -16,6 +16,166 @@ test("Interval#toString returns a simple range format", () =>
 test("Interval#toString returns an unfriendly string for invalid intervals", () =>
   expect(invalid.toString()).toBe("Invalid Interval"));
 
+//------
+// .toLocaleString()
+//------
+
+test("Interval#toLocaleString defaults to the DATE_SHORT format", () =>
+  expect(interval.toLocaleString()).toBe("5/25/1982 – 10/14/1983"));
+
+test("Interval#toLocaleString returns an unfriendly string for invalid intervals", () =>
+  expect(invalid.toLocaleString()).toBe("Invalid Interval"));
+
+test("Interval#toLocaleString lets the locale set the numbering system", () => {
+  expect(
+    Interval.after(interval.start.reconfigure({ locale: "ja-JP" }), { hour: 2 }).toLocaleString({
+      hour: "numeric",
+    })
+  ).toBe("9時～11時");
+});
+
+test("Interval#toLocaleString accepts locale settings from the start DateTime", () => {
+  expect(
+    Interval.fromDateTimes(
+      interval.start.reconfigure({ locale: "be" }),
+      interval.end
+    ).toLocaleString()
+  ).toBe("25.5.1982 – 14.10.1983");
+});
+
+test("Interval#toLocaleString accepts numbering system settings from the start DateTime", () => {
+  expect(
+    Interval.fromDateTimes(
+      interval.start.reconfigure({ numberingSystem: "beng" }),
+      interval.end
+    ).toLocaleString()
+  ).toBe("৫/২৫/১৯৮২ – ১০/১৪/১৯৮৩");
+});
+
+test("Interval#toLocaleString accepts ouptput calendar settings from the start DateTime", () => {
+  expect(
+    Interval.fromDateTimes(
+      interval.start.reconfigure({ outputCalendar: "islamic" }),
+      interval.end
+    ).toLocaleString()
+  ).toBe("8/2/1402 – 1/8/1404 AH");
+});
+
+test("Interval#toLocaleString accepts options to the formatter", () => {
+  expect(interval.toLocaleString({ weekday: "short" })).toBe("Tue – Fri");
+});
+
+test("Interval#toLocaleString can override the start DateTime's locale", () => {
+  expect(
+    Interval.fromDateTimes(
+      interval.start.reconfigure({ locale: "be" }),
+      interval.end
+    ).toLocaleString({}, { locale: "fr" })
+  ).toBe("25/05/1982 – 14/10/1983");
+});
+
+test("Interval#toLocaleString can override the start DateTime's numbering system", () => {
+  expect(
+    Interval.fromDateTimes(
+      interval.start.reconfigure({ numberingSystem: "beng" }),
+      interval.end
+    ).toLocaleString({ numberingSystem: "mong" })
+  ).toBe("᠕/᠒᠕/᠑᠙᠘᠒ – ᠑᠐/᠑᠔/᠑᠙᠘᠓");
+});
+
+test("Interval#toLocaleString can override the start DateTime's output calendar", () => {
+  expect(
+    Interval.fromDateTimes(
+      interval.start.reconfigure({ outputCalendar: "islamic" }),
+      interval.end
+    ).toLocaleString({}, { outputCalendar: "coptic" })
+  ).toBe("9/17/1698 – 2/3/1700 ERA1");
+});
+
+test("Interval#toLocaleString shows things in the right IANA zone", () => {
+  expect(
+    Interval.fromDateTimes(
+      interval.start.setZone("Australia/Melbourne"),
+      interval.end
+    ).toLocaleString(DateTime.DATETIME_SHORT)
+  ).toBe("5/25/1982, 7:00 PM – 10/14/1983, 11:30 PM");
+});
+
+test("Interval#toLocaleString shows things in the right fixed-offset zone", () => {
+  expect(
+    Interval.fromDateTimes(interval.start.setZone("UTC-8"), interval.end).toLocaleString(
+      DateTime.DATETIME_SHORT
+    )
+  ).toBe("5/25/1982, 1:00 AM – 10/14/1983, 5:30 AM");
+});
+
+test("Interval#toLocaleString shows things in the right fixed-offset zone when showing the zone", () => {
+  expect(
+    Interval.fromDateTimes(interval.start.setZone("UTC-8"), interval.end).toLocaleString(
+      DateTime.DATETIME_FULL
+    )
+  ).toBe("May 25, 1982 at 1:00 AM GMT-8 – October 14, 1983 at 5:30 AM GMT-8");
+});
+
+test("Interval#toLocaleString shows things with UTC if fixed-offset with 0 offset is used", () => {
+  expect(
+    Interval.fromDateTimes(interval.start.setZone("UTC"), interval.end).toLocaleString(
+      DateTime.DATETIME_FULL
+    )
+  ).toBe("May 25, 1982 at 9:00 AM UTC – October 14, 1983 at 1:30 PM UTC");
+});
+
+test("Interval#toLocaleString does the best it can with unsupported fixed-offset zone when showing the zone", () => {
+  expect(
+    Interval.fromDateTimes(interval.start.setZone("UTC+4:30"), interval.end).toLocaleString(
+      DateTime.DATETIME_FULL
+    )
+  ).toBe("May 25, 1982 at 9:00 AM UTC – October 14, 1983 at 1:30 PM UTC");
+});
+
+test("Interval#toLocaleString uses locale-appropriate time formats", () => {
+  expect(
+    Interval.after(interval.start.reconfigure({ locale: "en-US" }), { hour: 2 }).toLocaleString(
+      DateTime.TIME_SIMPLE
+    )
+  ).toBe("9:00 – 11:00 AM");
+  expect(
+    Interval.after(interval.start.reconfigure({ locale: "en-US" }), { hour: 2 }).toLocaleString(
+      DateTime.TIME_24_SIMPLE
+    )
+  ).toBe("09:00 – 11:00");
+
+  // France has 24-hour by default
+  expect(
+    Interval.after(interval.start.reconfigure({ locale: "fr" }), { hour: 2 }).toLocaleString(
+      DateTime.TIME_SIMPLE
+    )
+  ).toBe("09:00 – 11:00");
+  expect(
+    Interval.after(interval.start.reconfigure({ locale: "fr" }), { hour: 2 }).toLocaleString(
+      DateTime.TIME_24_SIMPLE
+    )
+  ).toBe("09:00 – 11:00");
+
+  // Spain does't prefix with "0" and doesn't use spaces
+  expect(
+    Interval.after(interval.start.reconfigure({ locale: "es" }), { hour: 2 }).toLocaleString(
+      DateTime.TIME_SIMPLE
+    )
+  ).toBe("9:00–11:00");
+  expect(
+    Interval.after(interval.start.reconfigure({ locale: "es" }), { hour: 2 }).toLocaleString(
+      DateTime.TIME_24_SIMPLE
+    )
+  ).toBe("9:00–11:00");
+});
+
+test("Interval#toLocaleString sets the separator between days for same-month dates", () => {
+  expect(Interval.after(interval.start, { day: 2 }).toLocaleString(DateTime.DATE_MED)).toBe(
+    "May 25 – 27, 1982"
+  );
+});
+
 //------
 // .toISO()
 //------