Repository: moment/luxon (js)

Issue:
Interval split by
I have an interval that is a year and a bit worth of date ranges - from 30 Dec 2019 - 01 Jan 2021. I was looking to use the splitBy to break it up into monthly energy consumption values.

Looks like things get off to a good start - then hits Feb 2020, correctly adjusting for leap year. March 2020 then ends on the 29th which I would guess the correct behaviour should be 31 March.

```javascript
const { DateTime, Duration, Interval, Info }  = require('luxon');


let start = DateTime.fromMillis(1577664000000);
let end = DateTime.fromMillis(1609630200000);

let intervalConsumption = Interval.fromDateTimes(start, end);

// Interval {
//     s: DateTime {
//       ts: 1577664000000,
//       _zone: LocalZone {},
//       loc: Locale {
//         locale: 'en-GB',
//         numberingSystem: null,
//         outputCalendar: null,
//         intl: 'en-GB',
//         weekdaysCache: [Object],
//         monthsCache: [Object],
//         meridiemCache: null,
//         eraCache: {},
//         specifiedLocale: null,
//         fastNumbersCached: null
//       },
//       invalid: null,
//       weekData: null,
//       c: {
//         year: 2019,
//         month: 12,
//         day: 30,
//         hour: 0,
//         minute: 0,
//         second: 0,
//         millisecond: 0
//       },
//       o: -0,
//       isLuxonDateTime: true
//     },
//     e: DateTime {
//       ts: 1609630200000,
//       _zone: LocalZone {},
//       loc: Locale {
//         locale: 'en-GB',
//         numberingSystem: null,
//         outputCalendar: null,
//         intl: 'en-GB',
//         weekdaysCache: [Object],
//         monthsCache: [Object],
//         meridiemCache: null,
//         eraCache: {},
//         specifiedLocale: null,
//         fastNumbersCached: null
//       },
//       invalid: null,
//       weekData: null,
//       c: {
//         year: 2021,
//         month: 1,
//         day: 2,
//         hour: 23,
//         minute: 30,
//         second: 0,
//         millisecond: 0
//       },
//       o: -0,
//       isLuxonDateTime: true
//     },
//     invalid: null,
//     isLuxonInterval: true
//   }

console.log(intervalConsumption.splitBy(Duration.fromISO('P1M')));

// [
//     Interval {
//       s: DateTime {
//         ts: 1577664000000,
//         _zone: LocalZone {},
//         loc: [Locale],
//         invalid: null,
//         weekData: null,
//         c: [Object],
//         o: -0,
//         isLuxonDateTime: true
//       },
//       e: DateTime {
//         ts: 1580342400000,
//         _zone: LocalZone {},
//         loc: [Locale],
//         invalid: null,
//         weekData: null,
//         c: [Object],
//         o: -0,
//         isLuxonDateTime: true
//       },
//       invalid: null,
//       isLuxonInterval: true
//     },
//     Interval {
//       s: DateTime {
//         ts: 1580342400000,
//         _zone: LocalZone {},
//         loc: [Locale],
//         invalid: null,
//         weekData: null,
//         c: [Object],
//         o: -0,
//         isLuxonDateTime: true
//       },
//       e: DateTime {
//         ts: 1582934400000,
//         _zone: LocalZone {},
//         loc: [Locale],
//         invalid: null,
//         weekData: null,
//         c: [Object],
//         o: -0,
//         isLuxonDateTime: true
//       },
//       invalid: null,
//       isLuxonInterval: true
//     },
//     Interval {
//       s: DateTime {
//         ts: 1582934400000,
//         _zone: LocalZone {},
//         loc: [Locale],
//         invalid: null,
//         weekData: null,
//         c: [Object],
//         o: -0,
//         isLuxonDateTime: true
//       },
//       e: DateTime {
//         ts: 1585440000000, <-- NOT AS EXPECTED (29 March 2020)
//         _zone: LocalZone {},
//         loc: [Locale],
//         invalid: null,
//         weekData: null,
//         c: [Object],
//         o: -0,
//         isLuxonDateTime: true
//       },
//       invalid: null,
//       isLuxonInterval: true
//     },
//     Interval {
//       s: DateTime {
//         ts: 1585440000000, <-- NOT AS EXPECTED (29 March 2020)
//         _zone: LocalZone {},
//         loc: [Locale],
//         invalid: null,
//         weekData: null,
//         c: [Object],
//         o: -0,
//         isLuxonDateTime: true
//       },
//       e: DateTime {
//         ts: 1588114800000, <-- THEN THINGS GO WRONG INCREMENTING MONTHS
//         _zone: LocalZone {},
//         loc: [Locale],
//         invalid: null,
//         weekData: null,
//         c: [Object],
//         o: 60,
//         isLuxonDateTime: true
//       },
//       invalid: null,
//       isLuxonInterval: true
//     },

```

Pull request:
#848: Base Interval.splitBy calcs on original start date
Fix #848 - this bases duration calcs on the original start date rather than the start date of the previous split.

