Repository: moment/luxon (js)

Issue:
Issue at MUI: AdapterLuxon doesn't provide placeholder text (hh:mm / dd.mm.yy) in Date- or TimePickers
Hello, first I opened a bug at Material-UI for this issue. As you can see, it seems to be in your responsibility:
[MaterialUI-Issues](https://github.com/mui/mui-x/issues/4755)

**Describe the bug**
I wrapped TimePicker and also DatePicker in my app with LocalizationProvider.
Both gets the prop: dateAdapter={AdapterLuxon} and locale={string} ("en-US" / "de-DE" ...).

The am/pm behaviour works as expected, but I never get any placeholder text.
I switched from moment library (with AdapterMoment in LocalizationProvider) to Luxon DateTime now and the placeholder disappears.
Will say, with Moment it worked well and also as we can see with DateFNS it work's also like on your documentation:
[LocalizationProvider](https://mui.com/components/time-picker/#localization)

**To Reproduce**
[CodeSandbox](https://codesandbox.io/s/localizationprovider-issue-6yepj?file=/src/App.tsx)

**Actual vs Expected behavior**
Placeholder textes should also appear with AdapterLuxon in all Date- / Timepickers.

**Desktop (please complete the following information):**

- OS: Win10 / MacOs 12.3.1
- Browser Chromium Browsers: Edge: Version 100.0.1185.50 (Offizielles Build) (arm64) / Brave: [Version 1.38.109 Chromium: 101.0.4951.41 (Offizieller Build) (arm64)] / ...
- Luxon version 2.3.0 in cloud app / cadesandbox: 2.3.2
- Your timezone: Berlin

**Additional context**
This problem is a UX requirement in order to quickly and easily convey to the user what needs to be entered into the field. Additionally with the info in which format.


Pull request description:
Correctly handle hourCycle when expanding macro tokens
When expanding macro tokens, we must expand "hour" either into "h" or "H", depending on the desired hour cycle. 
This fixes #1197.

I have not changed the parsing of `h`, which currently accepts 24 hours just the same as `H`. This would be a breaking change and I am not sure it is worth introducing another special case in the parser for this.


Patch:
diff --git a/src/datetime.js b/src/datetime.js
index 5120b056..0e3c4eee 100644
--- a/src/datetime.js
+++ b/src/datetime.js
@@ -224,14 +224,14 @@ function toISOTime(
   if (extended) {
     c += ":";
     c += padStart(o.c.minute);
-    if (o.c.second !== 0 || !suppressSeconds) {
+    if (o.c.millisecond !== 0 || o.c.second !== 0 || !suppressSeconds) {
       c += ":";
     }
   } else {
     c += padStart(o.c.minute);
   }
 
-  if (o.c.second !== 0 || !suppressSeconds) {
+  if (o.c.millisecond !== 0 || o.c.second !== 0 || !suppressSeconds) {
     c += padStart(o.c.second);
 
     if (o.c.millisecond !== 0 || !suppressMilliseconds) {
diff --git a/src/duration.js b/src/duration.js
index fc512b6e..72f67fb4 100644
--- a/src/duration.js
+++ b/src/duration.js
@@ -12,6 +12,7 @@ import {
   roundTo,
 } from "./impl/util.js";
 import Settings from "./settings.js";
+import DateTime from "./datetime.js";
 
 const INVALID = "Invalid Duration";
 
@@ -548,26 +549,11 @@ export default class Duration {
       includePrefix: false,
       format: "extended",
       ...opts,
+      includeOffset: false,
     };
 
-    const value = this.shiftTo("hours", "minutes", "seconds", "milliseconds");
-
-    let fmt = opts.format === "basic" ? "hhmm" : "hh:mm";
-
-    if (!opts.suppressSeconds || value.seconds !== 0 || value.milliseconds !== 0) {
-      fmt += opts.format === "basic" ? "ss" : ":ss";
-      if (!opts.suppressMilliseconds || value.milliseconds !== 0) {
-        fmt += ".SSS";
-      }
-    }
-
-    let str = value.toFormat(fmt);
-
-    if (opts.includePrefix) {
-      str = "T" + str;
-    }
-
-    return str;
+    const dateTime = DateTime.fromMillis(millis, { zone: "UTC" });
+    return dateTime.toISOTime(opts);
   }
 
   /**
diff --git a/src/impl/diff.js b/src/impl/diff.js
index 7fe389b6..ebd129e7 100644
--- a/src/impl/diff.js
+++ b/src/impl/diff.js
@@ -25,6 +25,14 @@ function highOrderDiffs(cursor, later, units) {
   const earlier = cursor;
   let lowestOrder, highWater;
 
+  /* This loop tries to diff using larger units first.
+     If we overshoot, we backtrack and try the next smaller unit.
+     "cursor" starts out at the earlier timestamp and moves closer and closer to "later"
+     as we use smaller and smaller units.
+     highWater keeps track of where we would be if we added one more of the smallest unit,
+     this is used later to potentially convert any difference smaller than the smallest higher order unit
+     into a fraction of that smallest higher order unit
+  */
   for (const [unit, differ] of differs) {
     if (units.indexOf(unit) >= 0) {
       lowestOrder = unit;
@@ -33,8 +41,20 @@ function highOrderDiffs(cursor, later, units) {
       highWater = earlier.plus(results);
 
       if (highWater > later) {
+        // we overshot the end point, backtrack cursor by 1
         results[unit]--;
         cursor = earlier.plus(results);
+
+        // if we are still overshooting now, we need to backtrack again
+        // this happens in certain situations when diffing times in different zones,
+        // because this calculation ignores time zones
+        if (cursor > later) {
+          // keep the "overshot by 1" around as highWater
+          highWater = cursor;
+          // backtrack cursor by 1
+          results[unit]--;
+          cursor = earlier.plus(results);
+        }
       } else {
         cursor = highWater;
       }
diff --git a/src/impl/formatter.js b/src/impl/formatter.js
index 7fe6891f..179eab6e 100644
--- a/src/impl/formatter.js
+++ b/src/impl/formatter.js
@@ -101,24 +101,25 @@ export default class Formatter {
     return df.format();
   }
 
-  formatDateTime(dt, opts = {}) {
-    const df = this.loc.dtFormatter(dt, { ...this.opts, ...opts });
-    return df.format();
+  dtFormatter(dt, opts = {}) {
+    return this.loc.dtFormatter(dt, { ...this.opts, ...opts });
+  }
+
+  formatDateTime(dt, opts) {
+    return this.dtFormatter(dt, opts).format();
   }
 
-  formatDateTimeParts(dt, opts = {}) {
-    const df = this.loc.dtFormatter(dt, { ...this.opts, ...opts });
-    return df.formatToParts();
+  formatDateTimeParts(dt, opts) {
+    return this.dtFormatter(dt, opts).formatToParts();
   }
 
-  formatInterval(interval, opts = {}) {
-    const df = this.loc.dtFormatter(interval.start, { ...this.opts, ...opts });
+  formatInterval(interval, opts) {
+    const df = this.dtFormatter(interval.start, 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();
+  resolvedOptions(dt, opts) {
+    return this.dtFormatter(dt, opts).resolvedOptions();
   }
 
   num(n, p = 0) {
diff --git a/src/impl/tokenParser.js b/src/impl/tokenParser.js
index 5a913b0f..19008d29 100644
--- a/src/impl/tokenParser.js
+++ b/src/impl/tokenParser.js
@@ -219,10 +219,14 @@ const partTypeStyleToTokenVal = {
   },
   dayperiod: "a",
   dayPeriod: "a",
-  hour: {
+  hour12: {
     numeric: "h",
     "2-digit": "hh",
   },
+  hour24: {
+    numeric: "H",
+    "2-digit": "HH",
+  },
   minute: {
     numeric: "m",
     "2-digit": "mm",
@@ -237,7 +241,7 @@ const partTypeStyleToTokenVal = {
   },
 };
 
-function tokenForPart(part, formatOpts) {
+function tokenForPart(part, formatOpts, resolvedOpts) {
   const { type, value } = part;
 
   if (type === "literal") {
@@ -250,7 +254,26 @@ function tokenForPart(part, formatOpts) {
 
   const style = formatOpts[type];
 
-  let val = partTypeStyleToTokenVal[type];
+  // The user might have explicitly specified hour12 or hourCycle
+  // if so, respect their decision
+  // if not, refer back to the resolvedOpts, which are based on the locale
+  let actualType = type;
+  if (type === "hour") {
+    if (formatOpts.hour12 != null) {
+      actualType = formatOpts.hour12 ? "hour12" : "hour24";
+    } else if (formatOpts.hourCycle != null) {
+      if (formatOpts.hourCycle === "h11" || formatOpts.hourCycle === "h12") {
+        actualType = "hour12";
+      } else {
+        actualType = "hour24";
+      }
+    } else {
+      // tokens only differentiate between 24 hours or not,
+      // so we do not need to check hourCycle here, which is less supported anyways
+      actualType = resolvedOpts.hour12 ? "hour12" : "hour24";
+    }
+  }
+  let val = partTypeStyleToTokenVal[actualType];
   if (typeof val === "object") {
     val = val[style];
   }
@@ -439,6 +462,8 @@ export function formatOptsToTokens(formatOpts, locale) {
   }
 
   const formatter = Formatter.create(locale, formatOpts);
-  const parts = formatter.formatDateTimeParts(getDummyDateTime());
-  return parts.map((p) => tokenForPart(p, formatOpts));
+  const df = formatter.dtFormatter(getDummyDateTime());
+  const parts = df.formatToParts();
+  const resolvedOpts = df.resolvedOptions();
+  return parts.map((p) => tokenForPart(p, formatOpts, resolvedOpts));
 }


Test patch:
diff --git a/test/datetime/diff.test.js b/test/datetime/diff.test.js
index d9472545..ed118f1c 100644
--- a/test/datetime/diff.test.js
+++ b/test/datetime/diff.test.js
@@ -313,6 +313,21 @@ test("DateTime#diff handles Feb-29 edge case logic for higher order units in a m
   expect(left.plus(diff).equals(right)).toBe(true);
 });
 
+// see https://github.com/moment/luxon/issues/1165
+test("DateTime#diff handles datetimes whose dayDiff is off by 2 days instead of the usual 1 due to zone differences", () => {
+  // Notice how `end` is 5 days away when only looking at calendar days ignoring time zones,
+  // but actually the diff is 3 days and 23 hours (days are off by 2)
+  // because Europe/Madrid here has an offset of +02:00.
+  const start = DateTime.fromISO("2022-05-05T23:00", { zone: "UTC" });
+  const end = DateTime.fromISO("2022-05-10T00:00", { zone: "Europe/Madrid" });
+
+  const diff1 = end.diff(start, ["days", "hours", "minutes"]).toObject();
+  expect(diff1).toEqual({ days: 3, hours: 23, minutes: 0 });
+
+  const diff2 = end.diff(start, ["days"]);
+  expect(diff2.days).toBeCloseTo(3.958333, 6);
+});
+
 //------
 // diffNow
 //-------
diff --git a/test/datetime/format.test.js b/test/datetime/format.test.js
index 3f584444..2aeca906 100644
--- a/test/datetime/format.test.js
+++ b/test/datetime/format.test.js
@@ -96,6 +96,12 @@ test("DateTime#toISO() suppresses [milli]seconds", () => {
   const noZeroSeconds = { suppressSeconds: true, suppressMilliseconds: true };
   expect(dt.set({ millisecond: 0 }).toISO(noZeroSeconds)).toBe("1982-05-25T09:23:54Z");
   expect(dt.set({ seconds: 0, milliseconds: 0 }).toISO(noZeroSeconds)).toBe("1982-05-25T09:23Z");
+
+  const suppressOnlySeconds = { suppressSeconds: true };
+  expect(dt.set({ seconds: 0 }).toISO(suppressOnlySeconds)).toBe("1982-05-25T09:23:00.123Z");
+  expect(dt.set({ seconds: 0, milliseconds: 0 }).toISO(suppressOnlySeconds)).toBe(
+    "1982-05-25T09:23Z"
+  );
 });
 
 test("DateTime#toISO() returns null for invalid DateTimes", () => {
diff --git a/test/datetime/tokenParse.test.js b/test/datetime/tokenParse.test.js
index 9ba0d162..683d60e9 100644
--- a/test/datetime/tokenParse.test.js
+++ b/test/datetime/tokenParse.test.js
@@ -1186,6 +1186,17 @@ test("DateTime.parseFormatForOpts returns a parsing format", () => {
   const format = DateTime.parseFormatForOpts("");
   expect(format).toBeNull();
 });
+test("DateTime.parseFormatForOpts respects the hour cycle", () => {
+  const enFormat = DateTime.parseFormatForOpts(DateTime.TIME_SIMPLE, { locale: "en-US" });
+  expect(enFormat).toEqual("h:m a");
+
+  const deFormat = DateTime.parseFormatForOpts(DateTime.TIME_SIMPLE, { locale: "de-DE" });
+  expect(deFormat).toEqual("H:m");
+});
+test("DateTime.parseFormatForOpts respects the hour cycle when forced by the options", () => {
+  const format = DateTime.parseFormatForOpts(DateTime.TIME_24_SIMPLE, { locale: "en-US" });
+  expect(format).toEqual("H:m");
+});
 
 //------
 // .expandFormat
@@ -1200,3 +1211,16 @@ test("DateTime.expandFormat works with other locales", () => {
   const format = DateTime.expandFormat("D", { locale: "en-gb" });
   expect(format).toBe("d/M/yyyyy");
 });
+
+test("DateTime.expandFormat respects the hour cycle", () => {
+  const enFormat = DateTime.expandFormat("t", { locale: "en-US" });
+  expect(enFormat).toBe("h:m a");
+
+  const deFormat = DateTime.expandFormat("t", { locale: "de-DE" });
+  expect(deFormat).toBe("H:m");
+});
+
+test("DateTime.expandFormat respects the hour cycle when forced by the macro token", () => {
+  const format = DateTime.expandFormat("T", { locale: "en-US" });
+  expect(format).toBe("H:m");
+});
diff --git a/test/duration/format.test.js b/test/duration/format.test.js
index abcf4f4c..408660f8 100644
--- a/test/duration/format.test.js
+++ b/test/duration/format.test.js
@@ -117,6 +117,12 @@ test("Duration#toISOTime returns null if the value is outside the range of one d
   expect(Duration.fromObject({ milliseconds: -1 }).toISOTime()).toBe(null);
 });
 
+test("Duration#toISOTime is not influenced by the locale", () => {
+  expect(Duration.fromObject({ hours: 3, minutes: 10 }, { locale: "ar-QA" }).toISOTime()).toBe(
+    "03:10:00.000"
+  );
+});
+
 //------
 // #toMillis()
 //------