Patch:
diff --git a/src/impl/english.js b/src/impl/english.js
index 633f5943..0ae2069a 100644
--- a/src/impl/english.js
+++ b/src/impl/english.js
@@ -44,11 +44,11 @@ export const monthsNarrow = ["J", "F", "M", "A", "M", "J", "J", "A", "S", "O", "
 export function months(length) {
   switch (length) {
     case "narrow":
-      return monthsNarrow;
+      return [...monthsNarrow];
     case "short":
-      return monthsShort;
+      return [...monthsShort];
     case "long":
-      return monthsLong;
+      return [...monthsLong];
     case "numeric":
       return ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12"];
     case "2-digit":
@@ -75,11 +75,11 @@ export const weekdaysNarrow = ["M", "T", "W", "T", "F", "S", "S"];
 export function weekdays(length) {
   switch (length) {
     case "narrow":
-      return weekdaysNarrow;
+      return [...weekdaysNarrow];
     case "short":
-      return weekdaysShort;
+      return [...weekdaysShort];
     case "long":
-      return weekdaysLong;
+      return [...weekdaysLong];
     case "numeric":
       return ["1", "2", "3", "4", "5", "6", "7"];
     default:
@@ -98,11 +98,11 @@ export const erasNarrow = ["B", "A"];
 export function eras(length) {
   switch (length) {
     case "narrow":
-      return erasNarrow;
+      return [...erasNarrow];
     case "short":
-      return erasShort;
+      return [...erasShort];
     case "long":
-      return erasLong;
+      return [...erasLong];
     default:
       return null;
   }
diff --git a/src/interval.js b/src/interval.js
index f2cfce8b..e8a419bc 100644
--- a/src/interval.js
+++ b/src/interval.js
@@ -340,15 +340,16 @@ export default class Interval {
     }
 
     let { s } = this,
-      added,
+      idx = 1,
       next;
 
     const results = [];
     while (s < this.e) {
-      added = s.plus(dur);
+      const added = this.start.plus(dur.mapUnits(x => x * idx));
       next = +added > +this.e ? this.e : added;
       results.push(Interval.fromDateTimes(s, next));
       s = next;
+      idx += 1;
     }
 
     return results;


Test patch:
diff --git a/test/info/listers.test.js b/test/info/listers.test.js
index c58419fe..2b0b834e 100644
--- a/test/info/listers.test.js
+++ b/test/info/listers.test.js
@@ -2,6 +2,9 @@
 
 import { Info } from "../../src/luxon";
 
+import Helpers from "../helpers";
+const withDefaultLocale = Helpers.withDefaultLocale;
+
 //------
 // .months()
 //------
@@ -374,3 +377,33 @@ test("Info.eras lists both eras", () => {
   expect(Info.eras("short", { locale: "fr" })).toEqual(["av. J.-C.", "ap. J.-C."]);
   expect(Info.eras("long", { locale: "fr" })).toEqual(["avant Jésus-Christ", "après Jésus-Christ"]);
 });
+
+//------
+// general
+//------
+test("Info English lists are not mutable", () => {
+  withDefaultLocale("en-US", () => {
+    const cachingMethods = [
+      ["weekdays", "short"],
+      ["weekdays", "long"],
+      ["weekdays", "narrow"],
+      ["weekdays", "numeric"],
+      ["months", "short"],
+      ["months", "long"],
+      ["months", "narrow"],
+      ["months", "numeric"],
+      ["months", "2-digit"],
+      ["eras", "narrow"],
+      ["eras", "short"],
+      ["eras", "long"]
+    ];
+
+    for (const [method, arg] of cachingMethods) {
+      const fn = Info[method];
+      const original = [...fn(arg)];
+      fn(arg).pop();
+      const expected = fn(arg);
+      expect(expected).toEqual(original);
+    }
+  });
+});
diff --git a/test/interval/many.test.js b/test/interval/many.test.js
index f074316b..002bd97b 100644
--- a/test/interval/many.test.js
+++ b/test/interval/many.test.js
@@ -371,6 +371,31 @@ test("Interval#split by returns [] for durations of length 0", () => {
   expect(split).toEqual([]);
 });
 
+test("Interval#split by works across varying length months", () => {
+  Helpers.withDefaultZone("Europe/London", () => {
+    const start = DateTime.fromISO("2019-12-30T00:00:00.000+00:00");
+    const end = DateTime.fromISO("2020-05-02T23:30:00.000+00:00");
+    const interval = Interval.fromDateTimes(start, end);
+
+    const months = interval.splitBy(Duration.fromISO("P1M"));
+    expect(months.length).toEqual(5);
+
+    for (let i = 0; i < months.length; i++) {
+      const month = months[i];
+      const expectedStart = start.plus({ month: i });
+      const expectedEnd = start.plus({ month: i + 1 });
+
+      expect(month.start).toEqual(expectedStart);
+
+      if (expectedEnd > end) {
+        expect(month.end).toEqual(end);
+      } else {
+        expect(month.end).toEqual(expectedEnd);
+      }
+    }
+  });
+});
+
 //-------
 // #divideEqually()
 //-------