diff --git a/Jint.Tests.Test262/IcuCldrProvider.cs b/Jint.Tests.Test262/IcuCldrProvider.cs
index 8c5c76c013..b433b329bf 100644
--- a/Jint.Tests.Test262/IcuCldrProvider.cs
+++ b/Jint.Tests.Test262/IcuCldrProvider.cs
@@ -271,67 +271,20 @@ private static string MapToCldrUnit(string unit)
public DateTimePatterns? GetDateTimePatterns(string locale, string? dateStyle, string? timeStyle)
=> _fallback.GetDateTimePatterns(locale, dateStyle, timeStyle);
- public string[]? GetMonthNames(string locale, string style)
- => _fallback.GetMonthNames(locale, style);
+ public string[]? GetMonthNames(string locale, string style, string? calendar)
+ => _fallback.GetMonthNames(locale, style, calendar);
public string[]? GetWeekdayNames(string locale, string style)
=> _fallback.GetWeekdayNames(locale, style);
- public string[]? GetDayPeriods(string locale, string style)
- => _fallback.GetDayPeriods(locale, style);
+ public string[]? GetDayPeriods(string locale, string style, string? calendar)
+ => _fallback.GetDayPeriods(locale, style, calendar);
- public string[]? GetEraNames(string locale, string style)
- => _fallback.GetEraNames(locale, style);
+ public string[]? GetEraNames(string locale, string style, string? calendar)
+ => _fallback.GetEraNames(locale, style, calendar);
// === Display Names ===
- public string? GetLanguageDisplayName(string locale, string code)
- {
- try
- {
- var displayLocale = new UCultureInfo(locale);
- var codeLocale = new UCultureInfo(code);
- var result = codeLocale.GetDisplayLanguage(displayLocale);
- return string.IsNullOrEmpty(result) ? _fallback.GetLanguageDisplayName(locale, code) : result;
- }
- catch
- {
- return _fallback.GetLanguageDisplayName(locale, code);
- }
- }
-
- public string? GetRegionDisplayName(string locale, string code)
- {
- try
- {
- var displayLocale = new UCultureInfo(locale);
- // Create a locale with just the region
- var codeLocale = new UCultureInfo("und-" + code);
- var result = codeLocale.GetDisplayCountry(displayLocale);
- return string.IsNullOrEmpty(result) ? _fallback.GetRegionDisplayName(locale, code) : result;
- }
- catch
- {
- return _fallback.GetRegionDisplayName(locale, code);
- }
- }
-
- public string? GetScriptDisplayName(string locale, string code)
- {
- try
- {
- var displayLocale = new UCultureInfo(locale);
- // Create a locale with just the script
- var codeLocale = new UCultureInfo("und-" + code);
- var result = codeLocale.GetDisplayScript(displayLocale);
- return string.IsNullOrEmpty(result) ? _fallback.GetScriptDisplayName(locale, code) : result;
- }
- catch
- {
- return _fallback.GetScriptDisplayName(locale, code);
- }
- }
-
public string? GetCurrencyDisplayName(string locale, string code)
=> _fallback.GetCurrencyDisplayName(locale, code);
diff --git a/Jint.Tests.Test262/Jint.Tests.Test262.csproj b/Jint.Tests.Test262/Jint.Tests.Test262.csproj
index 8a973847ce..4e8893c722 100644
--- a/Jint.Tests.Test262/Jint.Tests.Test262.csproj
+++ b/Jint.Tests.Test262/Jint.Tests.Test262.csproj
@@ -22,6 +22,7 @@
+
diff --git a/Jint.Tests.Test262/NodaTimeZoneProvider.cs b/Jint.Tests.Test262/NodaTimeZoneProvider.cs
new file mode 100644
index 0000000000..c27de6bd67
--- /dev/null
+++ b/Jint.Tests.Test262/NodaTimeZoneProvider.cs
@@ -0,0 +1,530 @@
+#nullable enable
+
+using System.Numerics;
+using Jint.Native.Temporal;
+using NodaTime;
+using NodaTime.TimeZones;
+
+namespace Jint.Tests.Test262;
+
+///
+/// NodaTime-based time zone provider for accurate IANA timezone support.
+/// Provides sub-minute offset precision, precise historical DST transitions,
+/// and comprehensive timezone alias resolution.
+///
+internal sealed class NodaTimeZoneProvider : ITimeZoneProvider
+{
+ private static readonly IDateTimeZoneProvider TzdbProvider = DateTimeZoneProviders.Tzdb;
+ private static readonly TzdbDateTimeZoneSource TzdbSource = TzdbDateTimeZoneSource.Default;
+
+ public static NodaTimeZoneProvider Instance { get; } = new();
+
+ private static readonly BigInteger NanosecondsPerTick = 100;
+ private static readonly BigInteger NanosecondsPerSecond = 1_000_000_000;
+
+ public long GetOffsetNanosecondsFor(string timeZoneId, BigInteger epochNanoseconds)
+ {
+ if (string.Equals(timeZoneId, "UTC", StringComparison.Ordinal) ||
+ string.Equals(timeZoneId, "Etc/UTC", StringComparison.Ordinal))
+ {
+ return 0;
+ }
+
+ // Handle offset-based time zones
+ var parsedOffset = ParseOffsetString(timeZoneId);
+ if (parsedOffset.HasValue)
+ {
+ return (long)parsedOffset.Value.TotalMilliseconds * 1_000_000L;
+ }
+
+ var zone = ResolveZone(timeZoneId);
+ if (zone is null)
+ {
+ throw new ArgumentException($"Unknown time zone: {timeZoneId}", nameof(timeZoneId));
+ }
+
+ var instant = EpochNanosecondsToInstant(epochNanoseconds);
+ var offset = zone.GetUtcOffset(instant);
+ return offset.Nanoseconds;
+ }
+
+ public BigInteger[] GetPossibleInstantsFor(
+ string timeZoneId,
+ int year, int month, int day,
+ int hour, int minute, int second,
+ int millisecond, int microsecond, int nanosecond)
+ {
+ if (string.Equals(timeZoneId, "UTC", StringComparison.Ordinal) ||
+ string.Equals(timeZoneId, "Etc/UTC", StringComparison.Ordinal))
+ {
+ return [LocalToEpochNanoseconds(year, month, day, hour, minute, second, millisecond, microsecond, nanosecond, Offset.Zero)];
+ }
+
+ var parsedOffset = ParseOffsetString(timeZoneId);
+ if (parsedOffset.HasValue)
+ {
+ var msOffset = (long)parsedOffset.Value.TotalMilliseconds;
+ var offsetSeconds = (int)(msOffset / 1000);
+ var nodaOffset = Offset.FromSeconds(offsetSeconds);
+ return [LocalToEpochNanoseconds(year, month, day, hour, minute, second, millisecond, microsecond, nanosecond, nodaOffset)];
+ }
+
+ var zone = ResolveZone(timeZoneId);
+ if (zone is null)
+ {
+ throw new ArgumentException($"Unknown time zone: {timeZoneId}", nameof(timeZoneId));
+ }
+
+ try
+ {
+ var localDateTime = new LocalDateTime(year, month, day, hour, minute, second)
+ .PlusMilliseconds(millisecond);
+
+ var mapping = zone.MapLocal(localDateTime);
+
+ switch (mapping.Count)
+ {
+ case 0:
+ // Gap (spring forward) - no valid instants
+ return [];
+ case 1:
+ {
+ // Unambiguous
+ var instant = mapping.Single();
+ var epochNs = InstantToEpochNanoseconds(instant.ToInstant());
+ // Add sub-millisecond precision
+ epochNs += (BigInteger)microsecond * 1000 + nanosecond;
+ return [epochNs];
+ }
+ default:
+ {
+ // Ambiguous (fall back) - two instants
+ var first = mapping.First();
+ var last = mapping.Last();
+ var epochNs1 = InstantToEpochNanoseconds(first.ToInstant());
+ var epochNs2 = InstantToEpochNanoseconds(last.ToInstant());
+ epochNs1 += (BigInteger)microsecond * 1000 + nanosecond;
+ epochNs2 += (BigInteger)microsecond * 1000 + nanosecond;
+ var results = new[] { epochNs1, epochNs2 };
+ Array.Sort(results);
+ return results;
+ }
+ }
+ }
+ catch (ArgumentOutOfRangeException)
+ {
+ // Date is outside NodaTime's representable range.
+ // Use the offset from the boundary instant (no DST at these extremes).
+ try
+ {
+ var boundaryInstant = year > 0
+ ? Instant.FromUnixTimeTicks(NodaMaxTicks)
+ : Instant.FromUnixTimeTicks(NodaMinTicks);
+ var offset = zone.GetUtcOffset(boundaryInstant);
+ return [LocalToEpochNanoseconds(year, month, day, hour, minute, second, millisecond, microsecond, nanosecond, offset)];
+ }
+ catch
+ {
+ return [];
+ }
+ }
+ }
+
+ public BigInteger? GetNextTransition(string timeZoneId, BigInteger epochNanoseconds)
+ {
+ if (string.Equals(timeZoneId, "UTC", StringComparison.Ordinal) ||
+ string.Equals(timeZoneId, "Etc/UTC", StringComparison.Ordinal))
+ {
+ return null;
+ }
+
+ var zone = ResolveZone(timeZoneId);
+ if (zone is null)
+ {
+ return null;
+ }
+
+ var instant = EpochNanosecondsToInstant(epochNanoseconds);
+ var interval = zone.GetZoneInterval(instant);
+
+ // Find the next transition where the UTC offset actually changes
+ // (skip rule changes that only affect abbreviation/DST flag but not offset)
+ while (interval.HasEnd)
+ {
+ var nextInterval = zone.GetZoneInterval(interval.End);
+ if (nextInterval.WallOffset != interval.WallOffset)
+ {
+ return InstantToEpochNanoseconds(interval.End);
+ }
+ interval = nextInterval;
+ }
+
+ return null;
+ }
+
+ public BigInteger? GetPreviousTransition(string timeZoneId, BigInteger epochNanoseconds)
+ {
+ if (string.Equals(timeZoneId, "UTC", StringComparison.Ordinal) ||
+ string.Equals(timeZoneId, "Etc/UTC", StringComparison.Ordinal))
+ {
+ return null;
+ }
+
+ var zone = ResolveZone(timeZoneId);
+ if (zone is null)
+ {
+ return null;
+ }
+
+ var instant = EpochNanosecondsToInstant(epochNanoseconds);
+ var interval = zone.GetZoneInterval(instant);
+
+ // Find the previous transition where the UTC offset actually changed
+ // (skip rule changes that only affect abbreviation/DST flag but not offset)
+ while (interval.HasStart)
+ {
+ var transitionNs = InstantToEpochNanoseconds(interval.Start);
+ var prevInstant = interval.Start - Duration.FromNanoseconds(1);
+ var prevInterval = zone.GetZoneInterval(prevInstant);
+
+ // Check nanosecond precision: transition+1ns should find this transition,
+ // but exactly at or before this transition should look further back
+ var isAtOrBefore = epochNanoseconds <= transitionNs;
+
+ if (!isAtOrBefore && prevInterval.WallOffset != interval.WallOffset)
+ {
+ // This transition changed the offset and we're after it
+ return transitionNs;
+ }
+
+ // Skip this transition - either it's a no-op or we're at/before it
+ interval = prevInterval;
+ }
+
+ return null;
+ }
+
+ public bool IsValidTimeZone(string timeZoneId)
+ {
+ if (string.IsNullOrEmpty(timeZoneId))
+ return false;
+
+ // Check for offset strings
+ if (timeZoneId.Length >= 3 && (timeZoneId[0] == '+' || timeZoneId[0] == '-'))
+ {
+ return IsValidOffsetString(timeZoneId);
+ }
+
+ return ResolveZone(timeZoneId) is not null;
+ }
+
+ public string? CanonicalizeTimeZone(string timeZoneId)
+ {
+ if (string.IsNullOrEmpty(timeZoneId))
+ return null;
+
+ // Handle UTC variants
+ if (timeZoneId.Equals("UTC", StringComparison.OrdinalIgnoreCase))
+ return "UTC";
+ if (timeZoneId.Equals("Etc/UTC", StringComparison.OrdinalIgnoreCase))
+ return "Etc/UTC";
+ if (timeZoneId.Equals("Etc/GMT", StringComparison.OrdinalIgnoreCase))
+ return "Etc/GMT";
+ if (timeZoneId.Equals("GMT", StringComparison.OrdinalIgnoreCase))
+ return "GMT";
+
+ // Handle Etc/GMT0 (IANA Link name, no sign)
+ if (timeZoneId.Equals("Etc/GMT0", StringComparison.OrdinalIgnoreCase))
+ return "Etc/GMT0";
+
+ // Handle Etc/GMT+N and Etc/GMT-N
+ if (timeZoneId.StartsWith("Etc/GMT", StringComparison.OrdinalIgnoreCase) && timeZoneId.Length > 7)
+ {
+ var suffix = timeZoneId.Substring(7);
+ if ((suffix[0] == '+' || suffix[0] == '-') && suffix.Length >= 2 && suffix.Length <= 3)
+ {
+ if (suffix.Length == 3 && suffix[1] == '0')
+ return null;
+
+ if (int.TryParse(suffix.AsSpan(1), System.Globalization.NumberStyles.Integer,
+ System.Globalization.CultureInfo.InvariantCulture, out var offset))
+ {
+ var maxOffset = suffix[0] == '-' ? 14 : 12;
+ if (offset >= 0 && offset <= maxOffset)
+ return $"Etc/GMT{suffix}";
+ }
+ }
+ return null;
+ }
+
+ // Handle offset strings
+ if (timeZoneId.Length >= 3 && (timeZoneId[0] == '+' || timeZoneId[0] == '-'))
+ {
+ return CanonicalizeOffsetString(timeZoneId);
+ }
+
+ var zone = ResolveZone(timeZoneId);
+ if (zone is null)
+ return null;
+
+ // Return the ID as resolved by NodaTime (which preserves IANA casing)
+ return zone.Id;
+ }
+
+ public IReadOnlyCollection GetAvailableTimeZones()
+ {
+ // Only return primary/canonical IDs (where CanonicalIdMap maps them to themselves)
+ // This ensures supportedValuesOf('timeZone') returns only distinct canonical IDs
+ return TzdbSource.CanonicalIdMap
+ .Where(kvp => string.Equals(kvp.Key, kvp.Value, StringComparison.Ordinal))
+ .Select(kvp => kvp.Key)
+ .OrderBy(x => x, StringComparer.Ordinal)
+ .ToList();
+ }
+
+ public string GetDefaultTimeZone()
+ {
+ try
+ {
+ var systemZone = DateTimeZoneProviders.Tzdb.GetSystemDefault();
+ return systemZone.Id;
+ }
+ catch
+ {
+ return TimeZoneInfo.Local.Id;
+ }
+ }
+
+ public string? GetPrimaryTimeZoneIdentifier(string timeZoneId)
+ {
+ if (string.IsNullOrEmpty(timeZoneId))
+ return null;
+
+ // UTC variants all map to "UTC"
+ if (timeZoneId.Equals("UTC", StringComparison.OrdinalIgnoreCase) ||
+ timeZoneId.Equals("Etc/UTC", StringComparison.OrdinalIgnoreCase) ||
+ timeZoneId.Equals("Etc/GMT", StringComparison.OrdinalIgnoreCase) ||
+ timeZoneId.Equals("Etc/UCT", StringComparison.OrdinalIgnoreCase) ||
+ timeZoneId.Equals("Etc/GMT0", StringComparison.OrdinalIgnoreCase) ||
+ timeZoneId.Equals("Etc/GMT+0", StringComparison.OrdinalIgnoreCase) ||
+ timeZoneId.Equals("Etc/GMT-0", StringComparison.OrdinalIgnoreCase) ||
+ timeZoneId.Equals("Etc/Greenwich", StringComparison.OrdinalIgnoreCase) ||
+ timeZoneId.Equals("Etc/Universal", StringComparison.OrdinalIgnoreCase) ||
+ timeZoneId.Equals("Etc/Zulu", StringComparison.OrdinalIgnoreCase) ||
+ timeZoneId.Equals("UCT", StringComparison.OrdinalIgnoreCase) ||
+ timeZoneId.Equals("GMT", StringComparison.OrdinalIgnoreCase) ||
+ timeZoneId.Equals("GMT+0", StringComparison.OrdinalIgnoreCase) ||
+ timeZoneId.Equals("GMT-0", StringComparison.OrdinalIgnoreCase) ||
+ timeZoneId.Equals("GMT0", StringComparison.OrdinalIgnoreCase) ||
+ timeZoneId.Equals("Greenwich", StringComparison.OrdinalIgnoreCase) ||
+ timeZoneId.Equals("Universal", StringComparison.OrdinalIgnoreCase) ||
+ timeZoneId.Equals("Zulu", StringComparison.OrdinalIgnoreCase))
+ {
+ return "UTC";
+ }
+
+ // Offset strings
+ if (timeZoneId.Length >= 3 && (timeZoneId[0] == '+' || timeZoneId[0] == '-'))
+ {
+ return CanonicalizeOffsetString(timeZoneId);
+ }
+
+ // Use NodaTime's CanonicalIdMap for alias resolution
+ if (TzdbSource.CanonicalIdMap.TryGetValue(timeZoneId, out var canonicalId))
+ {
+ return canonicalId;
+ }
+
+ // Case-insensitive fallback
+ foreach (var kvp in TzdbSource.CanonicalIdMap)
+ {
+ if (string.Equals(kvp.Key, timeZoneId, StringComparison.OrdinalIgnoreCase))
+ {
+ return kvp.Value;
+ }
+ }
+
+ return CanonicalizeTimeZone(timeZoneId);
+ }
+
+ private static DateTimeZone? ResolveZone(string timeZoneId)
+ {
+ try
+ {
+ return TzdbProvider[timeZoneId];
+ }
+ catch (DateTimeZoneNotFoundException)
+ {
+ // Try case-insensitive lookup
+ foreach (var id in TzdbProvider.Ids)
+ {
+ if (string.Equals(id, timeZoneId, StringComparison.OrdinalIgnoreCase))
+ {
+ return TzdbProvider[id];
+ }
+ }
+ return null;
+ }
+ }
+
+ // NodaTime's Instant tick range (from NodaTime source)
+ private const long NodaMinTicks = -3776735808000000000L;
+ private const long NodaMaxTicks = 2534023007999999999L;
+
+ private static Instant EpochNanosecondsToInstant(BigInteger epochNanoseconds)
+ {
+ // NodaTime Instant has tick (100ns) precision, not nanosecond.
+ // Use floor division (toward negative infinity) so that sub-tick differences
+ // near transition boundaries map to the correct tick:
+ // e.g., transition_ns - 1 must map to the tick BEFORE the transition.
+ // BigInteger.DivRem truncates toward zero, which gives wrong results for negative values.
+ var ticks = FloorDiv(epochNanoseconds, NanosecondsPerTick);
+
+ // Clamp to NodaTime's Instant tick range (Temporal limits can exceed it)
+ if (ticks > NodaMaxTicks)
+ {
+ return Instant.FromUnixTimeTicks(NodaMaxTicks);
+ }
+ if (ticks < NodaMinTicks)
+ {
+ return Instant.FromUnixTimeTicks(NodaMinTicks);
+ }
+
+ return Instant.FromUnixTimeTicks((long)ticks);
+ }
+
+ private static BigInteger FloorDiv(BigInteger a, BigInteger b)
+ {
+ var (quotient, remainder) = BigInteger.DivRem(a, b);
+ // Adjust when remainder is non-zero and signs differ
+ if (remainder != 0 && (remainder < 0) != (b < 0))
+ {
+ quotient--;
+ }
+ return quotient;
+ }
+
+ private static BigInteger InstantToEpochNanoseconds(Instant instant)
+ {
+ // Convert via ticks to preserve precision
+ var ticks = instant.ToUnixTimeTicks();
+ return (BigInteger)ticks * NanosecondsPerTick;
+ }
+
+ private static BigInteger LocalToEpochNanoseconds(
+ int year, int month, int day,
+ int hour, int minute, int second,
+ int millisecond, int microsecond, int nanosecond,
+ Offset offset)
+ {
+ // Calculate total nanoseconds from epoch, then subtract offset
+ var daysSinceEpoch = TemporalHelpers.IsoDateToDays(year, month, day);
+ BigInteger totalNs = daysSinceEpoch;
+ totalNs *= 24L * 60 * 60 * NanosecondsPerSecond;
+ totalNs += (BigInteger)hour * 60 * 60 * NanosecondsPerSecond;
+ totalNs += (BigInteger)minute * 60 * NanosecondsPerSecond;
+ totalNs += (BigInteger)second * NanosecondsPerSecond;
+ totalNs += (BigInteger)millisecond * 1_000_000;
+ totalNs += (BigInteger)microsecond * 1000;
+ totalNs += nanosecond;
+ totalNs -= (BigInteger)offset.Nanoseconds;
+ return totalNs;
+ }
+
+ private static TimeSpan? ParseOffsetString(string input)
+ {
+ if (string.IsNullOrEmpty(input) || input.Length < 3)
+ return null;
+
+ var sign = input[0];
+ if (sign != '+' && sign != '-')
+ return null;
+
+ var isNegative = sign == '-';
+ int hours, minutes = 0, seconds = 0;
+
+ if (input.Length == 3)
+ {
+ if (!int.TryParse(input.AsSpan(1, 2), System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out hours))
+ return null;
+ }
+ else if (input.Length >= 6 && input[3] == ':')
+ {
+ if (!int.TryParse(input.AsSpan(1, 2), System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out hours) ||
+ !int.TryParse(input.AsSpan(4, 2), System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out minutes))
+ return null;
+
+ if (input.Length >= 9 && input[6] == ':')
+ {
+ if (!int.TryParse(input.AsSpan(7, 2), System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out seconds))
+ return null;
+ }
+ }
+ else if (input.Length >= 5)
+ {
+ if (!int.TryParse(input.AsSpan(1, 2), System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out hours) ||
+ !int.TryParse(input.AsSpan(3, 2), System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out minutes))
+ return null;
+ }
+ else
+ {
+ return null;
+ }
+
+ if (hours < 0 || hours > 23 || minutes < 0 || minutes > 59 || seconds < 0 || seconds > 59)
+ return null;
+
+ var offset = new TimeSpan(hours, minutes, seconds);
+ return isNegative ? -offset : offset;
+ }
+
+ private static bool IsValidOffsetString(string timeZoneId)
+ {
+ // Valid formats: +HH (3 chars), +HH:MM (6 chars), +HHMM (5 chars)
+ if (timeZoneId.Length == 3 || timeZoneId.Length == 5 || (timeZoneId.Length == 6 && timeZoneId[3] == ':'))
+ {
+ var parsed = ParseOffsetString(timeZoneId);
+ if (parsed.HasValue)
+ {
+ var totalMinutes = Math.Abs(parsed.Value.TotalMinutes);
+ return totalMinutes <= 23 * 60 + 59;
+ }
+ }
+ return false;
+ }
+
+ private static string? CanonicalizeOffsetString(string timeZoneId)
+ {
+ if (timeZoneId.Length == 3 && char.IsDigit(timeZoneId[1]) && char.IsDigit(timeZoneId[2]))
+ {
+ var parsed = ParseOffsetString(timeZoneId);
+ if (parsed.HasValue)
+ {
+ var totalMinutes = Math.Abs(parsed.Value.TotalMinutes);
+ if (totalMinutes <= 23 * 60 + 59)
+ return $"{timeZoneId}:00";
+ }
+ }
+ else if (timeZoneId.Length == 6 && timeZoneId[3] == ':')
+ {
+ var parsed = ParseOffsetString(timeZoneId);
+ if (parsed.HasValue)
+ {
+ var totalMinutes = Math.Abs(parsed.Value.TotalMinutes);
+ if (totalMinutes <= 23 * 60 + 59)
+ return timeZoneId;
+ }
+ }
+ else if (timeZoneId.Length == 5 && char.IsDigit(timeZoneId[3]))
+ {
+ var parsed = ParseOffsetString(timeZoneId);
+ if (parsed.HasValue)
+ {
+ var totalMinutes = Math.Abs(parsed.Value.TotalMinutes);
+ if (totalMinutes <= 23 * 60 + 59)
+ return $"{timeZoneId.Substring(0, 3)}:{timeZoneId.Substring(3)}";
+ }
+ }
+ return null;
+ }
+}
diff --git a/Jint.Tests.Test262/Test262Harness.settings.json b/Jint.Tests.Test262/Test262Harness.settings.json
index 7702bd09be..0562c556ed 100644
--- a/Jint.Tests.Test262/Test262Harness.settings.json
+++ b/Jint.Tests.Test262/Test262Harness.settings.json
@@ -128,6 +128,7 @@
"intl402/DateTimeFormat/prototype/formatRangeToParts/chinese-calendar-dates.js",
"intl402/DateTimeFormat/prototype/formatRangeToParts/dangi-calendar-dates.js",
"intl402/DateTimeFormat/prototype/formatToParts/chinese-calendar-dates.js",
+ "intl402/DateTimeFormat/prototype/formatToParts/dangi-calendar-dates.js",
// DurationFormat - precision formatting requires exact sub-second arithmetic
"intl402/DurationFormat/prototype/format/precision-exact-mathematical-values.js",
@@ -136,6 +137,7 @@
// NumberFormat - locale-specific formatting and formatRange
"intl402/NumberFormat/prototype/format/format-significant-digits.js",
+ "intl402/NumberFormat/prototype/format/numbering-systems.js",
"intl402/NumberFormat/prototype/format/useGrouping-extended-en-IN.js",
"intl402/NumberFormat/prototype/format/value-decimal-string.js",
// formatRange requires locale-specific CLDR range patterns and part collapsing
@@ -155,22 +157,110 @@
"intl402/fallback-locales-are-supported.js",
"intl402/supportedLocalesOf-consistent-with-resolvedOptions.js",
- // misc features that require investigation
- "intl402/NumberFormat/prototype/format/numbering-systems.js",
+ // === INTL402 TEMPORAL EXCLUSIONS ===
+ // Non-ISO calendar arithmetic not yet implemented (Chinese/Dangi lunisolar, Hebrew, Persian, etc.)
+ // These require CalendarDateAdd, CalendarDateUntil, monthCode, monthsInYear, inLeapYear
+ // for non-Gregorian-based calendars.
+
+ // Chinese calendar - lunisolar calendar arithmetic
+ "intl402/Temporal/PlainDate/prototype/inLeapYear/chinese-calendar-dates.js",
+ "intl402/Temporal/PlainDate/prototype/monthCode/chinese-calendar-dates.js",
+ "intl402/Temporal/PlainDate/prototype/monthsInYear/chinese-calendar-leap-days.js",
+ "intl402/Temporal/PlainDate/prototype/since/chinese-calendar-dates.js",
+ "intl402/Temporal/PlainDate/prototype/subtract/chinese-calendar-dates.js",
+ "intl402/Temporal/PlainDate/prototype/until/chinese-calendar-dates.js",
+ "intl402/Temporal/PlainDate/prototype/with/chinese-calendar-leap-dates.js",
+ "intl402/Temporal/PlainDateTime/prototype/inLeapYear/chinese-calendar-dates.js",
+ "intl402/Temporal/PlainDateTime/prototype/monthCode/chinese-calendar-dates.js",
+ "intl402/Temporal/PlainDateTime/prototype/monthsInYear/chinese-calendar-leap-days.js",
+ "intl402/Temporal/PlainDateTime/prototype/since/chinese-calendar-dates.js",
+ "intl402/Temporal/PlainDateTime/prototype/until/chinese-calendar-dates.js",
+ "intl402/Temporal/PlainDateTime/prototype/with/chinese-calendar-leap-dates.js",
+ "intl402/Temporal/PlainMonthDay/from/chinese-calendar-dates.js",
+ "intl402/Temporal/PlainMonthDay/prototype/monthCode/chinese-calendar-dates.js",
+ "intl402/Temporal/PlainYearMonth/from/reference-day-chinese.js",
+ "intl402/Temporal/PlainYearMonth/prototype/inLeapYear/chinese-calendar-dates.js",
+ "intl402/Temporal/PlainYearMonth/prototype/monthCode/chinese-calendar-dates.js",
+ "intl402/Temporal/PlainYearMonth/prototype/monthsInYear/chinese-calendar-leap-days.js",
+ "intl402/Temporal/PlainYearMonth/prototype/since/chinese-calendar-dates.js",
+ "intl402/Temporal/PlainYearMonth/prototype/until/chinese-calendar-dates.js",
+ "intl402/Temporal/PlainYearMonth/prototype/with/chinese-calendar-leap-dates.js",
+ "intl402/Temporal/ZonedDateTime/prototype/inLeapYear/chinese-calendar-dates.js",
+ "intl402/Temporal/ZonedDateTime/prototype/monthCode/chinese-calendar-dates.js",
+ "intl402/Temporal/ZonedDateTime/prototype/monthsInYear/chinese-calendar-leap-days.js",
+ "intl402/Temporal/ZonedDateTime/prototype/since/chinese-calendar-dates.js",
+ "intl402/Temporal/ZonedDateTime/prototype/until/chinese-calendar-dates.js",
+ "intl402/Temporal/ZonedDateTime/prototype/with/chinese-calendar-leap-dates.js",
+
+ // Dangi (Korean) calendar - lunisolar calendar arithmetic
+ "intl402/Temporal/PlainDate/prototype/inLeapYear/dangi-calendar-dates.js",
+ "intl402/Temporal/PlainDate/prototype/monthCode/dangi-calendar-dates.js",
+ "intl402/Temporal/PlainDate/prototype/monthsInYear/dangi-calendar-leap-days.js",
+ "intl402/Temporal/PlainDate/prototype/since/dangi-calendar-dates.js",
+ "intl402/Temporal/PlainDate/prototype/subtract/dangi-calendar-dates.js",
+ "intl402/Temporal/PlainDate/prototype/until/dangi-calendar-dates.js",
+ "intl402/Temporal/PlainDate/prototype/with/dangi-calendar-leap-dates.js",
+ "intl402/Temporal/PlainDateTime/prototype/inLeapYear/dangi-calendar-dates.js",
+ "intl402/Temporal/PlainDateTime/prototype/monthCode/dangi-calendar-dates.js",
+ "intl402/Temporal/PlainDateTime/prototype/monthsInYear/dangi-calendar-leap-days.js",
+ "intl402/Temporal/PlainDateTime/prototype/since/dangi-calendar-dates.js",
+ "intl402/Temporal/PlainDateTime/prototype/until/dangi-calendar-dates.js",
+ "intl402/Temporal/PlainDateTime/prototype/with/dangi-calendar-leap-dates.js",
+ "intl402/Temporal/PlainMonthDay/from/dangi-30-day-leap-months.js",
+ "intl402/Temporal/PlainMonthDay/from/dangi-calendar-dates.js",
+ "intl402/Temporal/PlainMonthDay/prototype/monthCode/dangi-calendar-dates.js",
+ "intl402/Temporal/PlainYearMonth/prototype/inLeapYear/dangi-calendar-dates.js",
+ "intl402/Temporal/PlainYearMonth/prototype/monthCode/dangi-calendar-dates.js",
+ "intl402/Temporal/PlainYearMonth/prototype/monthsInYear/dangi-calendar-leap-days.js",
+ "intl402/Temporal/PlainYearMonth/prototype/since/dangi-calendar-dates.js",
+ "intl402/Temporal/PlainYearMonth/prototype/until/dangi-calendar-dates.js",
+ "intl402/Temporal/PlainYearMonth/prototype/with/dangi-calendar-leap-dates.js",
+ "intl402/Temporal/ZonedDateTime/prototype/inLeapYear/dangi-calendar-dates.js",
+ "intl402/Temporal/ZonedDateTime/prototype/monthCode/dangi-calendar-dates.js",
+ "intl402/Temporal/ZonedDateTime/prototype/monthsInYear/dangi-calendar-leap-days.js",
+ "intl402/Temporal/ZonedDateTime/prototype/since/dangi-calendar-dates.js",
+ "intl402/Temporal/ZonedDateTime/prototype/until/dangi-calendar-dates.js",
+ "intl402/Temporal/ZonedDateTime/prototype/with/dangi-calendar-leap-dates.js",
+
+ // Non-ISO calendar fields - Hebrew/multi-calendar with() operations
+ "intl402/Temporal/PlainDateTime/prototype/with/non-iso-calendar-fields.js",
+ "intl402/Temporal/PlainYearMonth/prototype/with/non-iso-calendar-fields.js",
+ "intl402/Temporal/ZonedDateTime/prototype/with/non-iso-calendar-fields.js",
+
+ // Leap year since/until across non-ISO calendars (Chinese + Hebrew)
+ "intl402/Temporal/PlainDate/prototype/since/leap-year-since.js",
+ "intl402/Temporal/PlainDate/prototype/until/leap-year-until.js",
+ "intl402/Temporal/PlainDateTime/prototype/since/leap-year-since.js",
+ "intl402/Temporal/PlainDateTime/prototype/until/leap-year-until.js",
+ "intl402/Temporal/ZonedDateTime/prototype/since/leap-year-since.js",
+ "intl402/Temporal/ZonedDateTime/prototype/until/leap-year-until.js",
+
+ // dateStyle with non-ISO calendar formatting (Islamic month names etc.)
+ "intl402/Temporal/Instant/prototype/toLocaleString/dateStyle.js",
+ "intl402/Temporal/PlainDate/prototype/toLocaleString/dateStyle.js",
+ "intl402/Temporal/PlainDateTime/prototype/toLocaleString/dateStyle.js",
+ "intl402/Temporal/PlainMonthDay/prototype/toLocaleString/dateStyle.js",
+ "intl402/Temporal/PlainYearMonth/prototype/toLocaleString/dateStyle.js",
+ "intl402/Temporal/ZonedDateTime/prototype/toLocaleString/dateStyle.js",
+
+ // Persian calendar arithmetic
+ "intl402/Temporal/PlainDate/prototype/toString/basic-persian.js",
+
+ // Calendar era handling - requires Chinese/Hebrew calendar arithmetic
+ "intl402/Temporal/PlainYearMonth/from/calendar-not-supporting-eras.js",
+ "intl402/Temporal/ZonedDateTime/from/calendar-not-supporting-eras.js",
+
+ // Hebrew calendar arithmetic (reference-day, add/subtract with leap months)
+ "intl402/Temporal/PlainYearMonth/from/reference-day-hebrew.js",
+ "intl402/Temporal/PlainYearMonth/prototype/add/options-undefined.js",
+ "intl402/Temporal/PlainYearMonth/prototype/subtract/options-undefined.js",
+
+ // Japanese calendar era boundary resolution
+ "intl402/Temporal/PlainYearMonth/from/reference-day-japanese.js",
- // Temporal
- "intl402/Temporal/Duration/**/*.js",
- "intl402/Temporal/Instant/**/*.js",
- "intl402/Temporal/PlainDate/**/*.js",
- "intl402/Temporal/PlainDateTime/**/*.js",
- "intl402/Temporal/PlainMonthDay/**/*.js",
- "intl402/Temporal/PlainTime/**/*.js",
- "intl402/Temporal/PlainYearMonth/**/*.js",
- "intl402/Temporal/ZonedDateTime/**/*.js",
- "intl402/DateTimeFormat/**/*temporal*.js",
- "intl402/DurationFormat/**/*temporal*.js",
- "intl402/DateTimeFormat/prototype/formatRange/to-datetime-formattable-with-different-arg-kinds.js",
- "intl402/DateTimeFormat/prototype/formatRangeToParts/to-datetime-formattable-with-different-arg-kinds.js",
- "intl402/DateTimeFormat/prototype/formatToParts/dangi-calendar-dates.js"
+ // PlainMonthDay from - multi-calendar (constrain, reference-date, reference-year)
+ "intl402/Temporal/PlainMonthDay/from/constrain-to-leap-day.js",
+ "intl402/Temporal/PlainMonthDay/from/reference-date-noniso-calendar.js",
+ "intl402/Temporal/PlainMonthDay/from/reference-year-1972.js"
]
}
\ No newline at end of file
diff --git a/Jint.Tests.Test262/Test262Test.cs b/Jint.Tests.Test262/Test262Test.cs
index d19161e7a3..18c456825e 100644
--- a/Jint.Tests.Test262/Test262Test.cs
+++ b/Jint.Tests.Test262/Test262Test.cs
@@ -24,6 +24,8 @@ private static Engine BuildTestExecutor(Test262File file, Test262AgentManager? a
cfg.TimeoutInterval(TimeSpan.FromSeconds(30));
// Use ICU-based CLDR provider for better Intl support
cfg.Intl.CldrProvider = IcuCldrProvider.Instance;
+ // Use NodaTime for accurate IANA timezone support (sub-minute offsets, historical DST)
+ cfg.Temporal.TimeZoneProvider = NodaTimeZoneProvider.Instance;
});
if (file.Flags.Contains("raw"))
diff --git a/Jint.Tests/Runtime/EngineTests.cs b/Jint.Tests/Runtime/EngineTests.cs
index eaa0589a5a..ea855248e8 100644
--- a/Jint.Tests/Runtime/EngineTests.cs
+++ b/Jint.Tests/Runtime/EngineTests.cs
@@ -1851,9 +1851,9 @@ public void DateToStringMethodsShouldUseCurrentTimeZoneAndCulture()
equal('Mon Jun 01 2015', d.toDateString());
equal('05:00:00 GMT-0700 (Pacific Standard Time)', d.toTimeString());
// ECMA-402 compliant: numeric defaults used when no options specified
- equal('1/6/2015 5:00:00', d.toLocaleString());
+ equal('1/6/2015, 05:00:00', d.toLocaleString());
equal('1/6/2015', d.toLocaleDateString());
- equal('5:00:00', d.toLocaleTimeString());
+ equal('05:00:00', d.toLocaleTimeString());
");
}
diff --git a/Jint/Native/Date/DatePrototype.cs b/Jint/Native/Date/DatePrototype.cs
index 066a46338e..daad78509b 100644
--- a/Jint/Native/Date/DatePrototype.cs
+++ b/Jint/Native/Date/DatePrototype.cs
@@ -249,8 +249,9 @@ private JsValue ToLocaleString(JsValue thisObject, JsCallArguments arguments)
}
// Use Intl.DateTimeFormat for locale-aware formatting
+ // Pass UTC time; DTF handles timezone conversion based on its timeZone option
var dateTimeFormat = (JsDateTimeFormat) Engine.Realm.Intrinsics.DateTimeFormat.Construct([locales, options], Engine.Realm.Intrinsics.DateTimeFormat);
- return dateTimeFormat.Format(ToLocalTime(dateInstance));
+ return dateTimeFormat.Format(dateInstance.ToDateTime());
}
///
@@ -287,8 +288,9 @@ private JsValue ToLocaleDateString(JsValue thisObject, JsCallArguments arguments
}
// Use Intl.DateTimeFormat for locale-aware formatting
+ // Pass UTC time; DTF handles timezone conversion based on its timeZone option
var dateTimeFormat = (JsDateTimeFormat) Engine.Realm.Intrinsics.DateTimeFormat.Construct([locales, options], Engine.Realm.Intrinsics.DateTimeFormat);
- return dateTimeFormat.Format(ToLocalTime(dateInstance));
+ return dateTimeFormat.Format(dateInstance.ToDateTime());
}
///
@@ -325,8 +327,9 @@ private JsValue ToLocaleTimeString(JsValue thisObject, JsCallArguments arguments
}
// Use Intl.DateTimeFormat for locale-aware formatting
+ // Pass UTC time; DTF handles timezone conversion based on its timeZone option
var dateTimeFormat = (JsDateTimeFormat) Engine.Realm.Intrinsics.DateTimeFormat.Construct([locales, options], Engine.Realm.Intrinsics.DateTimeFormat);
- return dateTimeFormat.Format(ToLocalTime(dateInstance));
+ return dateTimeFormat.Format(dateInstance.ToDateTime());
}
///
@@ -341,14 +344,13 @@ private static bool NeedDateTimeDefaults(ObjectInstance options, bool checkDate,
return false;
}
- // Check date-related properties
+ // Check date-related properties (era is intentionally not checked per spec)
if (checkDate)
{
if (!options.Get("weekday").IsUndefined() ||
!options.Get("year").IsUndefined() ||
!options.Get("month").IsUndefined() ||
- !options.Get("day").IsUndefined() ||
- !options.Get("era").IsUndefined())
+ !options.Get("day").IsUndefined())
{
return false;
}
@@ -384,7 +386,7 @@ private static void CopyOptions(ObjectInstance source, ObjectInstance target)
// Date components
"weekday", "era", "year", "month", "day",
// Time components
- "dayPeriod", "hour", "minute", "second", "fractionalSecondDigits"
+ "dayPeriod", "hour", "minute", "second", "fractionalSecondDigits", "timeZoneName"
};
foreach (var option in optionsToCopy)
{
diff --git a/Jint/Native/Intl/Data/MetaZoneData.cs b/Jint/Native/Intl/Data/MetaZoneData.cs
new file mode 100644
index 0000000000..68c60e0bbf
--- /dev/null
+++ b/Jint/Native/Intl/Data/MetaZoneData.cs
@@ -0,0 +1,649 @@
+using System.Runtime.InteropServices;
+
+namespace Jint.Native.Intl.Data;
+
+///
+/// CLDR metazone data for timezone display name resolution.
+/// Maps IANA timezone IDs to metazones, and provides en-US display names.
+/// Based on CLDR metaZones.xml and timeZoneNames.json for en-US locale.
+///
+internal static class MetaZoneData
+{
+ [StructLayout(LayoutKind.Auto)]
+ internal readonly record struct MetaZoneNames(
+ string LongStandard,
+ string LongDaylight,
+ string LongGeneric,
+ string ShortStandard,
+ string ShortDaylight,
+ string ShortGeneric);
+
+ ///
+ /// Maps IANA timezone IDs to their current CLDR metazone name.
+ ///
+ internal static readonly Dictionary IanaToMetaZone = new(StringComparer.OrdinalIgnoreCase)
+ {
+ // Europe_Central
+ ["Europe/Andorra"] = "Europe_Central",
+ ["Europe/Belgrade"] = "Europe_Central",
+ ["Europe/Berlin"] = "Europe_Central",
+ ["Europe/Bratislava"] = "Europe_Central",
+ ["Europe/Brussels"] = "Europe_Central",
+ ["Europe/Budapest"] = "Europe_Central",
+ ["Europe/Busingen"] = "Europe_Central",
+ ["Europe/Copenhagen"] = "Europe_Central",
+ ["Europe/Gibraltar"] = "Europe_Central",
+ ["Europe/Ljubljana"] = "Europe_Central",
+ ["Europe/Luxembourg"] = "Europe_Central",
+ ["Europe/Madrid"] = "Europe_Central",
+ ["Europe/Malta"] = "Europe_Central",
+ ["Europe/Monaco"] = "Europe_Central",
+ ["Europe/Oslo"] = "Europe_Central",
+ ["Europe/Paris"] = "Europe_Central",
+ ["Europe/Podgorica"] = "Europe_Central",
+ ["Europe/Prague"] = "Europe_Central",
+ ["Europe/Rome"] = "Europe_Central",
+ ["Europe/San_Marino"] = "Europe_Central",
+ ["Europe/Sarajevo"] = "Europe_Central",
+ ["Europe/Skopje"] = "Europe_Central",
+ ["Europe/Stockholm"] = "Europe_Central",
+ ["Europe/Tirane"] = "Europe_Central",
+ ["Europe/Vaduz"] = "Europe_Central",
+ ["Europe/Vatican"] = "Europe_Central",
+ ["Europe/Vienna"] = "Europe_Central",
+ ["Europe/Warsaw"] = "Europe_Central",
+ ["Europe/Zagreb"] = "Europe_Central",
+ ["Europe/Zurich"] = "Europe_Central",
+ ["Africa/Ceuta"] = "Europe_Central",
+ ["Africa/Tunis"] = "Europe_Central",
+ ["Arctic/Longyearbyen"] = "Europe_Central",
+
+ // Europe_Eastern
+ ["Europe/Athens"] = "Europe_Eastern",
+ ["Europe/Bucharest"] = "Europe_Eastern",
+ ["Europe/Chisinau"] = "Europe_Eastern",
+ ["Europe/Helsinki"] = "Europe_Eastern",
+ ["Europe/Kyiv"] = "Europe_Eastern",
+ ["Europe/Mariehamn"] = "Europe_Eastern",
+ ["Europe/Nicosia"] = "Europe_Eastern",
+ ["Europe/Riga"] = "Europe_Eastern",
+ ["Europe/Sofia"] = "Europe_Eastern",
+ ["Europe/Tallinn"] = "Europe_Eastern",
+ ["Europe/Uzhgorod"] = "Europe_Eastern",
+ ["Europe/Vilnius"] = "Europe_Eastern",
+ ["Europe/Zaporozhye"] = "Europe_Eastern",
+ ["Africa/Cairo"] = "Europe_Eastern",
+ ["Asia/Nicosia"] = "Europe_Eastern",
+ ["Asia/Famagusta"] = "Europe_Eastern",
+
+ // Europe_Western
+ ["Atlantic/Canary"] = "Europe_Western",
+ ["Atlantic/Faroe"] = "Europe_Western",
+ ["Atlantic/Madeira"] = "Europe_Western",
+ ["Europe/Lisbon"] = "Europe_Western",
+
+ // GMT
+ ["Europe/London"] = "GMT",
+ ["Europe/Dublin"] = "GMT",
+ ["Atlantic/Reykjavik"] = "GMT",
+ ["Africa/Abidjan"] = "GMT",
+ ["Africa/Accra"] = "GMT",
+ ["Africa/Bamako"] = "GMT",
+ ["Africa/Banjul"] = "GMT",
+ ["Africa/Bissau"] = "GMT",
+ ["Africa/Conakry"] = "GMT",
+ ["Africa/Dakar"] = "GMT",
+ ["Africa/Freetown"] = "GMT",
+ ["Africa/Lome"] = "GMT",
+ ["Africa/Monrovia"] = "GMT",
+ ["Africa/Nouakchott"] = "GMT",
+ ["Africa/Ouagadougou"] = "GMT",
+ ["Africa/Sao_Tome"] = "GMT",
+
+ // America_Eastern
+ ["America/New_York"] = "America_Eastern",
+ ["America/Detroit"] = "America_Eastern",
+ ["America/Indiana/Indianapolis"] = "America_Eastern",
+ ["America/Indiana/Marengo"] = "America_Eastern",
+ ["America/Indiana/Petersburg"] = "America_Eastern",
+ ["America/Indiana/Tell_City"] = "America_Eastern",
+ ["America/Indiana/Vevay"] = "America_Eastern",
+ ["America/Indiana/Vincennes"] = "America_Eastern",
+ ["America/Indiana/Winamac"] = "America_Eastern",
+ ["America/Iqaluit"] = "America_Eastern",
+ ["America/Kentucky/Louisville"] = "America_Eastern",
+ ["America/Kentucky/Monticello"] = "America_Eastern",
+ ["America/Nassau"] = "America_Eastern",
+ ["America/Nipigon"] = "America_Eastern",
+ ["America/Pangnirtung"] = "America_Eastern",
+ ["America/Port-au-Prince"] = "America_Eastern",
+ ["America/Thunder_Bay"] = "America_Eastern",
+ ["America/Toronto"] = "America_Eastern",
+
+ // America_Central
+ ["America/Chicago"] = "America_Central",
+ ["America/Indiana/Knox"] = "America_Central",
+ ["America/Matamoros"] = "America_Central",
+ ["America/Menominee"] = "America_Central",
+ ["America/North_Dakota/Beulah"] = "America_Central",
+ ["America/North_Dakota/Center"] = "America_Central",
+ ["America/North_Dakota/New_Salem"] = "America_Central",
+ ["America/Rainy_River"] = "America_Central",
+ ["America/Rankin_Inlet"] = "America_Central",
+ ["America/Resolute"] = "America_Central",
+ ["America/Winnipeg"] = "America_Central",
+ ["America/Bahia_Banderas"] = "America_Central",
+ ["America/Belize"] = "America_Central",
+ ["America/Costa_Rica"] = "America_Central",
+ ["America/El_Salvador"] = "America_Central",
+ ["America/Guatemala"] = "America_Central",
+ ["America/Managua"] = "America_Central",
+ ["America/Merida"] = "America_Central",
+ ["America/Mexico_City"] = "America_Central",
+ ["America/Monterrey"] = "America_Central",
+ ["America/Regina"] = "America_Central",
+ ["America/Swift_Current"] = "America_Central",
+ ["America/Tegucigalpa"] = "America_Central",
+
+ // America_Mountain
+ ["America/Denver"] = "America_Mountain",
+ ["America/Boise"] = "America_Mountain",
+ ["America/Cambridge_Bay"] = "America_Mountain",
+ ["America/Edmonton"] = "America_Mountain",
+ ["America/Inuvik"] = "America_Mountain",
+ ["America/Ojinaga"] = "America_Mountain",
+ ["America/Chihuahua"] = "America_Mountain",
+ ["America/Mazatlan"] = "America_Mountain",
+ ["America/Phoenix"] = "America_Mountain",
+ ["America/Creston"] = "America_Mountain",
+ ["America/Dawson_Creek"] = "America_Mountain",
+ ["America/Fort_Nelson"] = "America_Mountain",
+
+ // America_Pacific
+ ["America/Los_Angeles"] = "America_Pacific",
+ ["America/Dawson"] = "America_Pacific",
+ ["America/Tijuana"] = "America_Pacific",
+ ["America/Vancouver"] = "America_Pacific",
+ ["America/Whitehorse"] = "America_Pacific",
+
+ // Alaska
+ ["America/Anchorage"] = "Alaska",
+ ["America/Juneau"] = "Alaska",
+ ["America/Metlakatla"] = "Alaska",
+ ["America/Nome"] = "Alaska",
+ ["America/Sitka"] = "Alaska",
+ ["America/Yakutat"] = "Alaska",
+
+ // Hawaii_Aleutian
+ ["Pacific/Honolulu"] = "Hawaii_Aleutian",
+ ["America/Adak"] = "Hawaii_Aleutian",
+
+ // Atlantic
+ ["America/Halifax"] = "Atlantic",
+ ["America/Glace_Bay"] = "Atlantic",
+ ["America/Goose_Bay"] = "Atlantic",
+ ["America/Moncton"] = "Atlantic",
+ ["America/Thule"] = "Atlantic",
+ ["Atlantic/Bermuda"] = "Atlantic",
+ ["America/Barbados"] = "Atlantic",
+ ["America/Martinique"] = "Atlantic",
+ ["America/Puerto_Rico"] = "Atlantic",
+ ["America/Santo_Domingo"] = "Atlantic",
+ ["America/Virgin"] = "Atlantic",
+
+ // Newfoundland
+ ["America/St_Johns"] = "Newfoundland",
+
+ // Brasilia
+ ["America/Sao_Paulo"] = "Brasilia",
+ ["America/Araguaina"] = "Brasilia",
+ ["America/Bahia"] = "Brasilia",
+ ["America/Belem"] = "Brasilia",
+ ["America/Fortaleza"] = "Brasilia",
+ ["America/Maceio"] = "Brasilia",
+ ["America/Recife"] = "Brasilia",
+ ["America/Santarem"] = "Brasilia",
+
+ // Amazon
+ ["America/Manaus"] = "Amazon",
+ ["America/Boa_Vista"] = "Amazon",
+ ["America/Campo_Grande"] = "Amazon",
+ ["America/Cuiaba"] = "Amazon",
+ ["America/Porto_Velho"] = "Amazon",
+
+ // Argentina
+ ["America/Argentina/Buenos_Aires"] = "Argentina",
+ ["America/Argentina/Catamarca"] = "Argentina",
+ ["America/Argentina/Cordoba"] = "Argentina",
+ ["America/Argentina/Jujuy"] = "Argentina",
+ ["America/Argentina/La_Rioja"] = "Argentina",
+ ["America/Argentina/Mendoza"] = "Argentina",
+ ["America/Argentina/Rio_Gallegos"] = "Argentina",
+ ["America/Argentina/Salta"] = "Argentina",
+ ["America/Argentina/San_Juan"] = "Argentina",
+ ["America/Argentina/San_Luis"] = "Argentina",
+ ["America/Argentina/Tucuman"] = "Argentina",
+ ["America/Argentina/Ushuaia"] = "Argentina",
+
+ // Moscow
+ ["Europe/Moscow"] = "Moscow",
+ ["Europe/Kirov"] = "Moscow",
+ ["Europe/Simferopol"] = "Moscow",
+ ["Europe/Volgograd"] = "Moscow",
+
+ // Japan
+ ["Asia/Tokyo"] = "Japan",
+
+ // Korea
+ ["Asia/Seoul"] = "Korea",
+
+ // China
+ ["Asia/Shanghai"] = "China",
+ ["Asia/Macau"] = "China",
+
+ // Taipei
+ ["Asia/Taipei"] = "Taipei",
+
+ // Hong_Kong
+ ["Asia/Hong_Kong"] = "Hong_Kong",
+
+ // India
+ ["Asia/Kolkata"] = "India",
+ ["Asia/Calcutta"] = "India",
+
+ // Pakistan
+ ["Asia/Karachi"] = "Pakistan",
+
+ // Bangladesh
+ ["Asia/Dhaka"] = "Bangladesh",
+
+ // Gulf
+ ["Asia/Dubai"] = "Gulf",
+ ["Asia/Muscat"] = "Gulf",
+
+ // Arabian
+ ["Asia/Riyadh"] = "Arabian",
+ ["Asia/Aden"] = "Arabian",
+ ["Asia/Baghdad"] = "Arabian",
+ ["Asia/Bahrain"] = "Arabian",
+ ["Asia/Kuwait"] = "Arabian",
+ ["Asia/Qatar"] = "Arabian",
+
+ // Iran
+ ["Asia/Tehran"] = "Iran",
+
+ // Israel
+ ["Asia/Jerusalem"] = "Israel",
+
+ // Singapore
+ ["Asia/Singapore"] = "Singapore",
+
+ // Indochina
+ ["Asia/Bangkok"] = "Indochina",
+ ["Asia/Ho_Chi_Minh"] = "Indochina",
+ ["Asia/Phnom_Penh"] = "Indochina",
+ ["Asia/Vientiane"] = "Indochina",
+
+ // Indonesia_Western
+ ["Asia/Jakarta"] = "Indonesia_Western",
+ ["Asia/Pontianak"] = "Indonesia_Western",
+
+ // Indonesia_Central
+ ["Asia/Makassar"] = "Indonesia_Central",
+
+ // Indonesia_Eastern
+ ["Asia/Jayapura"] = "Indonesia_Eastern",
+
+ // Philippines
+ ["Asia/Manila"] = "Philippines",
+
+ // Malaysia
+ ["Asia/Kuala_Lumpur"] = "Malaysia",
+
+ // Australia_Eastern
+ ["Australia/Sydney"] = "Australia_Eastern",
+ ["Australia/Melbourne"] = "Australia_Eastern",
+ ["Australia/Brisbane"] = "Australia_Eastern",
+ ["Australia/Hobart"] = "Australia_Eastern",
+ ["Australia/Currie"] = "Australia_Eastern",
+ ["Australia/Lindeman"] = "Australia_Eastern",
+ ["Antarctica/Macquarie"] = "Australia_Eastern",
+
+ // Australia_Central
+ ["Australia/Adelaide"] = "Australia_Central",
+ ["Australia/Broken_Hill"] = "Australia_Central",
+ ["Australia/Darwin"] = "Australia_Central",
+
+ // Australia_Western
+ ["Australia/Perth"] = "Australia_Western",
+
+ // New_Zealand
+ ["Pacific/Auckland"] = "New_Zealand",
+ ["Antarctica/McMurdo"] = "New_Zealand",
+
+ // Africa_Central
+ ["Africa/Maputo"] = "Africa_Central",
+ ["Africa/Blantyre"] = "Africa_Central",
+ ["Africa/Bujumbura"] = "Africa_Central",
+ ["Africa/Gaborone"] = "Africa_Central",
+ ["Africa/Harare"] = "Africa_Central",
+ ["Africa/Kigali"] = "Africa_Central",
+ ["Africa/Lubumbashi"] = "Africa_Central",
+ ["Africa/Lusaka"] = "Africa_Central",
+ ["Africa/Windhoek"] = "Africa_Central",
+
+ // Africa_Eastern
+ ["Africa/Nairobi"] = "Africa_Eastern",
+ ["Africa/Addis_Ababa"] = "Africa_Eastern",
+ ["Africa/Asmera"] = "Africa_Eastern",
+ ["Africa/Dar_es_Salaam"] = "Africa_Eastern",
+ ["Africa/Djibouti"] = "Africa_Eastern",
+ ["Africa/Kampala"] = "Africa_Eastern",
+ ["Africa/Mogadishu"] = "Africa_Eastern",
+ ["Indian/Antananarivo"] = "Africa_Eastern",
+ ["Indian/Comoro"] = "Africa_Eastern",
+ ["Indian/Mayotte"] = "Africa_Eastern",
+
+ // Africa_Southern
+ ["Africa/Johannesburg"] = "Africa_Southern",
+ ["Africa/Maseru"] = "Africa_Southern",
+ ["Africa/Mbabane"] = "Africa_Southern",
+
+ // Africa_Western
+ ["Africa/Lagos"] = "Africa_Western",
+ ["Africa/Bangui"] = "Africa_Western",
+ ["Africa/Brazzaville"] = "Africa_Western",
+ ["Africa/Douala"] = "Africa_Western",
+ ["Africa/Kinshasa"] = "Africa_Western",
+ ["Africa/Libreville"] = "Africa_Western",
+ ["Africa/Luanda"] = "Africa_Western",
+ ["Africa/Malabo"] = "Africa_Western",
+ ["Africa/Ndjamena"] = "Africa_Western",
+ ["Africa/Niamey"] = "Africa_Western",
+ ["Africa/Porto-Novo"] = "Africa_Western",
+
+ // Novosibirsk
+ ["Asia/Novosibirsk"] = "Novosibirsk",
+
+ // Vladivostok
+ ["Asia/Vladivostok"] = "Vladivostok",
+
+ // Yakutsk
+ ["Asia/Yakutsk"] = "Yakutsk",
+ ["Asia/Chita"] = "Yakutsk",
+ ["Asia/Khandyga"] = "Yakutsk",
+
+ // Magadan
+ ["Asia/Magadan"] = "Magadan",
+
+ // Kamchatka
+ ["Asia/Kamchatka"] = "Kamchatka",
+
+ // Apia
+ ["Pacific/Apia"] = "Apia",
+
+ // Tonga
+ ["Pacific/Tongatapu"] = "Tonga",
+
+ // Fiji
+ ["Pacific/Fiji"] = "Fiji",
+
+ // Chatham
+ ["Pacific/Chatham"] = "Chatham",
+
+ // Gambier
+ ["Pacific/Gambier"] = "Gambier",
+
+ // Marquesas
+ ["Pacific/Marquesas"] = "Marquesas",
+
+ // Norfolk
+ ["Pacific/Norfolk"] = "Norfolk",
+
+ // Colombia
+ ["America/Bogota"] = "Colombia",
+
+ // Peru
+ ["America/Lima"] = "Peru",
+
+ // Chile
+ ["America/Santiago"] = "Chile",
+
+ // Paraguay
+ ["America/Asuncion"] = "Paraguay",
+
+ // Uruguay
+ ["America/Montevideo"] = "Uruguay",
+
+ // Bolivia
+ ["America/La_Paz"] = "Bolivia",
+
+ // Venezuela
+ ["America/Caracas"] = "Venezuela",
+
+ // Guyana
+ ["America/Guyana"] = "Guyana",
+
+ // Suriname
+ ["America/Paramaribo"] = "Suriname",
+
+ // French_Guiana
+ ["America/Cayenne"] = "French_Guiana",
+
+ // Ecuador
+ ["America/Guayaquil"] = "Ecuador",
+
+ // Galapagos
+ ["Pacific/Galapagos"] = "Galapagos",
+
+ // East_Timor
+ ["Asia/Dili"] = "East_Timor",
+
+ // Afghanistan
+ ["Asia/Kabul"] = "Afghanistan",
+
+ // Nepal
+ ["Asia/Kathmandu"] = "Nepal",
+
+ // Bhutan
+ ["Asia/Thimphu"] = "Bhutan",
+
+ // Myanmar
+ ["Asia/Yangon"] = "Myanmar",
+ ["Asia/Rangoon"] = "Myanmar",
+
+ // Lanka
+ ["Asia/Colombo"] = "Lanka",
+
+ // Kazakhstan_Eastern
+ ["Asia/Almaty"] = "Kazakhstan_Eastern",
+
+ // Kazakhstan_Western
+ ["Asia/Aqtau"] = "Kazakhstan_Western",
+ ["Asia/Aqtobe"] = "Kazakhstan_Western",
+ ["Asia/Atyrau"] = "Kazakhstan_Western",
+ ["Asia/Oral"] = "Kazakhstan_Western",
+ ["Asia/Qostanay"] = "Kazakhstan_Western",
+ ["Asia/Qyzylorda"] = "Kazakhstan_Western",
+
+ // Uzbekistan
+ ["Asia/Tashkent"] = "Uzbekistan",
+ ["Asia/Samarkand"] = "Uzbekistan",
+
+ // Turkmenistan
+ ["Asia/Ashgabat"] = "Turkmenistan",
+
+ // Georgia
+ ["Asia/Tbilisi"] = "Georgia",
+
+ // Azerbaijan
+ ["Asia/Baku"] = "Azerbaijan",
+
+ // Armenia
+ ["Asia/Yerevan"] = "Armenia",
+
+ // Yekaterinburg
+ ["Asia/Yekaterinburg"] = "Yekaterinburg",
+
+ // Omsk
+ ["Asia/Omsk"] = "Omsk",
+
+ // Krasnoyarsk
+ ["Asia/Krasnoyarsk"] = "Krasnoyarsk",
+
+ // Irkutsk
+ ["Asia/Irkutsk"] = "Irkutsk",
+
+ // Europe_Further_Eastern
+ ["Europe/Minsk"] = "Moscow",
+
+ // Samoa (additional)
+ ["Pacific/Pago_Pago"] = "Samoa",
+ ["Pacific/Midway"] = "Samoa",
+
+ // Cuba
+ ["America/Havana"] = "Cuba",
+
+ // Mexico_Pacific
+ ["America/Hermosillo"] = "Mexico_Pacific",
+
+ // Pierre_Miquelon
+ ["America/Miquelon"] = "Pierre_Miquelon",
+
+ // Greenland_Western
+ ["America/Nuuk"] = "Greenland_Western",
+
+ // Apia (Pacific/Apia mapped above under Samoa section - current metazone is Apia)
+
+ };
+
+ ///
+ /// En-US display names for metazones.
+ /// From CLDR dates/timeZoneNames for en-US locale.
+ ///
+ internal static readonly Dictionary EnUsNames = new(StringComparer.Ordinal)
+ {
+ ["Europe_Central"] = new("Central European Standard Time", "Central European Summer Time", "Central European Time", "CET", "CEST", "CET"),
+ ["Europe_Eastern"] = new("Eastern European Standard Time", "Eastern European Summer Time", "Eastern European Time", "EET", "EEST", "EET"),
+ ["Europe_Western"] = new("Western European Standard Time", "Western European Summer Time", "Western European Time", "WET", "WEST", "WET"),
+ ["GMT"] = new("Greenwich Mean Time", "Greenwich Mean Time", "Greenwich Mean Time", "GMT", "GMT", "GMT"),
+ ["America_Eastern"] = new("Eastern Standard Time", "Eastern Daylight Time", "Eastern Time", "EST", "EDT", "ET"),
+ ["America_Central"] = new("Central Standard Time", "Central Daylight Time", "Central Time", "CST", "CDT", "CT"),
+ ["America_Mountain"] = new("Mountain Standard Time", "Mountain Daylight Time", "Mountain Time", "MST", "MDT", "MT"),
+ ["America_Pacific"] = new("Pacific Standard Time", "Pacific Daylight Time", "Pacific Time", "PST", "PDT", "PT"),
+ ["Alaska"] = new("Alaska Standard Time", "Alaska Daylight Time", "Alaska Time", "AKST", "AKDT", "AKT"),
+ ["Hawaii_Aleutian"] = new("Hawaii-Aleutian Standard Time", "Hawaii-Aleutian Daylight Time", "Hawaii-Aleutian Time", "HST", "HDT", "HAT"),
+ ["Atlantic"] = new("Atlantic Standard Time", "Atlantic Daylight Time", "Atlantic Time", "AST", "ADT", "AT"),
+ ["Newfoundland"] = new("Newfoundland Standard Time", "Newfoundland Daylight Time", "Newfoundland Time", "NST", "NDT", "NT"),
+ ["Brasilia"] = new("Brasilia Standard Time", "Brasilia Summer Time", "Brasilia Time", "BRT", "BRST", "BRT"),
+ ["Amazon"] = new("Amazon Standard Time", "Amazon Summer Time", "Amazon Time", "AMT", "AMST", "AMT"),
+ ["Argentina"] = new("Argentina Standard Time", "Argentina Summer Time", "Argentina Time", "ART", "ARST", "ART"),
+ ["Moscow"] = new("Moscow Standard Time", "Moscow Daylight Time", "Moscow Time", "MSK", "MSD", "MSK"),
+ ["Japan"] = new("Japan Standard Time", "Japan Daylight Time", "Japan Time", "JST", "JDT", "JST"),
+ ["Korea"] = new("Korean Standard Time", "Korean Daylight Time", "Korean Time", "KST", "KDT", "KST"),
+ ["China"] = new("China Standard Time", "China Daylight Time", "China Time", "CST", "CDT", "CT"),
+ ["Taipei"] = new("Taipei Standard Time", "Taipei Daylight Time", "Taipei Time", "CST", "CDT", "CT"),
+ ["Hong_Kong"] = new("Hong Kong Standard Time", "Hong Kong Summer Time", "Hong Kong Time", "HKT", "HKST", "HKT"),
+ ["India"] = new("India Standard Time", "India Daylight Time", "India Standard Time", "IST", "IDT", "IST"),
+ ["Pakistan"] = new("Pakistan Standard Time", "Pakistan Summer Time", "Pakistan Time", "PKT", "PKST", "PKT"),
+ ["Bangladesh"] = new("Bangladesh Standard Time", "Bangladesh Summer Time", "Bangladesh Time", "BST", "BSST", "BST"),
+ ["Gulf"] = new("Gulf Standard Time", "Gulf Daylight Time", "Gulf Time", "GST", "GDT", "GT"),
+ ["Arabian"] = new("Arabian Standard Time", "Arabian Daylight Time", "Arabian Time", "AST", "ADT", "AT"),
+ ["Iran"] = new("Iran Standard Time", "Iran Daylight Time", "Iran Time", "IRST", "IRDT", "IRT"),
+ ["Israel"] = new("Israel Standard Time", "Israel Daylight Time", "Israel Time", "IST", "IDT", "IT"),
+ ["Singapore"] = new("Singapore Standard Time", "Singapore Daylight Time", "Singapore Time", "SGT", "SGT", "SGT"),
+ ["Indochina"] = new("Indochina Time", "Indochina Time", "Indochina Time", "ICT", "ICT", "ICT"),
+ ["Indonesia_Western"] = new("Western Indonesia Time", "Western Indonesia Time", "Western Indonesia Time", "WIB", "WIB", "WIB"),
+ ["Indonesia_Central"] = new("Central Indonesia Time", "Central Indonesia Time", "Central Indonesia Time", "WITA", "WITA", "WITA"),
+ ["Indonesia_Eastern"] = new("Eastern Indonesia Time", "Eastern Indonesia Time", "Eastern Indonesia Time", "WIT", "WIT", "WIT"),
+ ["Philippines"] = new("Philippine Standard Time", "Philippine Summer Time", "Philippine Time", "PHT", "PHST", "PHT"),
+ ["Malaysia"] = new("Malaysia Time", "Malaysia Time", "Malaysia Time", "MYT", "MYT", "MYT"),
+ ["Australia_Eastern"] = new("Australian Eastern Standard Time", "Australian Eastern Daylight Time", "Australian Eastern Time", "AEST", "AEDT", "AET"),
+ ["Australia_Central"] = new("Australian Central Standard Time", "Australian Central Daylight Time", "Australian Central Time", "ACST", "ACDT", "ACT"),
+ ["Australia_Western"] = new("Australian Western Standard Time", "Australian Western Daylight Time", "Australian Western Time", "AWST", "AWDT", "AWT"),
+ ["New_Zealand"] = new("New Zealand Standard Time", "New Zealand Daylight Time", "New Zealand Time", "NZST", "NZDT", "NZT"),
+ ["Africa_Central"] = new("Central Africa Time", "Central Africa Time", "Central Africa Time", "CAT", "CAT", "CAT"),
+ ["Africa_Eastern"] = new("East Africa Time", "East Africa Time", "East Africa Time", "EAT", "EAT", "EAT"),
+ ["Africa_Southern"] = new("South Africa Standard Time", "South Africa Standard Time", "South Africa Time", "SAST", "SAST", "SAST"),
+ ["Africa_Western"] = new("West Africa Standard Time", "West Africa Summer Time", "West Africa Time", "WAT", "WAST", "WAT"),
+ ["Novosibirsk"] = new("Novosibirsk Standard Time", "Novosibirsk Summer Time", "Novosibirsk Time", "NOVT", "NOVST", "NOVT"),
+ ["Vladivostok"] = new("Vladivostok Standard Time", "Vladivostok Summer Time", "Vladivostok Time", "VLAT", "VLAST", "VLAT"),
+ ["Yakutsk"] = new("Yakutsk Standard Time", "Yakutsk Summer Time", "Yakutsk Time", "YAKT", "YAKST", "YAKT"),
+ ["Magadan"] = new("Magadan Standard Time", "Magadan Summer Time", "Magadan Time", "MAGT", "MAGST", "MAGT"),
+ ["Kamchatka"] = new("Kamchatka Standard Time", "Kamchatka Summer Time", "Kamchatka Time", "PETT", "PETST", "PETT"),
+ ["Fiji"] = new("Fiji Standard Time", "Fiji Summer Time", "Fiji Time", "FJT", "FJST", "FJT"),
+ ["Chatham"] = new("Chatham Standard Time", "Chatham Daylight Time", "Chatham Time", "CHAST", "CHADT", "CHAT"),
+ ["Tonga"] = new("Tonga Standard Time", "Tonga Summer Time", "Tonga Time", "TOT", "TOST", "TOT"),
+ ["Samoa"] = new("Samoa Standard Time", "Samoa Daylight Time", "Samoa Time", "SST", "SDT", "ST"),
+ ["Colombia"] = new("Colombia Standard Time", "Colombia Summer Time", "Colombia Time", "COT", "COST", "COT"),
+ ["Peru"] = new("Peru Standard Time", "Peru Summer Time", "Peru Time", "PET", "PEST", "PET"),
+ ["Chile"] = new("Chile Standard Time", "Chile Summer Time", "Chile Time", "CLT", "CLST", "CLT"),
+ ["Paraguay"] = new("Paraguay Standard Time", "Paraguay Summer Time", "Paraguay Time", "PYT", "PYST", "PYT"),
+ ["Uruguay"] = new("Uruguay Standard Time", "Uruguay Summer Time", "Uruguay Time", "UYT", "UYST", "UYT"),
+ ["Bolivia"] = new("Bolivia Time", "Bolivia Time", "Bolivia Time", "BOT", "BOT", "BOT"),
+ ["Venezuela"] = new("Venezuela Time", "Venezuela Time", "Venezuela Time", "VET", "VET", "VET"),
+ ["French_Guiana"] = new("French Guiana Time", "French Guiana Time", "French Guiana Time", "GFT", "GFT", "GFT"),
+ ["Guyana"] = new("Guyana Time", "Guyana Time", "Guyana Time", "GYT", "GYT", "GYT"),
+ ["Suriname"] = new("Suriname Time", "Suriname Time", "Suriname Time", "SRT", "SRT", "SRT"),
+ ["Ecuador"] = new("Ecuador Time", "Ecuador Time", "Ecuador Time", "ECT", "ECT", "ECT"),
+ ["Galapagos"] = new("Galapagos Time", "Galapagos Time", "Galapagos Time", "GALT", "GALT", "GALT"),
+ ["Afghanistan"] = new("Afghanistan Time", "Afghanistan Time", "Afghanistan Time", "AFT", "AFT", "AFT"),
+ ["Nepal"] = new("Nepal Time", "Nepal Time", "Nepal Time", "NPT", "NPT", "NPT"),
+ ["Bhutan"] = new("Bhutan Time", "Bhutan Time", "Bhutan Time", "BTT", "BTT", "BTT"),
+ ["Myanmar"] = new("Myanmar Time", "Myanmar Time", "Myanmar Time", "MMT", "MMT", "MMT"),
+ ["Lanka"] = new("Sri Lanka Time", "Sri Lanka Time", "Sri Lanka Time", "SLT", "SLT", "SLT"),
+ ["Kazakhstan_Eastern"] = new("East Kazakhstan Time", "East Kazakhstan Time", "East Kazakhstan Time", "ALMT", "ALMT", "ALMT"),
+ ["Kazakhstan_Western"] = new("West Kazakhstan Time", "West Kazakhstan Time", "West Kazakhstan Time", "AQTT", "AQTT", "AQTT"),
+ ["Uzbekistan"] = new("Uzbekistan Standard Time", "Uzbekistan Summer Time", "Uzbekistan Time", "UZT", "UZST", "UZT"),
+ ["Turkmenistan"] = new("Turkmenistan Standard Time", "Turkmenistan Summer Time", "Turkmenistan Time", "TMT", "TMST", "TMT"),
+ ["Georgia"] = new("Georgia Standard Time", "Georgia Summer Time", "Georgia Time", "GET", "GEST", "GET"),
+ ["Azerbaijan"] = new("Azerbaijan Standard Time", "Azerbaijan Summer Time", "Azerbaijan Time", "AZT", "AZST", "AZT"),
+ ["Armenia"] = new("Armenia Standard Time", "Armenia Summer Time", "Armenia Time", "AMT", "AMST", "AMT"),
+ ["Yekaterinburg"] = new("Yekaterinburg Standard Time", "Yekaterinburg Summer Time", "Yekaterinburg Time", "YEKT", "YEKST", "YEKT"),
+ ["Omsk"] = new("Omsk Standard Time", "Omsk Summer Time", "Omsk Time", "OMST", "OMSST", "OMST"),
+ ["Krasnoyarsk"] = new("Krasnoyarsk Standard Time", "Krasnoyarsk Summer Time", "Krasnoyarsk Time", "KRAT", "KRAST", "KRAT"),
+ ["Irkutsk"] = new("Irkutsk Standard Time", "Irkutsk Summer Time", "Irkutsk Time", "IRKT", "IRKST", "IRKT"),
+ ["Norfolk"] = new("Norfolk Island Standard Time", "Norfolk Island Daylight Time", "Norfolk Island Time", "NFT", "NFDT", "NFT"),
+ ["Marquesas"] = new("Marquesas Time", "Marquesas Time", "Marquesas Time", "MART", "MART", "MART"),
+ ["Gambier"] = new("Gambier Time", "Gambier Time", "Gambier Time", "GAMT", "GAMT", "GAMT"),
+ ["Cuba"] = new("Cuba Standard Time", "Cuba Daylight Time", "Cuba Time", "CST", "CDT", "CT"),
+ ["Mexico_Pacific"] = new("Mexican Pacific Standard Time", "Mexican Pacific Daylight Time", "Mexican Pacific Time", "MST", "MDT", "MT"),
+ ["Pierre_Miquelon"] = new("St. Pierre & Miquelon Standard Time", "St. Pierre & Miquelon Daylight Time", "St. Pierre & Miquelon Time", "PMST", "PMDT", "PMT"),
+ ["Greenland_Western"] = new("West Greenland Standard Time", "West Greenland Summer Time", "West Greenland Time", "WGT", "WGST", "WGT"),
+ ["Apia"] = new("Apia Standard Time", "Apia Daylight Time", "Apia Time", "WSST", "WSDT", "WST"),
+ ["East_Timor"] = new("East Timor Time", "East Timor Time", "East Timor Time", "TLT", "TLT", "TLT"),
+ };
+
+ ///
+ /// Resolves the CLDR timezone display name for a given IANA timezone ID.
+ ///
+ /// The IANA timezone identifier
+ /// Whether daylight saving time is currently in effect
+ /// True for long format, false for short format
+ /// True for generic (non-specific) format
+ /// The display name, or null if not found in CLDR data
+ internal static string? GetDisplayName(string ianaId, bool isDaylightSaving, bool longName, bool generic)
+ {
+ if (!IanaToMetaZone.TryGetValue(ianaId, out var metaZone))
+ {
+ return null;
+ }
+
+ if (!EnUsNames.TryGetValue(metaZone, out var names))
+ {
+ return null;
+ }
+
+ if (generic)
+ {
+ return longName ? names.LongGeneric : names.ShortGeneric;
+ }
+
+ if (isDaylightSaving)
+ {
+ return longName ? names.LongDaylight : names.ShortDaylight;
+ }
+
+ return longName ? names.LongStandard : names.ShortStandard;
+ }
+}
diff --git a/Jint/Native/Intl/Data/TimeZoneData.cs b/Jint/Native/Intl/Data/TimeZoneData.cs
index a7f76f4b0a..3a8b1e7ce6 100644
--- a/Jint/Native/Intl/Data/TimeZoneData.cs
+++ b/Jint/Native/Intl/Data/TimeZoneData.cs
@@ -65,13 +65,14 @@ internal static class TimeZoneData
"America/St_Johns", "America/Swift_Current", "America/Tegucigalpa",
"America/Thule", "America/Tijuana", "America/Toronto", "America/Vancouver",
"America/Whitehorse", "America/Winnipeg", "America/Yakutat",
- "America/Yellowknife",
+ // Note: America/Yellowknife is a Link to America/Edmonton since IANA TZDB 2022b
"Antarctica/Casey", "Antarctica/Davis", "Antarctica/Macquarie",
"Antarctica/Mawson", "Antarctica/Palmer", "Antarctica/Rothera",
"Antarctica/Troll",
"Asia/Almaty", "Asia/Amman", "Asia/Anadyr", "Asia/Aqtau", "Asia/Aqtobe",
"Asia/Ashgabat", "Asia/Atyrau", "Asia/Baghdad", "Asia/Baku", "Asia/Bangkok",
- "Asia/Barnaul", "Asia/Beirut", "Asia/Bishkek", "Asia/Chita", "Asia/Choibalsan",
+ "Asia/Barnaul", "Asia/Beirut", "Asia/Bishkek", "Asia/Chita",
+ // Note: Asia/Choibalsan is a Link to Asia/Ulaanbaatar since IANA TZDB 2022b
"Asia/Colombo", "Asia/Damascus", "Asia/Dhaka", "Asia/Dili", "Asia/Dubai",
"Asia/Dushanbe", "Asia/Famagusta", "Asia/Gaza", "Asia/Hebron",
"Asia/Ho_Chi_Minh", "Asia/Hong_Kong", "Asia/Hovd", "Asia/Irkutsk",
@@ -94,7 +95,8 @@ internal static class TimeZoneData
"Australia/Darwin", "Australia/Eucla", "Australia/Hobart",
"Australia/Lindeman", "Australia/Lord_Howe", "Australia/Melbourne",
"Australia/Perth", "Australia/Sydney",
- "CET", "CST6CDT", "EET", "EST", "EST5EDT",
+ // Note: CET, CST6CDT, EET, EST, EST5EDT, HST, MET, MST, MST7MDT, PST8PDT, WET
+ // are Links in modern IANA TZDB (canonical-tz) - not included in supportedValuesOf
// Note: Etc/GMT and Etc/UTC are NOT canonical - they are valid identifiers
// but canonicalize to UTC for supportedValuesOf purposes
"Etc/GMT+1", "Etc/GMT+10", "Etc/GMT+11", "Etc/GMT+12",
@@ -113,10 +115,7 @@ internal static class TimeZoneData
"Europe/Simferopol", "Europe/Sofia", "Europe/Tallinn", "Europe/Tirane",
"Europe/Ulyanovsk", "Europe/Vienna", "Europe/Vilnius", "Europe/Volgograd",
"Europe/Warsaw", "Europe/Zurich",
- "HST",
"Indian/Chagos", "Indian/Maldives", "Indian/Mauritius",
- "MET", "MST", "MST7MDT",
- "PST8PDT",
"Pacific/Apia", "Pacific/Auckland", "Pacific/Bougainville",
"Pacific/Chatham", "Pacific/Easter", "Pacific/Efate", "Pacific/Fakaofo",
"Pacific/Fiji", "Pacific/Galapagos", "Pacific/Gambier",
@@ -126,8 +125,7 @@ internal static class TimeZoneData
"Pacific/Noumea", "Pacific/Pago_Pago", "Pacific/Palau", "Pacific/Pitcairn",
"Pacific/Port_Moresby", "Pacific/Rarotonga", "Pacific/Tahiti",
"Pacific/Tarawa", "Pacific/Tongatapu",
- "UTC",
- "WET"
+ "UTC"
];
///
@@ -183,13 +181,12 @@ internal static class TimeZoneData
"America/St_Johns", "America/Swift_Current", "America/Tegucigalpa",
"America/Thule", "America/Tijuana", "America/Toronto", "America/Vancouver",
"America/Whitehorse", "America/Winnipeg", "America/Yakutat",
- "America/Yellowknife",
"Antarctica/Casey", "Antarctica/Davis", "Antarctica/Macquarie",
"Antarctica/Mawson", "Antarctica/Palmer", "Antarctica/Rothera",
"Antarctica/Troll",
"Asia/Almaty", "Asia/Amman", "Asia/Anadyr", "Asia/Aqtau", "Asia/Aqtobe",
"Asia/Ashgabat", "Asia/Atyrau", "Asia/Baghdad", "Asia/Baku", "Asia/Bangkok",
- "Asia/Barnaul", "Asia/Beirut", "Asia/Bishkek", "Asia/Chita", "Asia/Choibalsan",
+ "Asia/Barnaul", "Asia/Beirut", "Asia/Bishkek", "Asia/Chita",
"Asia/Colombo", "Asia/Damascus", "Asia/Dhaka", "Asia/Dili", "Asia/Dubai",
"Asia/Dushanbe", "Asia/Famagusta", "Asia/Gaza", "Asia/Hebron",
"Asia/Ho_Chi_Minh", "Asia/Hong_Kong", "Asia/Hovd", "Asia/Irkutsk",
@@ -212,7 +209,6 @@ internal static class TimeZoneData
"Australia/Darwin", "Australia/Eucla", "Australia/Hobart",
"Australia/Lindeman", "Australia/Lord_Howe", "Australia/Melbourne",
"Australia/Perth", "Australia/Sydney",
- "CET", "CST6CDT", "EET", "EST", "EST5EDT",
// Per ECMA-402 2024 spec, Etc/GMT and Etc/UTC should be preserved (not canonicalized to UTC)
// when returned in resolvedOptions().timeZone
"Etc/GMT", "Etc/UTC",
@@ -232,10 +228,7 @@ internal static class TimeZoneData
"Europe/Simferopol", "Europe/Sofia", "Europe/Tallinn", "Europe/Tirane",
"Europe/Ulyanovsk", "Europe/Vienna", "Europe/Vilnius", "Europe/Volgograd",
"Europe/Warsaw", "Europe/Zurich",
- "HST",
"Indian/Chagos", "Indian/Maldives", "Indian/Mauritius",
- "MET", "MST", "MST7MDT",
- "PST8PDT",
"Pacific/Apia", "Pacific/Auckland", "Pacific/Bougainville",
"Pacific/Chatham", "Pacific/Easter", "Pacific/Efate", "Pacific/Fakaofo",
"Pacific/Fiji", "Pacific/Galapagos", "Pacific/Gambier",
@@ -246,9 +239,11 @@ internal static class TimeZoneData
"Pacific/Port_Moresby", "Pacific/Rarotonga", "Pacific/Tahiti",
"Pacific/Tarawa", "Pacific/Tongatapu",
"UTC",
- "WET",
// IANA TZDB Link names (aliases - must also be supported)
+ // POSIX-style zones (Links in canonical-tz IANA TZDB)
+ "CET", "CST6CDT", "EET", "EST", "EST5EDT", "HST",
+ "MET", "MST", "MST7MDT", "PST8PDT", "WET",
"Africa/Accra", "Africa/Addis_Ababa", "Africa/Asmara", "Africa/Asmera",
"Africa/Bamako", "Africa/Bangui", "Africa/Banjul", "Africa/Blantyre",
"Africa/Brazzaville", "Africa/Bujumbura", "Africa/Conakry", "Africa/Dakar",
@@ -273,12 +268,12 @@ internal static class TimeZoneData
"America/Santa_Isabel", "America/Shiprock", "America/St_Barthelemy",
"America/St_Kitts", "America/St_Lucia", "America/St_Thomas",
"America/St_Vincent", "America/Thunder_Bay", "America/Tortola",
- "America/Virgin",
+ "America/Virgin", "America/Yellowknife",
"Antarctica/DumontDUrville", "Antarctica/McMurdo", "Antarctica/South_Pole",
"Antarctica/Syowa", "Antarctica/Vostok",
"Arctic/Longyearbyen",
"Asia/Aden", "Asia/Ashkhabad", "Asia/Bahrain", "Asia/Brunei",
- "Asia/Calcutta", "Asia/Chongqing", "Asia/Chungking", "Asia/Dacca",
+ "Asia/Calcutta", "Asia/Choibalsan", "Asia/Chongqing", "Asia/Chungking", "Asia/Dacca",
"Asia/Harbin", "Asia/Istanbul", "Asia/Kashgar", "Asia/Katmandu",
"Asia/Kuala_Lumpur", "Asia/Kuwait", "Asia/Macao", "Asia/Muscat",
"Asia/Phnom_Penh", "Asia/Rangoon", "Asia/Saigon", "Asia/Tel_Aviv",
diff --git a/Jint/Native/Intl/DateTimeFormatConstructor.cs b/Jint/Native/Intl/DateTimeFormatConstructor.cs
index 87baa5a63c..9f8a783236 100644
--- a/Jint/Native/Intl/DateTimeFormatConstructor.cs
+++ b/Jint/Native/Intl/DateTimeFormatConstructor.cs
@@ -8,6 +8,18 @@
namespace Jint.Native.Intl;
+///
+/// Per spec CreateDateTimeFormat 'required' parameter.
+/// https://tc39.es/proposal-temporal/#sec-createdatetimeformat
+///
+internal enum DateTimeRequired { Date, Time, YearMonth, MonthDay, Any }
+
+///
+/// Per spec CreateDateTimeFormat 'defaults' parameter.
+/// https://tc39.es/proposal-temporal/#sec-createdatetimeformat
+///
+internal enum DateTimeDefaults { Date, Time, YearMonth, MonthDay, ZonedDateTime, All }
+
///
/// https://tc39.es/ecma402/#sec-intl-datetimeformat-constructor
///
@@ -77,6 +89,23 @@ public override ObjectInstance Construct(JsCallArguments arguments, JsValue newT
{
var locales = arguments.At(0);
var options = arguments.At(1);
+ return CreateDateTimeFormat(locales, options, newTarget);
+ }
+
+ ///
+ /// CreateDateTimeFormat per spec, with optional required/defaults/toLocaleStringTimeZone parameters
+ /// for Temporal toLocaleString support.
+ /// https://tc39.es/proposal-temporal/#sec-createdatetimeformat
+ ///
+ internal JsDateTimeFormat CreateDateTimeFormat(
+ JsValue locales,
+ JsValue options,
+ JsValue? newTarget = null,
+ DateTimeRequired required = DateTimeRequired.Any,
+ DateTimeDefaults defaults = DateTimeDefaults.Date,
+ string? toLocaleStringTimeZone = null)
+ {
+ newTarget ??= this;
// Get options object
var optionsObj = IntlUtilities.CoerceOptionsToObject(_engine, options);
@@ -277,8 +306,31 @@ public override ObjectInstance Construct(JsCallArguments arguments, JsValue newT
preserveHourCycleExt ? extHourCycle : null,
preserveNumberingSystemExt ? extNumberingSystem : null);
- // Step 29: Get timeZone option
- var timeZone = GetTimeZoneOption(optionsObj);
+ // Step 29: Get timeZone option (read once to avoid double property access)
+ var timeZoneValue = optionsObj.Get("timeZone");
+ string? timeZone;
+ if (timeZoneValue.IsUndefined())
+ {
+ if (toLocaleStringTimeZone != null)
+ {
+ // Per ECMA-402 CanonicalizeTimeZoneName, resolve to primary identifier for formatting
+ // (Temporal preserves non-primary names in timeZoneId, but Intl canonicalizes to primary)
+ var provider = _engine.Options.Temporal.TimeZoneProvider;
+ timeZone = provider.GetPrimaryTimeZoneIdentifier(toLocaleStringTimeZone) ?? toLocaleStringTimeZone;
+ }
+ else
+ {
+ timeZone = null; // Will use system default
+ }
+ }
+ else
+ {
+ if (toLocaleStringTimeZone != null)
+ {
+ Throw.TypeError(_realm, "TimeZone option is not allowed when formatting a Temporal.ZonedDateTime");
+ }
+ timeZone = ValidateAndCanonicalizeTimeZone(TypeConverter.ToString(timeZoneValue));
+ }
// Step 36: Get component options in order (per Table 7 of ECMA-402)
// Order: weekday, era, year, month, day, dayPeriod, hour, minute, second, fractionalSecondDigits, timeZoneName
@@ -294,6 +346,13 @@ public override ObjectInstance Construct(JsCallArguments arguments, JsValue newT
var fractionalSecondDigits = GetNumberOption(optionsObj, "fractionalSecondDigits", 1, 3, null);
var timeZoneName = GetStringOption(optionsObj, "timeZoneName", TimeZoneNameValues, null);
+ // Track whether user explicitly specified any core component option (before defaults are applied).
+ // Per spec, era and timeZoneName are supplementary options - they don't participate in
+ // overlap checking for Temporal types or prevent defaults from being applied.
+ var hasExplicitFormatComponents = weekday != null || year != null ||
+ month != null || day != null || dayPeriod != null || hour != null ||
+ minute != null || second != null || fractionalSecondDigits != null;
+
// Step 37: Get formatMatcher option
GetStringOption(optionsObj, "formatMatcher", FormatMatcherValues, null);
@@ -310,20 +369,108 @@ public override ObjectInstance Construct(JsCallArguments arguments, JsValue newT
{
Throw.TypeError(_realm, "Can't set date/time component options when dateStyle or timeStyle is used");
}
+
+ // Per spec: required=~date~/~year-month~/~month-day~ + timeStyle → TypeError
+ // required=~time~ + dateStyle → TypeError
+ if ((required == DateTimeRequired.Date || required == DateTimeRequired.YearMonth || required == DateTimeRequired.MonthDay)
+ && timeStyle != null)
+ {
+ Throw.TypeError(_realm, "timeStyle is not allowed when formatting a date-only Temporal type");
+ }
+ if (required == DateTimeRequired.Time && dateStyle != null)
+ {
+ Throw.TypeError(_realm, "dateStyle is not allowed when formatting a time-only Temporal type");
+ }
}
else
{
- // If no date/time component options specified, use default date format
- // Note: dayPeriod, fractionalSecondDigits, and timeZoneName don't prevent defaults
- if (weekday == null && era == null && year == null && month == null &&
- day == null && dayPeriod == null && hour == null && minute == null && second == null)
+ // Determine needDefaults based on required parameter per spec GetDateTimeFormat
+ var needDefaults = true;
+ switch (required)
{
- year = "numeric";
- month = "numeric";
- day = "numeric";
+ case DateTimeRequired.Date:
+ if (weekday != null || year != null || month != null || day != null)
+ needDefaults = false;
+ break;
+ case DateTimeRequired.Time:
+ if (dayPeriod != null || hour != null || minute != null || second != null || fractionalSecondDigits != null)
+ needDefaults = false;
+ break;
+ case DateTimeRequired.YearMonth:
+ if (year != null || month != null)
+ needDefaults = false;
+ break;
+ case DateTimeRequired.MonthDay:
+ if (month != null || day != null)
+ needDefaults = false;
+ break;
+ case DateTimeRequired.Any:
+ if (weekday != null || year != null || month != null || day != null ||
+ dayPeriod != null || hour != null || minute != null || second != null || fractionalSecondDigits != null)
+ needDefaults = false;
+ break;
+ }
+
+ if (needDefaults)
+ {
+ // Apply defaults based on defaults parameter per spec
+ switch (defaults)
+ {
+ case DateTimeDefaults.Date:
+ year = "numeric";
+ month = "numeric";
+ day = "numeric";
+ break;
+ case DateTimeDefaults.Time:
+ hour = "numeric";
+ minute = "numeric";
+ second = "numeric";
+ break;
+ case DateTimeDefaults.YearMonth:
+ year = "numeric";
+ month = "numeric";
+ break;
+ case DateTimeDefaults.MonthDay:
+ month = "numeric";
+ day = "numeric";
+ break;
+ case DateTimeDefaults.ZonedDateTime:
+ case DateTimeDefaults.All:
+ year = "numeric";
+ month = "numeric";
+ day = "numeric";
+ hour = "numeric";
+ minute = "numeric";
+ second = "numeric";
+ if (defaults == DateTimeDefaults.ZonedDateTime && timeZoneName == null)
+ {
+ timeZoneName = "short";
+ }
+ break;
+ }
}
}
+ // Per spec GetDateTimeFormat: filter out irrelevant components based on required.
+ // For time-only types, date components should not appear in the format.
+ // For date-only types, time components should not appear.
+ if (required == DateTimeRequired.Time)
+ {
+ weekday = null;
+ era = null;
+ year = null;
+ month = null;
+ day = null;
+ }
+ else if (required is DateTimeRequired.Date or DateTimeRequired.YearMonth or DateTimeRequired.MonthDay)
+ {
+ dayPeriod = null;
+ hour = null;
+ minute = null;
+ second = null;
+ fractionalSecondDigits = null;
+ }
+
// Get DateTimeFormatInfo for the locale
var culture = IntlUtilities.GetCultureInfo(resolvedLocale) ?? CultureInfo.InvariantCulture;
var dateTimeFormatInfo = (DateTimeFormatInfo) culture.DateTimeFormat.Clone();
@@ -331,6 +478,10 @@ public override ObjectInstance Construct(JsCallArguments arguments, JsValue newT
// Get prototype from newTarget (for cross-realm construction)
var proto = GetPrototypeFromConstructor(newTarget, static intrinsics => intrinsics.DateTimeFormat.PrototypeObject);
+ // Resolve default calendar from locale if not explicitly specified
+ // Per ECMA-402, the calendar is always resolved - defaults to "gregory" for most locales
+ calendar ??= GetDefaultCalendarForLocale(culture);
+
return new JsDateTimeFormat(
_engine,
proto,
@@ -352,6 +503,7 @@ public override ObjectInstance Construct(JsCallArguments arguments, JsValue newT
second,
fractionalSecondDigits,
timeZoneName,
+ hasExplicitFormatComponents,
dateTimeFormatInfo,
culture);
}
@@ -421,8 +573,11 @@ public override ObjectInstance Construct(JsCallArguments arguments, JsValue newT
return null;
}
- var timeZone = TypeConverter.ToString(value);
+ return ValidateAndCanonicalizeTimeZone(TypeConverter.ToString(value));
+ }
+ private string ValidateAndCanonicalizeTimeZone(string timeZone)
+ {
// Validate and canonicalize time zone using IANA database
// Per ECMA-402, only IANA timezone identifiers are allowed
if (string.Equals(timeZone, "UTC", StringComparison.OrdinalIgnoreCase))
@@ -447,7 +602,7 @@ public override ObjectInstance Construct(JsCallArguments arguments, JsValue newT
// Not a valid IANA timezone identifier
Throw.RangeError(_realm, $"Invalid time zone: {timeZone}");
- return null;
+ return null!;
}
///
@@ -554,6 +709,26 @@ private JsArray SupportedLocalesOf(JsValue thisObject, JsCallArguments arguments
return new JsArray(_engine, supported.ToArray());
}
+ ///
+ /// Gets the default calendar for a locale. Per ECMA-402, most locales use "gregory".
+ /// Japanese locale uses "japanese", Thai uses "buddhist", etc.
+ ///
+ private static string GetDefaultCalendarForLocale(CultureInfo culture)
+ {
+ var calendarName = culture.Calendar.GetType().Name;
+ return calendarName switch
+ {
+ "JapaneseCalendar" => "japanese",
+ "ThaiBuddhistCalendar" => "buddhist",
+ "KoreanCalendar" => "korean",
+ "TaiwanCalendar" => "roc",
+ "HijriCalendar" or "UmAlQuraCalendar" => "islamic",
+ "HebrewCalendar" => "hebrew",
+ "PersianCalendar" => "persian",
+ _ => "gregory"
+ };
+ }
+
///
/// Checks if the given calendar is supported.
///
diff --git a/Jint/Native/Intl/DateTimeFormatPrototype.cs b/Jint/Native/Intl/DateTimeFormatPrototype.cs
index e96283921f..4612fd7134 100644
--- a/Jint/Native/Intl/DateTimeFormatPrototype.cs
+++ b/Jint/Native/Intl/DateTimeFormatPrototype.cs
@@ -1,5 +1,7 @@
+using System.Numerics;
using Jint.Native.Object;
using Jint.Native.Symbol;
+using Jint.Native.Temporal;
using Jint.Runtime;
using Jint.Runtime.Descriptors;
using Jint.Runtime.Interop;
@@ -77,8 +79,20 @@ private ClrFunction GetFormat(JsValue thisObject, JsCallArguments arguments)
return new ClrFunction(Engine, "", (_, args) =>
{
var dateValue = args.At(0);
- var dateTime = ToDateTimeWithOriginalYear(dateValue, out var originalYear);
- return dateTimeFormat.Format(dateTime, originalYear);
+ var formattable = ToDateTimeFormattable(dateValue);
+
+ if (IsTemporalObject(formattable))
+ {
+ var temporalDtf = GetTemporalFormatDtf(dateTimeFormat, formattable);
+ var dateTime = ConvertTemporalToDateTime(formattable);
+ var isPlain = formattable is not JsInstant;
+ return temporalDtf.Format(dateTime, isPlain: isPlain);
+ }
+ else
+ {
+ var dateTime = HandleDateTimeNonTemporal(dateTimeFormat, formattable, out var originalYear);
+ return dateTimeFormat.Format(dateTime, originalYear);
+ }
}, 1, PropertyFlag.Configurable);
}
@@ -89,9 +103,21 @@ private JsArray FormatToParts(JsValue thisObject, JsCallArguments arguments)
{
var dateTimeFormat = ValidateDateTimeFormat(thisObject);
var dateValue = arguments.At(0);
- var dateTime = ToDateTimeWithOriginalYear(dateValue, out var originalYear);
+ var formattable = ToDateTimeFormattable(dateValue);
- var parts = dateTimeFormat.FormatToParts(dateTime, originalYear);
+ List parts;
+ if (IsTemporalObject(formattable))
+ {
+ var temporalDtf = GetTemporalFormatDtf(dateTimeFormat, formattable);
+ var dateTime = ConvertTemporalToDateTime(formattable);
+ var isPlain = formattable is not JsInstant;
+ parts = temporalDtf.FormatToParts(dateTime, isPlain: isPlain);
+ }
+ else
+ {
+ var dateTime = HandleDateTimeNonTemporal(dateTimeFormat, formattable, out var originalYear);
+ parts = dateTimeFormat.FormatToParts(dateTime, originalYear);
+ }
var result = new JsArray(Engine, (uint) parts.Count);
for (var i = 0; i < parts.Count; i++)
@@ -105,6 +131,449 @@ private JsArray FormatToParts(JsValue thisObject, JsCallArguments arguments)
return result;
}
+ ///
+ /// https://tc39.es/ecma402/#sec-todatetimeformattable
+ /// Returns the Temporal object as-is if it is a Temporal object, otherwise converts to Number.
+ ///
+ private static JsValue ToDateTimeFormattable(JsValue value)
+ {
+ if (value.IsUndefined())
+ {
+ return value;
+ }
+
+ if (IsTemporalObject(value))
+ {
+ return value;
+ }
+
+ if (value is JsDate)
+ {
+ return value;
+ }
+
+ return JsNumber.Create(TypeConverter.ToNumber(value));
+ }
+
+ ///
+ /// Returns true if the value is any Temporal type.
+ ///
+ private static bool IsTemporalObject(JsValue value)
+ {
+ return value is JsPlainDate or JsPlainDateTime or JsPlainTime
+ or JsPlainYearMonth or JsPlainMonthDay
+ or JsInstant or JsZonedDateTime or JsDuration;
+ }
+
+ ///
+ /// Validates that range arguments are of the same Temporal type.
+ /// Per spec: If IsTemporalObject(x) or IsTemporalObject(y), then SameTemporalType(x, y) must be true.
+ ///
+ private void ValidateTemporalRangeTypes(JsValue x, JsValue y)
+ {
+ var xIsTemporal = IsTemporalObject(x);
+ var yIsTemporal = IsTemporalObject(y);
+
+ if (!xIsTemporal && !yIsTemporal)
+ {
+ return;
+ }
+
+ // One is temporal and the other isn't, or they are different types
+ if (!xIsTemporal || !yIsTemporal || x.GetType() != y.GetType())
+ {
+ Throw.TypeError(_realm, "Both arguments must be the same Temporal type");
+ }
+ }
+
+ ///
+ /// Handles a non-temporal formattable value (Date, Number, undefined).
+ ///
+ private DateTime HandleDateTimeNonTemporal(JsDateTimeFormat dateTimeFormat, JsValue formattable, out int? originalYear)
+ {
+ originalYear = null;
+ return ToDateTimeWithOriginalYear(formattable, out originalYear);
+ }
+
+ ///
+ /// Per spec HandleDateTimeValue + GetDateTimeFormat: computes the per-type format DTF
+ /// for a Temporal object. Each Temporal type uses a specific format record with its own
+ /// required/defaults, which may differ from the main DateTimeFormat record.
+ ///
+ private JsDateTimeFormat GetTemporalFormatDtf(JsDateTimeFormat dtf, JsValue temporal)
+ {
+ if (temporal is JsZonedDateTime)
+ {
+ Throw.TypeError(_realm, "Temporal.ZonedDateTime is not supported in DateTimeFormat.format(). Use toLocaleString() instead.");
+ return null!;
+ }
+
+ // Plain types should not show timeZoneName
+ var isPlain = temporal is not JsInstant;
+
+ // Determine per-type required and defaults
+ DateTimeRequired required;
+ DateTimeDefaults defaults;
+ switch (temporal)
+ {
+ case JsPlainDate:
+ required = DateTimeRequired.Date;
+ defaults = DateTimeDefaults.Date;
+ break;
+ case JsPlainDateTime:
+ required = DateTimeRequired.Any;
+ defaults = DateTimeDefaults.All;
+ break;
+ case JsPlainTime:
+ required = DateTimeRequired.Time;
+ defaults = DateTimeDefaults.Time;
+ break;
+ case JsPlainYearMonth:
+ required = DateTimeRequired.YearMonth;
+ defaults = DateTimeDefaults.YearMonth;
+ break;
+ case JsPlainMonthDay:
+ required = DateTimeRequired.MonthDay;
+ defaults = DateTimeDefaults.MonthDay;
+ break;
+ case JsInstant:
+ required = DateTimeRequired.Any;
+ defaults = DateTimeDefaults.All;
+ break;
+ default:
+ Throw.TypeError(_realm, $"Unsupported Temporal type: {temporal.GetType().Name}");
+ return null!;
+ }
+
+ // If the DTF uses dateStyle/timeStyle, validate restrictions and adjust for temporal type
+ if (dtf.DateStyle != null || dtf.TimeStyle != null)
+ {
+ if ((required is DateTimeRequired.Date or DateTimeRequired.YearMonth or DateTimeRequired.MonthDay) && dtf.TimeStyle != null && dtf.DateStyle == null)
+ {
+ Throw.TypeError(_realm, "Cannot format a date-only Temporal type with timeStyle and no dateStyle");
+ }
+ if (required == DateTimeRequired.Time && dtf.DateStyle != null && dtf.TimeStyle == null)
+ {
+ Throw.TypeError(_realm, "Cannot format a time-only Temporal type with dateStyle and no timeStyle");
+ }
+
+ // For date-only types: strip timeStyle; for time-only types: strip dateStyle
+ var effectiveDateStyle = dtf.DateStyle;
+ var effectiveTimeStyle = dtf.TimeStyle;
+ if (required is DateTimeRequired.Date or DateTimeRequired.YearMonth or DateTimeRequired.MonthDay)
+ {
+ effectiveTimeStyle = null;
+ }
+ else if (required == DateTimeRequired.Time)
+ {
+ effectiveDateStyle = null;
+ }
+
+ // For PlainYearMonth/PlainMonthDay, convert dateStyle to component-based formatting
+ // because these types need specific fields omitted (day for YearMonth, year for MonthDay)
+ if (effectiveDateStyle != null && required is DateTimeRequired.YearMonth or DateTimeRequired.MonthDay)
+ {
+ var isShort = string.Equals(effectiveDateStyle, "short", StringComparison.Ordinal);
+ var isMedium = string.Equals(effectiveDateStyle, "medium", StringComparison.Ordinal);
+ string? year = null, month = null, day = null;
+
+ if (required == DateTimeRequired.YearMonth)
+ {
+ year = "numeric";
+ month = isShort ? "numeric" : isMedium ? "short" : "long";
+ }
+ else // MonthDay
+ {
+ month = isShort ? "numeric" : isMedium ? "short" : "long";
+ day = "numeric";
+ }
+
+ return new JsDateTimeFormat(
+ _engine, dtf._prototype!, dtf.Locale, dtf.Calendar, dtf.NumberingSystem,
+ dtf.TimeZone, dtf.HourCycle, null, null,
+ null, null, year, month, day, null, null, null, null, null,
+ null, false, dtf.DateTimeFormatInfo, dtf.CultureInfo);
+ }
+
+ // Return modified DTF if styles were stripped, otherwise return as-is
+ if (!string.Equals(effectiveDateStyle, dtf.DateStyle, StringComparison.Ordinal)
+ || !string.Equals(effectiveTimeStyle, dtf.TimeStyle, StringComparison.Ordinal))
+ {
+ return new JsDateTimeFormat(
+ _engine, dtf._prototype!, dtf.Locale, dtf.Calendar, dtf.NumberingSystem,
+ dtf.TimeZone, dtf.HourCycle, effectiveDateStyle, effectiveTimeStyle,
+ dtf.Weekday, dtf.Era, dtf.Year, dtf.Month, dtf.Day, dtf.DayPeriod,
+ dtf.Hour, dtf.Minute, dtf.Second, dtf.FractionalSecondDigits,
+ dtf.TimeZoneName, dtf.HasExplicitFormatComponents,
+ dtf.DateTimeFormatInfo, dtf.CultureInfo);
+ }
+ return dtf;
+ }
+
+ // Create per-type format DTF by filtering components relevant to the temporal type.
+ // This implements GetDateTimeFormat with inherit=~relevant~.
+ return CreatePerTypeFormatDtfFiltered(dtf, required, defaults, isPlain);
+ }
+
+ ///
+ /// Creates a per-type format DTF by filtering components relevant to the temporal type.
+ /// Implements GetDateTimeFormat with inherit=~relevant~.
+ /// When HasExplicitFormatComponents is true: filter to relevant components, throw if no overlap.
+ /// When HasExplicitFormatComponents is false: apply per-type defaults (e.g., era-only case).
+ ///
+ private JsDateTimeFormat CreatePerTypeFormatDtfFiltered(JsDateTimeFormat dtf, DateTimeRequired required, DateTimeDefaults defaults, bool isPlain = false)
+ {
+ // Plain types should not show timeZoneName (only Instant has timezone)
+
+ // Copy era if required includes date-like components
+ var era = (required is DateTimeRequired.Date or DateTimeRequired.YearMonth or DateTimeRequired.Any)
+ ? dtf.Era : null;
+
+ if (!dtf.HasExplicitFormatComponents)
+ {
+ // No explicit component options from user (e.g., only era, or nothing).
+ // Apply per-type defaults - ignore construction defaults on the DTF.
+ string? year = null, month = null, day = null;
+ string? hour = null, minute = null, second = null;
+ string? timeZoneName = null;
+
+ switch (defaults)
+ {
+ case DateTimeDefaults.Date:
+ year = "numeric"; month = "numeric"; day = "numeric";
+ break;
+ case DateTimeDefaults.Time:
+ hour = "numeric"; minute = "numeric"; second = "numeric";
+ break;
+ case DateTimeDefaults.YearMonth:
+ year = "numeric"; month = "numeric";
+ break;
+ case DateTimeDefaults.MonthDay:
+ month = "numeric"; day = "numeric";
+ break;
+ case DateTimeDefaults.ZonedDateTime:
+ case DateTimeDefaults.All:
+ year = "numeric"; month = "numeric"; day = "numeric";
+ hour = "numeric"; minute = "numeric"; second = "numeric";
+ if (defaults == DateTimeDefaults.ZonedDateTime && !isPlain)
+ timeZoneName = "short";
+ break;
+ }
+
+ return new JsDateTimeFormat(
+ _engine, dtf._prototype!, dtf.Locale, dtf.Calendar, dtf.NumberingSystem,
+ dtf.TimeZone, dtf.HourCycle, null, null,
+ null, era, year, month, day, null, hour, minute, second, null,
+ timeZoneName, false, dtf.DateTimeFormatInfo, dtf.CultureInfo);
+ }
+
+ // User specified explicit component options - filter to relevant components
+ string? fWeekday = null, fYear = null, fMonth = null, fDay = null;
+ string? fDayPeriod = null, fHour = null, fMinute = null, fSecond = null;
+ int? fFractionalSecondDigits = null;
+ string? fTimeZoneName = isPlain ? null : dtf.TimeZoneName;
+
+ bool anyPresent = false;
+
+ switch (required)
+ {
+ case DateTimeRequired.Date:
+ fWeekday = dtf.Weekday; fYear = dtf.Year; fMonth = dtf.Month; fDay = dtf.Day;
+ anyPresent = fWeekday != null || fYear != null || fMonth != null || fDay != null;
+ break;
+ case DateTimeRequired.Time:
+ fDayPeriod = dtf.DayPeriod; fHour = dtf.Hour; fMinute = dtf.Minute; fSecond = dtf.Second;
+ fFractionalSecondDigits = dtf.FractionalSecondDigits;
+ anyPresent = fDayPeriod != null || fHour != null || fMinute != null || fSecond != null || fFractionalSecondDigits != null;
+ break;
+ case DateTimeRequired.YearMonth:
+ fYear = dtf.Year; fMonth = dtf.Month;
+ anyPresent = fYear != null || fMonth != null;
+ break;
+ case DateTimeRequired.MonthDay:
+ fMonth = dtf.Month; fDay = dtf.Day;
+ anyPresent = fMonth != null || fDay != null;
+ break;
+ case DateTimeRequired.Any:
+ fWeekday = dtf.Weekday; fYear = dtf.Year; fMonth = dtf.Month; fDay = dtf.Day;
+ fDayPeriod = dtf.DayPeriod; fHour = dtf.Hour; fMinute = dtf.Minute; fSecond = dtf.Second;
+ fFractionalSecondDigits = dtf.FractionalSecondDigits;
+ anyPresent = fWeekday != null || fYear != null || fMonth != null || fDay != null ||
+ fDayPeriod != null || fHour != null || fMinute != null || fSecond != null || fFractionalSecondDigits != null;
+ break;
+ }
+
+ if (!anyPresent)
+ {
+ // User explicitly set component options that don't overlap with this temporal type
+ Throw.TypeError(_realm, "DateTimeFormat options are incompatible with this Temporal type");
+ return null!;
+ }
+
+ return new JsDateTimeFormat(
+ _engine, dtf._prototype!, dtf.Locale, dtf.Calendar, dtf.NumberingSystem,
+ dtf.TimeZone, dtf.HourCycle, null, null,
+ fWeekday, era, fYear, fMonth, fDay, fDayPeriod, fHour, fMinute, fSecond, fFractionalSecondDigits,
+ fTimeZoneName, true, dtf.DateTimeFormatInfo, dtf.CultureInfo);
+ }
+
+ ///
+ /// Converts a Temporal object to a .NET DateTime for formatting.
+ ///
+ private static DateTime ConvertTemporalToDateTime(JsValue temporal)
+ {
+ return temporal switch
+ {
+ JsPlainDate pd => new DateTime(pd.IsoDate.Year, pd.IsoDate.Month, pd.IsoDate.Day, 12, 0, 0, DateTimeKind.Unspecified),
+ JsPlainDateTime pdt => new DateTime(pdt.IsoDateTime.Date.Year, pdt.IsoDateTime.Date.Month, pdt.IsoDateTime.Date.Day,
+ pdt.IsoDateTime.Time.Hour, pdt.IsoDateTime.Time.Minute, pdt.IsoDateTime.Time.Second,
+ pdt.IsoDateTime.Time.Millisecond, DateTimeKind.Unspecified),
+ JsPlainTime pt => new DateTime(1970, 1, 1, pt.IsoTime.Hour, pt.IsoTime.Minute, pt.IsoTime.Second,
+ pt.IsoTime.Millisecond, DateTimeKind.Unspecified),
+ JsPlainYearMonth ym => new DateTime(ym.IsoDate.Year, ym.IsoDate.Month, ym.IsoDate.Day, 12, 0, 0, DateTimeKind.Unspecified),
+ JsPlainMonthDay md => new DateTime(md.IsoDate.Year, md.IsoDate.Month, md.IsoDate.Day, 12, 0, 0, DateTimeKind.Unspecified),
+ JsInstant inst => EpochNanosecondsToDateTime(inst.EpochNanoseconds),
+ _ => DateTime.Now
+ };
+ }
+
+ ///
+ /// Handles a formattable value for formatRange/formatRangeToParts.
+ ///
+ private DateTime HandleDateTimeTemporalOrOtherForRange(JsDateTimeFormat dateTimeFormat, JsValue formattable)
+ {
+ if (formattable is JsZonedDateTime)
+ {
+ Throw.TypeError(_realm, "Temporal.ZonedDateTime is not supported in DateTimeFormat.formatRange().");
+ return default;
+ }
+
+ if (formattable is JsPlainDate plainDate)
+ {
+ ValidateTemporalStyleRestrictions(dateTimeFormat, isDateOnly: true);
+ return IsoDateToDateTime(plainDate.IsoDate);
+ }
+
+ if (formattable is JsPlainDateTime plainDateTime)
+ {
+ return IsoDateTimeToDateTime(plainDateTime.IsoDateTime);
+ }
+
+ if (formattable is JsPlainTime plainTime)
+ {
+ ValidateTemporalStyleRestrictions(dateTimeFormat, isTimeOnly: true);
+ return IsoTimeToDateTime(plainTime.IsoTime);
+ }
+
+ if (formattable is JsPlainYearMonth yearMonth)
+ {
+ ValidateTemporalStyleRestrictions(dateTimeFormat, isDateOnly: true);
+ return IsoDateToDateTime(yearMonth.IsoDate);
+ }
+
+ if (formattable is JsPlainMonthDay monthDay)
+ {
+ ValidateTemporalStyleRestrictions(dateTimeFormat, isDateOnly: true);
+ return IsoDateToDateTime(monthDay.IsoDate);
+ }
+
+ if (formattable is JsInstant instant)
+ {
+ return EpochNanosecondsToDateTime(instant.EpochNanoseconds);
+ }
+
+ return ToDateTimeForRange(formattable);
+ }
+
+ ///
+ /// Validates style restrictions for Temporal "date-only" and "time-only" types.
+ /// PlainDate/PlainYearMonth/PlainMonthDay: timeStyle without dateStyle → TypeError
+ /// PlainTime: dateStyle without timeStyle → TypeError
+ /// When both styles present: the "wrong" one is simply ignored by the formatting.
+ ///
+ private void ValidateTemporalStyleRestrictions(JsDateTimeFormat dateTimeFormat, bool isDateOnly = false, bool isTimeOnly = false)
+ {
+ if (isDateOnly && dateTimeFormat.TimeStyle != null && dateTimeFormat.DateStyle == null)
+ {
+ Throw.TypeError(_realm, "Cannot format a date-only Temporal type with timeStyle and no dateStyle");
+ }
+
+ if (isTimeOnly && dateTimeFormat.DateStyle != null && dateTimeFormat.TimeStyle == null)
+ {
+ Throw.TypeError(_realm, "Cannot format a time-only Temporal type with dateStyle and no timeStyle");
+ }
+ }
+
+ ///
+ /// Converts an IsoDate to DateTime (time set to midnight).
+ ///
+ private static DateTime IsoDateToDateTime(IsoDate isoDate)
+ {
+ // Clamp to .NET DateTime range
+ if (isoDate.Year < 1 || isoDate.Year > 9999)
+ {
+ return isoDate.Year < 1 ? DateTime.MinValue : DateTime.MaxValue;
+ }
+
+ return new DateTime(isoDate.Year, isoDate.Month, isoDate.Day, 0, 0, 0, DateTimeKind.Unspecified);
+ }
+
+ ///
+ /// Converts an IsoDateTime to DateTime.
+ ///
+ private static DateTime IsoDateTimeToDateTime(IsoDateTime isoDateTime)
+ {
+ var date = isoDateTime.Date;
+ var time = isoDateTime.Time;
+
+ // Clamp to .NET DateTime range
+ if (date.Year < 1 || date.Year > 9999)
+ {
+ return date.Year < 1 ? DateTime.MinValue : DateTime.MaxValue;
+ }
+
+ return new DateTime(date.Year, date.Month, date.Day,
+ time.Hour, time.Minute, time.Second,
+ time.Millisecond, DateTimeKind.Unspecified);
+ }
+
+ ///
+ /// Converts an IsoTime to DateTime using reference date 1970-01-01.
+ ///
+ private static DateTime IsoTimeToDateTime(IsoTime time)
+ {
+ return new DateTime(1970, 1, 1, time.Hour, time.Minute, time.Second,
+ time.Millisecond, DateTimeKind.Unspecified);
+ }
+
+ ///
+ /// Converts epoch nanoseconds (BigInteger) to DateTime in UTC.
+ ///
+ private static DateTime EpochNanosecondsToDateTime(BigInteger epochNanoseconds)
+ {
+ const long nsPerTick = 100; // 1 tick = 100 nanoseconds
+
+ // Convert to ticks since Unix epoch
+ var ticks = (long) (epochNanoseconds / nsPerTick);
+
+ // Unix epoch in ticks
+ var unixEpochTicks = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc).Ticks;
+ var totalTicks = unixEpochTicks + ticks;
+
+ // Clamp to valid DateTime range
+ if (totalTicks < DateTime.MinValue.Ticks)
+ {
+ return DateTime.MinValue;
+ }
+
+ if (totalTicks > DateTime.MaxValue.Ticks)
+ {
+ return DateTime.MaxValue;
+ }
+
+ return new DateTime(totalTicks, DateTimeKind.Utc);
+ }
+
///
/// Converts a JavaScript value to DateTime, returning the original JavaScript year
/// when the date is outside .NET DateTime range (for proper era formatting).
@@ -209,7 +678,7 @@ private static double DayFromYear(int year)
/// Gets the default hour cycle for a locale.
/// Most locales use h12, but some use h23 (24-hour format without leading zero for midnight).
///
- private static string GetDefaultHourCycle(string locale)
+ internal static string GetDefaultHourCycle(string locale)
{
// Most English-speaking locales use 12-hour format
var lang = locale.Split('-')[0].ToLowerInvariant();
@@ -242,14 +711,7 @@ private JsObject ResolvedOptions(JsValue thisObject, JsCallArguments arguments)
// Use CreateDataPropertyOrThrow to avoid prototype chain setters
result.CreateDataPropertyOrThrow("locale", dateTimeFormat.Locale);
- if (dateTimeFormat.Calendar != null)
- {
- result.CreateDataPropertyOrThrow("calendar", dateTimeFormat.Calendar);
- }
- else
- {
- result.CreateDataPropertyOrThrow("calendar", "gregory");
- }
+ result.CreateDataPropertyOrThrow("calendar", dateTimeFormat.Calendar ?? "gregory");
result.CreateDataPropertyOrThrow("numberingSystem", dateTimeFormat.NumberingSystem ?? "latn");
@@ -374,12 +836,24 @@ private JsValue FormatRange(JsValue thisObject, JsCallArguments arguments)
Throw.TypeError(_realm, "startDate and endDate are required");
}
- var start = ToDateTimeForRange(startDate);
- var end = ToDateTimeForRange(endDate);
+ // Per spec: ToDateTimeFormattable on both before checking kinds
+ var x = ToDateTimeFormattable(startDate);
+ var y = ToDateTimeFormattable(endDate);
+
+ // Check SameTemporalType
+ ValidateTemporalRangeTypes(x, y);
+
+ var start = HandleDateTimeTemporalOrOtherForRange(dateTimeFormat, x);
+ var end = HandleDateTimeTemporalOrOtherForRange(dateTimeFormat, y);
+
+ // For Temporal objects, use per-type DTF and isPlain flag
+ var isTemporalInput = IsTemporalObject(x);
+ var isPlain = isTemporalInput && x is not JsInstant;
+ var effectiveDtf = isTemporalInput ? GetTemporalFormatDtf(dateTimeFormat, x) : dateTimeFormat;
// Format both dates
- var startFormatted = dateTimeFormat.Format(start);
- var endFormatted = dateTimeFormat.Format(end);
+ var startFormatted = effectiveDtf.Format(start, isPlain: isPlain);
+ var endFormatted = effectiveDtf.Format(end, isPlain: isPlain);
// If the dates are the same when formatted, return just one
if (string.Equals(startFormatted, endFormatted, StringComparison.Ordinal))
@@ -387,60 +861,50 @@ private JsValue FormatRange(JsValue thisObject, JsCallArguments arguments)
return startFormatted;
}
- // Get parts for both dates to apply collapsing logic
- var startParts = dateTimeFormat.FormatToParts(start);
- var endParts = dateTimeFormat.FormatToParts(end);
+ // Get parts to determine shared prefix/suffix for collapsing
+ var startParts = effectiveDtf.FormatToParts(start, isPlain: isPlain);
+ var endParts = effectiveDtf.FormatToParts(end, isPlain: isPlain);
- // Determine if we should use interval collapsing
- var useCollapsing = ShouldUseIntervalCollapsing(dateTimeFormat, startParts, endParts);
+ var sharedPrefixEnd = FindNaturalBoundaryPrefix(startParts, endParts);
+ var sharedSuffixLen = FindNaturalBoundarySuffix(startParts, endParts, sharedPrefixEnd);
- if (useCollapsing)
+ if (sharedPrefixEnd > 0 || sharedSuffixLen > 0)
{
- // Build collapsed range string
- var sharedPrefixEnd = FindSharedPrefixEnd(startParts, endParts);
- var sharedSuffixStart = FindSharedSuffixStart(startParts, endParts, sharedPrefixEnd);
-
- // Only collapse if there's a shared suffix
- // If the year (last component) differs, we should output full dates
- var hasSuffix = sharedSuffixStart < startParts.Count;
+ var result = new System.Text.StringBuilder();
+ var startUniqueEnd = startParts.Count - sharedSuffixLen;
+ var endUniqueEnd = endParts.Count - sharedSuffixLen;
- if (hasSuffix)
+ // Shared prefix
+ for (var i = 0; i < sharedPrefixEnd; i++)
{
- var result = new System.Text.StringBuilder();
-
- // Add shared prefix
- for (var i = 0; i < sharedPrefixEnd; i++)
- {
- result.Append(startParts[i].Value);
- }
-
- // Add start range differing parts
- for (var i = sharedPrefixEnd; i < sharedSuffixStart; i++)
- {
- result.Append(startParts[i].Value);
- }
+ result.Append(startParts[i].Value);
+ }
- // Add separator
- result.Append(" – ");
+ // Start range unique part
+ for (var i = sharedPrefixEnd; i < startUniqueEnd; i++)
+ {
+ result.Append(startParts[i].Value);
+ }
- // Add end range differing parts
- for (var i = sharedPrefixEnd; i < sharedSuffixStart; i++)
- {
- result.Append(endParts[i].Value);
- }
+ result.Append(" \u2013 ");
- // Add shared suffix
- for (var i = sharedSuffixStart; i < startParts.Count; i++)
- {
- result.Append(startParts[i].Value);
- }
+ // End range unique part
+ for (var i = sharedPrefixEnd; i < endUniqueEnd; i++)
+ {
+ result.Append(endParts[i].Value);
+ }
- return result.ToString();
+ // Shared suffix
+ for (var i = startUniqueEnd; i < startParts.Count; i++)
+ {
+ result.Append(startParts[i].Value);
}
+
+ return result.ToString();
}
// Return a range string without collapsing
- return $"{startFormatted} – {endFormatted}";
+ return $"{startFormatted} \u2013 {endFormatted}";
}
///
@@ -459,16 +923,28 @@ private JsArray FormatRangeToParts(JsValue thisObject, JsCallArguments arguments
Throw.TypeError(_realm, "startDate and endDate are required");
}
- var start = ToDateTimeForRange(startDate);
- var end = ToDateTimeForRange(endDate);
+ // Per spec: ToDateTimeFormattable on both before checking kinds
+ var x = ToDateTimeFormattable(startDate);
+ var y = ToDateTimeFormattable(endDate);
+
+ // Check SameTemporalType
+ ValidateTemporalRangeTypes(x, y);
+
+ var start = HandleDateTimeTemporalOrOtherForRange(dateTimeFormat, x);
+ var end = HandleDateTimeTemporalOrOtherForRange(dateTimeFormat, y);
+
+ // For Temporal objects, use per-type DTF and isPlain flag
+ var isTemporalInput = IsTemporalObject(x);
+ var isPlain = isTemporalInput && x is not JsInstant;
+ var effectiveDtf = isTemporalInput ? GetTemporalFormatDtf(dateTimeFormat, x) : dateTimeFormat;
// Get parts for both dates
- var startParts = dateTimeFormat.FormatToParts(start);
- var endParts = dateTimeFormat.FormatToParts(end);
+ var startParts = effectiveDtf.FormatToParts(start, isPlain: isPlain);
+ var endParts = effectiveDtf.FormatToParts(end, isPlain: isPlain);
// Check if dates are practically equal (same formatted output)
- var startFormatted = dateTimeFormat.Format(start);
- var endFormatted = dateTimeFormat.Format(end);
+ var startFormatted = effectiveDtf.Format(start, isPlain: isPlain);
+ var endFormatted = effectiveDtf.Format(end, isPlain: isPlain);
var result = new JsArray(Engine);
uint index = 0;
@@ -478,73 +954,45 @@ private JsArray FormatRangeToParts(JsValue thisObject, JsCallArguments arguments
// Dates are practically equal - return parts with source "shared"
foreach (var part in startParts)
{
- var partObj = OrdinaryObjectCreate(Engine, Engine.Realm.Intrinsics.Object.PrototypeObject);
- partObj.Set("type", part.Type);
- partObj.Set("value", part.Value);
- partObj.Set("source", "shared");
- result.SetIndexValue(index++, partObj, updateLength: true);
+ AddPartToResult(result, ref index, part.Type, part.Value, "shared");
}
}
else
{
- // Determine if we should use interval collapsing
- // Collapsing is used when explicit component options are set (not dateStyle/timeStyle)
- // and the month is textual (not numeric)
- var useCollapsing = ShouldUseIntervalCollapsing(dateTimeFormat, startParts, endParts);
+ // Find shared prefix and suffix at natural boundaries
+ var sharedPrefixEnd = FindNaturalBoundaryPrefix(startParts, endParts);
+ var sharedSuffixLen = FindNaturalBoundarySuffix(startParts, endParts, sharedPrefixEnd);
- if (useCollapsing)
+ if (sharedPrefixEnd > 0 || sharedSuffixLen > 0)
{
- // Find shared prefix and suffix
- var sharedPrefixEnd = FindSharedPrefixEnd(startParts, endParts);
- var sharedSuffixStart = FindSharedSuffixStart(startParts, endParts, sharedPrefixEnd);
+ var startUniqueEnd = startParts.Count - sharedSuffixLen;
+ var endUniqueEnd = endParts.Count - sharedSuffixLen;
- // Only collapse if there's a shared suffix
- // If the year (last component) differs, we should output full dates
- var hasSuffix = sharedSuffixStart < startParts.Count;
+ // Output shared prefix
+ for (var i = 0; i < sharedPrefixEnd; i++)
+ {
+ AddPartToResult(result, ref index, startParts[i].Type, startParts[i].Value, "shared");
+ }
- if (hasSuffix)
+ // Output start range unique part
+ for (var i = sharedPrefixEnd; i < startUniqueEnd; i++)
{
- // Add shared prefix
- for (var i = 0; i < sharedPrefixEnd; i++)
- {
- AddPartToResult(result, ref index, startParts[i].Type, startParts[i].Value, "shared");
- }
-
- // Add start range differing parts
- for (var i = sharedPrefixEnd; i < sharedSuffixStart; i++)
- {
- AddPartToResult(result, ref index, startParts[i].Type, startParts[i].Value, "startRange");
- }
-
- // Add separator
- AddPartToResult(result, ref index, "literal", " – ", "shared");
-
- // Add end range differing parts
- for (var i = sharedPrefixEnd; i < sharedSuffixStart; i++)
- {
- AddPartToResult(result, ref index, endParts[i].Type, endParts[i].Value, "endRange");
- }
-
- // Add shared suffix
- for (var i = sharedSuffixStart; i < startParts.Count; i++)
- {
- AddPartToResult(result, ref index, startParts[i].Type, startParts[i].Value, "shared");
- }
+ AddPartToResult(result, ref index, startParts[i].Type, startParts[i].Value, "startRange");
}
- else
+
+ // Separator
+ AddPartToResult(result, ref index, "literal", " \u2013 ", "shared");
+
+ // Output end range unique part
+ for (var i = sharedPrefixEnd; i < endUniqueEnd; i++)
{
- // No suffix to share - output full dates
- foreach (var part in startParts)
- {
- AddPartToResult(result, ref index, part.Type, part.Value, "startRange");
- }
-
- AddPartToResult(result, ref index, "literal", " – ", "shared");
-
- foreach (var part in endParts)
- {
- AddPartToResult(result, ref index, part.Type, part.Value, "endRange");
- }
+ AddPartToResult(result, ref index, endParts[i].Type, endParts[i].Value, "endRange");
+ }
+
+ // Output shared suffix
+ for (var i = startUniqueEnd; i < startParts.Count; i++)
+ {
+ AddPartToResult(result, ref index, startParts[i].Type, startParts[i].Value, "shared");
}
}
else
@@ -555,7 +1003,7 @@ private JsArray FormatRangeToParts(JsValue thisObject, JsCallArguments arguments
AddPartToResult(result, ref index, part.Type, part.Value, "startRange");
}
- AddPartToResult(result, ref index, "literal", " – ", "shared");
+ AddPartToResult(result, ref index, "literal", " \u2013 ", "shared");
foreach (var part in endParts)
{
@@ -576,105 +1024,124 @@ private void AddPartToResult(JsArray result, ref uint index, string type, string
result.SetIndexValue(index++, partObj, updateLength: true);
}
- private static bool ShouldUseIntervalCollapsing(JsDateTimeFormat format, List startParts, List endParts)
- {
- // Don't collapse if using dateStyle/timeStyle
- if (format.DateStyle != null || format.TimeStyle != null)
- {
- return false;
- }
-
- // Don't collapse if parts have different lengths
- if (startParts.Count != endParts.Count)
- {
- return false;
- }
-
- // Use collapsing when month is textual (short, long, narrow)
- // and we have explicit component options
- var hasTextualMonth = format.Month is "short" or "long" or "narrow";
- var hasExplicitComponents = format.Year != null || format.Month != null || format.Day != null;
-
- return hasTextualMonth && hasExplicitComponents;
- }
-
- private static int FindSharedPrefixEnd(List startParts, List endParts)
+ ///
+ /// Finds the length of the shared prefix between start and end parts,
+ /// but only if the prefix ends at a natural boundary (e.g., the ", " literal
+ /// separating date from time components, or a multi-char literal separator).
+ /// Internal separators like "/" and ":" within date/time groups are not natural boundaries.
+ /// Also ensures the remaining unique part has at least 2 non-literal components to avoid
+ /// over-collapsing (e.g., collapsing "Mar 4, " when only year differs).
+ /// Returns 0 if no valid shared prefix exists.
+ ///
+ private static int FindNaturalBoundaryPrefix(List startParts, List endParts)
{
var minLen = System.Math.Min(startParts.Count, endParts.Count);
- var i = 0;
- while (i < minLen)
+ // Find how many parts match from the start
+ var matchLen = 0;
+ while (matchLen < minLen)
{
- var startPart = startParts[i];
- var endPart = endParts[i];
+ var sp = startParts[matchLen];
+ var ep = endParts[matchLen];
- // Parts match if type and value are the same
- if (!string.Equals(startPart.Type, endPart.Type, StringComparison.Ordinal) ||
- !string.Equals(startPart.Value, endPart.Value, StringComparison.Ordinal))
+ if (!string.Equals(sp.Type, ep.Type, StringComparison.Ordinal) ||
+ !string.Equals(sp.Value, ep.Value, StringComparison.Ordinal))
{
break;
}
- i++;
+ matchLen++;
}
- // Include trailing literal in prefix if the next non-literal parts also match
- // This handles cases like "Jan " where the space should be shared
- while (i > 0 && i < minLen &&
- string.Equals(startParts[i - 1].Type, "literal", StringComparison.Ordinal))
+ // No shared prefix at all, or entire sequence matches (handled separately as "shared")
+ if (matchLen == 0 || matchLen >= minLen)
{
- // Check if the previous non-literal parts match
- var prevNonLiteral = i - 2;
- while (prevNonLiteral >= 0 && string.Equals(startParts[prevNonLiteral].Type, "literal", StringComparison.Ordinal))
- {
- prevNonLiteral--;
- }
+ return 0;
+ }
- if (prevNonLiteral >= 0 &&
- string.Equals(startParts[prevNonLiteral].Value, endParts[prevNonLiteral].Value, StringComparison.Ordinal))
- {
- // Include the literal in the prefix
- break;
- }
- else
+ // Check if the prefix ends at a natural boundary.
+ var lastPart = startParts[matchLen - 1];
+ if (!string.Equals(lastPart.Type, "literal", StringComparison.Ordinal))
+ {
+ return 0;
+ }
+
+ var val = lastPart.Value;
+ // Reject single-char internal separators
+ if (val.Length == 1 && (val[0] == '/' || val[0] == ':' || val[0] == '.' || val[0] == '-'))
+ {
+ return 0;
+ }
+
+ // Ensure at least 2 non-literal parts remain in the unique section.
+ // This prevents over-collapsing like "Mar 4, 2019 – 2020" when only years differ.
+ var remainingNonLiteral = 0;
+ for (var i = matchLen; i < startParts.Count; i++)
+ {
+ if (!string.Equals(startParts[i].Type, "literal", StringComparison.Ordinal))
{
- // Don't include trailing literal that precedes different values
- i--;
+ remainingNonLiteral++;
}
}
- return i;
+ if (remainingNonLiteral < 2)
+ {
+ return 0;
+ }
+
+ return matchLen;
}
- private static int FindSharedSuffixStart(List startParts, List endParts, int prefixEnd)
+ ///
+ /// Finds the number of shared suffix parts between start and end parts,
+ /// starting from the given offset (to avoid overlapping with a shared prefix).
+ /// Only accepts suffixes that start at a natural boundary (multi-char literal).
+ /// Returns 0 if no valid shared suffix exists.
+ ///
+ private static int FindNaturalBoundarySuffix(List startParts, List endParts, int prefixEnd)
{
var startLen = startParts.Count;
var endLen = endParts.Count;
- if (startLen != endLen)
+ // Find how many parts match from the end
+ var matchLen = 0;
+ while (matchLen < startLen - prefixEnd && matchLen < endLen - prefixEnd)
{
- return startLen;
- }
+ var sp = startParts[startLen - 1 - matchLen];
+ var ep = endParts[endLen - 1 - matchLen];
- var i = startLen - 1;
+ if (!string.Equals(sp.Type, ep.Type, StringComparison.Ordinal) ||
+ !string.Equals(sp.Value, ep.Value, StringComparison.Ordinal))
+ {
+ break;
+ }
+
+ matchLen++;
+ }
- while (i >= prefixEnd)
+ if (matchLen == 0)
{
- var startPart = startParts[i];
- var endPart = endParts[i];
+ return 0;
+ }
- // Parts match if type and value are the same
- if (!string.Equals(startPart.Type, endPart.Type, StringComparison.Ordinal) ||
- !string.Equals(startPart.Value, endPart.Value, StringComparison.Ordinal))
+ // Check if the suffix starts at a natural boundary
+ // The first suffix part (counting from the end) should be preceded by a natural boundary literal
+ var firstSuffixIdx = startLen - matchLen;
+ var boundaryPart = startParts[firstSuffixIdx];
+ if (string.Equals(boundaryPart.Type, "literal", StringComparison.Ordinal))
+ {
+ var val = boundaryPart.Value;
+ // Reject single-char internal separators
+ if (val.Length == 1 && (val[0] == '/' || val[0] == ':' || val[0] == '.' || val[0] == '-'))
{
- break;
+ return 0;
}
- i--;
+ // Accept multi-char literals like ", " as natural boundaries
+ return matchLen;
}
- // Move past the last differing part to get suffix start
- return i + 1;
+ return 0;
}
private DateTime ToDateTimeForRange(JsValue value)
diff --git a/Jint/Native/Intl/DefaultCldrProvider.cs b/Jint/Native/Intl/DefaultCldrProvider.cs
index 051cad1878..1a002ae50a 100644
--- a/Jint/Native/Intl/DefaultCldrProvider.cs
+++ b/Jint/Native/Intl/DefaultCldrProvider.cs
@@ -317,7 +317,7 @@ private DefaultCldrProvider()
return null;
}
- public string[]? GetMonthNames(string locale, string style)
+ public string[]? GetMonthNames(string locale, string style, string? calendar)
{
try
{
@@ -355,7 +355,7 @@ private DefaultCldrProvider()
}
}
- public string[]? GetDayPeriods(string locale, string style)
+ public string[]? GetDayPeriods(string locale, string style, string? calendar)
{
try
{
@@ -368,7 +368,7 @@ private DefaultCldrProvider()
}
}
- public string[]? GetEraNames(string locale, string style)
+ public string[]? GetEraNames(string locale, string style, string? calendar)
{
if (!IsEnglish(locale))
{
@@ -386,67 +386,6 @@ private DefaultCldrProvider()
// === Display Names ===
- public string? GetLanguageDisplayName(string locale, string code)
- {
- if (!IsEnglish(locale))
- {
- return null;
- }
-
- try
- {
- var culture = new CultureInfo(code);
- return culture.EnglishName;
- }
- catch
- {
- return null;
- }
- }
-
- public string? GetRegionDisplayName(string locale, string code)
- {
- if (!IsEnglish(locale))
- {
- return null;
- }
-
- try
- {
- var region = new RegionInfo(code);
- return region.EnglishName;
- }
- catch
- {
- return null;
- }
- }
-
- public string? GetScriptDisplayName(string locale, string code)
- {
- if (!IsEnglish(locale))
- {
- return null;
- }
-
- // Common script names
- return code.ToUpperInvariant() switch
- {
- "LATN" => "Latin",
- "CYRL" => "Cyrillic",
- "ARAB" => "Arabic",
- "HANS" => "Simplified Chinese",
- "HANT" => "Traditional Chinese",
- "DEVA" => "Devanagari",
- "GREK" => "Greek",
- "HEBR" => "Hebrew",
- "JPAN" => "Japanese",
- "KORE" => "Korean",
- "THAI" => "Thai",
- _ => null
- };
- }
-
public string? GetCurrencyDisplayName(string locale, string code)
{
if (!IsEnglish(locale))
diff --git a/Jint/Native/Intl/DurationFormatPrototype.cs b/Jint/Native/Intl/DurationFormatPrototype.cs
index 6ba1659d18..ba51847615 100644
--- a/Jint/Native/Intl/DurationFormatPrototype.cs
+++ b/Jint/Native/Intl/DurationFormatPrototype.cs
@@ -1,6 +1,7 @@
using System.Numerics;
using Jint.Native.Object;
using Jint.Native.Symbol;
+using Jint.Native.Temporal;
using Jint.Runtime;
using Jint.Runtime.Descriptors;
using Jint.Runtime.Interop;
@@ -135,8 +136,30 @@ private JsDurationFormat.DurationRecord ToDurationRecord(JsValue value)
// Per spec: if input is a string, try to parse it as a duration
if (value.IsString())
{
- // String durations not yet supported - throw RangeError
- Throw.RangeError(_realm, "Duration string parsing is not supported");
+ var parsed = TemporalHelpers.ParseDuration(value.ToString());
+ if (parsed is null)
+ {
+ Throw.RangeError(_realm, "Invalid duration string");
+ return default;
+ }
+
+ var dr = parsed.Value;
+ var record = new JsDurationFormat.DurationRecord
+ {
+ Years = dr.Years,
+ Months = dr.Months,
+ Weeks = dr.Weeks,
+ Days = dr.Days,
+ Hours = dr.Hours,
+ Minutes = dr.Minutes,
+ Seconds = dr.Seconds,
+ Milliseconds = dr.Milliseconds,
+ Microseconds = dr.Microseconds,
+ Nanoseconds = dr.Nanoseconds,
+ };
+
+ ValidateDurationRecord(record);
+ return record;
}
if (!value.IsObject())
@@ -146,6 +169,28 @@ private JsDurationFormat.DurationRecord ToDurationRecord(JsValue value)
var obj = value.AsObject();
+ // Fast path for Temporal.Duration objects - read internal slots directly
+ if (obj is JsDuration jsDuration)
+ {
+ var dr = jsDuration.DurationRecord;
+ var record = new JsDurationFormat.DurationRecord
+ {
+ Years = dr.Years,
+ Months = dr.Months,
+ Weeks = dr.Weeks,
+ Days = dr.Days,
+ Hours = dr.Hours,
+ Minutes = dr.Minutes,
+ Seconds = dr.Seconds,
+ Milliseconds = dr.Milliseconds,
+ Microseconds = dr.Microseconds,
+ Nanoseconds = dr.Nanoseconds,
+ };
+
+ ValidateDurationRecord(record);
+ return record;
+ }
+
// Check if at least one duration property is defined and not undefined
var hasDefinedProperty = false;
foreach (var prop in DurationProperties)
@@ -163,23 +208,25 @@ private JsDurationFormat.DurationRecord ToDurationRecord(JsValue value)
Throw.TypeError(_realm, "Duration must have at least one duration property defined");
}
- var record = new JsDurationFormat.DurationRecord();
-
- record.Years = GetDurationComponent(obj, "years");
- record.Months = GetDurationComponent(obj, "months");
- record.Weeks = GetDurationComponent(obj, "weeks");
- record.Days = GetDurationComponent(obj, "days");
- record.Hours = GetDurationComponent(obj, "hours");
- record.Minutes = GetDurationComponent(obj, "minutes");
- record.Seconds = GetDurationComponent(obj, "seconds");
- record.Milliseconds = GetDurationComponent(obj, "milliseconds");
- record.Microseconds = GetDurationComponent(obj, "microseconds");
- record.Nanoseconds = GetDurationComponent(obj, "nanoseconds");
-
- // Validate the duration record per spec (IsValidDurationRecord)
- ValidateDurationRecord(record);
-
- return record;
+ {
+ var record = new JsDurationFormat.DurationRecord();
+
+ record.Years = GetDurationComponent(obj, "years");
+ record.Months = GetDurationComponent(obj, "months");
+ record.Weeks = GetDurationComponent(obj, "weeks");
+ record.Days = GetDurationComponent(obj, "days");
+ record.Hours = GetDurationComponent(obj, "hours");
+ record.Minutes = GetDurationComponent(obj, "minutes");
+ record.Seconds = GetDurationComponent(obj, "seconds");
+ record.Milliseconds = GetDurationComponent(obj, "milliseconds");
+ record.Microseconds = GetDurationComponent(obj, "microseconds");
+ record.Nanoseconds = GetDurationComponent(obj, "nanoseconds");
+
+ // Validate the duration record per spec (IsValidDurationRecord)
+ ValidateDurationRecord(record);
+
+ return record;
+ }
}
private void ValidateDurationRecord(JsDurationFormat.DurationRecord record)
diff --git a/Jint/Native/Intl/ICldrProvider.cs b/Jint/Native/Intl/ICldrProvider.cs
index 4276c14e2e..9927a1f423 100644
--- a/Jint/Native/Intl/ICldrProvider.cs
+++ b/Jint/Native/Intl/ICldrProvider.cs
@@ -89,8 +89,9 @@ public interface ICldrProvider
///
/// The locale identifier.
/// Style: "long", "short", "narrow", or "numeric".
+ /// Calendar identifier (e.g., "gregory", "buddhist"), or null for default.
/// Array of 12 month names (January-December) or null if not available.
- string[]? GetMonthNames(string locale, string style);
+ string[]? GetMonthNames(string locale, string style, string? calendar);
///
/// Gets weekday names for a locale.
@@ -105,43 +106,21 @@ public interface ICldrProvider
///
/// The locale identifier.
/// Style: "long", "short", or "narrow".
+ /// Calendar identifier (e.g., "gregory", "buddhist"), or null for default.
/// Array of day period names or null if not available.
- string[]? GetDayPeriods(string locale, string style);
+ string[]? GetDayPeriods(string locale, string style, string? calendar);
///
/// Gets era names for a locale.
///
/// The locale identifier.
/// Style: "long", "short", or "narrow".
+ /// Calendar identifier (e.g., "gregory", "japanese"), or null for default.
/// Array of era names or null if not available.
- string[]? GetEraNames(string locale, string style);
+ string[]? GetEraNames(string locale, string style, string? calendar);
// === Display Names ===
- ///
- /// Gets the display name for a language code.
- ///
- /// The locale for localization.
- /// The language code to get the name for.
- /// Display name or null if not available.
- string? GetLanguageDisplayName(string locale, string code);
-
- ///
- /// Gets the display name for a region code.
- ///
- /// The locale for localization.
- /// The region code to get the name for.
- /// Display name or null if not available.
- string? GetRegionDisplayName(string locale, string code);
-
- ///
- /// Gets the display name for a script code.
- ///
- /// The locale for localization.
- /// The script code to get the name for.
- /// Display name or null if not available.
- string? GetScriptDisplayName(string locale, string code);
-
///
/// Gets the display name for a currency code.
///
diff --git a/Jint/Native/Intl/JsDateTimeFormat.cs b/Jint/Native/Intl/JsDateTimeFormat.cs
index 6499e20172..ff5fae224c 100644
--- a/Jint/Native/Intl/JsDateTimeFormat.cs
+++ b/Jint/Native/Intl/JsDateTimeFormat.cs
@@ -31,6 +31,7 @@ internal JsDateTimeFormat(
string? second,
int? fractionalSecondDigits,
string? timeZoneName,
+ bool hasExplicitFormatComponents,
DateTimeFormatInfo dateTimeFormatInfo,
CultureInfo cultureInfo) : base(engine)
{
@@ -53,6 +54,7 @@ internal JsDateTimeFormat(
Second = second;
FractionalSecondDigits = fractionalSecondDigits;
TimeZoneName = timeZoneName;
+ HasExplicitFormatComponents = hasExplicitFormatComponents;
DateTimeFormatInfo = dateTimeFormatInfo;
CultureInfo = cultureInfo;
}
@@ -75,6 +77,7 @@ internal JsDateTimeFormat(
internal string? Second { get; }
internal int? FractionalSecondDigits { get; }
internal string? TimeZoneName { get; }
+ internal bool HasExplicitFormatComponents { get; }
internal DateTimeFormatInfo DateTimeFormatInfo { get; }
internal CultureInfo CultureInfo { get; }
@@ -88,7 +91,8 @@ internal JsDateTimeFormat(
///
/// The .NET DateTime to format
/// Optional original JavaScript year (for dates outside .NET DateTime range)
- internal string Format(DateTime dateTime, int? originalYear = null)
+ /// If true, skip timezone conversion (for plain Temporal types)
+ internal string Format(DateTime dateTime, int? originalYear = null, bool isPlain = false)
{
// For Chinese and Dangi calendars, use FormatToParts to get consistent output
// This ensures the special part types (relatedYear, yearName) are properly handled
@@ -101,7 +105,7 @@ internal string Format(DateTime dateTime, int? originalYear = null)
if (isLunisolarCalendar || hasEra)
{
- var parts = FormatToParts(dateTime, originalYear);
+ var parts = FormatToParts(dateTime, originalYear, isPlain);
var sb = new StringBuilder();
foreach (var part in parts)
{
@@ -111,9 +115,21 @@ internal string Format(DateTime dateTime, int? originalYear = null)
}
// Convert to specified timezone if one was provided
- if (TimeZone != null)
+ // For plain Temporal types (isPlain=true), skip timezone conversion since
+ // they represent wall-clock time, not an absolute point in time
+ if (!isPlain)
{
- dateTime = ConvertToTimeZone(dateTime, TimeZone);
+ if (TimeZone != null)
+ {
+ dateTime = ConvertToTimeZone(dateTime, TimeZone);
+ }
+ else if (dateTime.Kind == DateTimeKind.Utc)
+ {
+ // No explicit timezone: convert UTC to engine's default timezone
+ // (not system ToLocalTime which ignores engine's configured timezone)
+ var defaultTz = _engine.Options.TimeSystem.DefaultTimeZone;
+ dateTime = TimeZoneInfo.ConvertTimeFromUtc(dateTime, defaultTz);
+ }
}
string result;
@@ -121,7 +137,7 @@ internal string Format(DateTime dateTime, int? originalYear = null)
// If dateStyle or timeStyle is specified, use those
if (DateStyle != null || TimeStyle != null)
{
- result = FormatWithStyles(dateTime);
+ result = FormatWithStyles(dateTime, isPlain);
}
else
{
@@ -240,7 +256,7 @@ private static DateTime ConvertToTimeZone(DateTime dateTime, string timeZoneId)
}
// Get era names from CLDR provider if available
- var eraNames = CldrProvider.GetEraNames(Locale, style);
+ var eraNames = CldrProvider.GetEraNames(Locale, style, calendar);
// Use original year for era calculation if the date was clamped
var effectiveYear = originalYear ?? dateTime.Year;
@@ -635,14 +651,14 @@ private void AddYearPart(DateTime dateTime, List result, ref bool
hasDate = true;
}
- private string FormatWithStyles(DateTime dateTime)
+ private string FormatWithStyles(DateTime dateTime, bool isPlain = false)
{
// When both dateStyle and timeStyle are specified, combine them appropriately
if (DateStyle != null && TimeStyle != null)
{
// Format date and time separately and combine with ", "
var datePart = FormatDateStyleOnly(dateTime);
- var timePart = FormatTimeStyle(dateTime);
+ var timePart = FormatTimeStyle(dateTime, isPlain);
return $"{datePart}, {timePart}";
}
@@ -653,7 +669,7 @@ private string FormatWithStyles(DateTime dateTime)
if (TimeStyle != null)
{
- return FormatTimeStyle(dateTime);
+ return FormatTimeStyle(dateTime, isPlain);
}
return dateTime.ToString("G", CultureInfo);
@@ -738,60 +754,37 @@ private string FormatShortDate(DateTime dateTime)
///
/// Formats time using timeStyle, respecting hourCycle
///
- private string FormatTimeStyle(DateTime dateTime)
+ private string FormatTimeStyle(DateTime dateTime, bool isPlain = false)
{
- // Determine if we should use 12 or 24 hour format
- bool use12Hour;
- if (HourCycle != null)
- {
- // Explicit hourCycle takes precedence
- use12Hour = string.Equals(HourCycle, "h11", StringComparison.Ordinal) ||
- string.Equals(HourCycle, "h12", StringComparison.Ordinal);
- }
- else
- {
- // Derive from locale - English uses 12-hour, most others use 24-hour
- var lang = Locale.Split('-')[0].ToLowerInvariant();
- use12Hour = string.Equals(lang, "en", StringComparison.Ordinal);
- }
+ // Use ComputeHourValue to handle all hour cycles correctly
+ // Style-based formatting always pads 24-hour values (e.g., "05:00:00" not "5:00:00")
+ ComputeHourValue(dateTime.Hour, out var hourStr, out var use12Hour, padByDefault: true);
+ // Plain Temporal types should not show timeZoneName
var timeZoneSuffix = "";
- if (string.Equals(TimeStyle, "full", StringComparison.Ordinal))
+ if (!isPlain)
{
- if (!string.IsNullOrEmpty(TimeZone) && string.Equals(TimeZone, "UTC", StringComparison.OrdinalIgnoreCase))
- {
- timeZoneSuffix = " Coordinated Universal Time";
- }
- else
+ if (string.Equals(TimeStyle, "full", StringComparison.Ordinal))
{
- timeZoneSuffix = " " + TimeZoneInfo.Local.DisplayName;
+ timeZoneSuffix = " " + GetTimeZoneDisplayName(dateTime, longName: true, generic: false);
}
- }
- else if (string.Equals(TimeStyle, "long", StringComparison.Ordinal))
- {
- if (!string.IsNullOrEmpty(TimeZone) && string.Equals(TimeZone, "UTC", StringComparison.OrdinalIgnoreCase))
+ else if (string.Equals(TimeStyle, "long", StringComparison.Ordinal))
{
- timeZoneSuffix = " UTC";
+ timeZoneSuffix = " " + GetTimeZoneDisplayName(dateTime, longName: false, generic: false);
}
}
+ var minuteStr = dateTime.Minute.ToString("D2", CultureInfo.InvariantCulture);
+ var secondStr = dateTime.Second.ToString("D2", CultureInfo.InvariantCulture);
+ var dayPeriod = use12Hour ? " " + GetDayPeriod(dateTime.Hour) : "";
+
return TimeStyle switch
{
- "full" => use12Hour
- ? dateTime.ToString("h:mm:ss", CultureInfo) + " " + GetDayPeriod(dateTime.Hour) + timeZoneSuffix
- : dateTime.ToString("HH:mm:ss", CultureInfo) + timeZoneSuffix,
- "long" => use12Hour
- ? dateTime.ToString("h:mm:ss", CultureInfo) + " " + GetDayPeriod(dateTime.Hour) + timeZoneSuffix
- : dateTime.ToString("HH:mm:ss", CultureInfo) + timeZoneSuffix,
- "medium" => use12Hour
- ? dateTime.ToString("h:mm:ss", CultureInfo) + " " + GetDayPeriod(dateTime.Hour)
- : dateTime.ToString("HH:mm:ss", CultureInfo),
- "short" => use12Hour
- ? dateTime.ToString("h:mm", CultureInfo) + " " + GetDayPeriod(dateTime.Hour)
- : dateTime.ToString("HH:mm", CultureInfo),
- _ => use12Hour
- ? dateTime.ToString("h:mm", CultureInfo) + " " + GetDayPeriod(dateTime.Hour)
- : dateTime.ToString("HH:mm", CultureInfo),
+ "full" => hourStr + ":" + minuteStr + ":" + secondStr + dayPeriod + timeZoneSuffix,
+ "long" => hourStr + ":" + minuteStr + ":" + secondStr + dayPeriod + timeZoneSuffix,
+ "medium" => hourStr + ":" + minuteStr + ":" + secondStr + dayPeriod,
+ "short" => hourStr + ":" + minuteStr + dayPeriod,
+ _ => hourStr + ":" + minuteStr + dayPeriod,
};
}
@@ -862,16 +855,14 @@ private string FormatWithComponents(DateTime dateTime, int? originalYear = null)
}
}
- // Hour
+ // Hour - use pre-computed value to handle all hour cycles (h11/h12/h23/h24)
+ bool hourUse12Hour = false;
if (Hour != null)
{
- var use12Hour = string.Equals(GetHourFormat(), "h12", StringComparison.Ordinal);
- parts.Add(Hour switch
- {
- "numeric" => use12Hour ? "h" : "H",
- "2-digit" => use12Hour ? "hh" : "HH",
- _ => use12Hour ? "h" : "H"
- });
+ ComputeHourValue(dateTime.Hour, out var hourStr, out var use12Hr);
+ hourUse12Hour = use12Hr;
+ // Use escaped literal in format string so .NET outputs our pre-computed value
+ parts.Add("'" + hourStr + "'");
}
// Minute - for time components, "numeric" typically uses 2-digit padding in most locales
@@ -904,25 +895,17 @@ private string FormatWithComponents(DateTime dateTime, int? originalYear = null)
// Day period (AM/PM) - only add "tt" if using 12-hour format with hour specified
// and DayPeriod is not explicitly specified (DayPeriod uses extended periods)
- var needsAmPm = Hour != null && string.Equals(GetHourFormat(), "h12", StringComparison.Ordinal) && DayPeriod == null;
+ var needsAmPm = Hour != null && hourUse12Hour && DayPeriod == null;
if (needsAmPm)
{
parts.Add("tt");
}
- // Time zone name
+ // Time zone name - compute the display name directly (not via .NET format specifier)
+ string? timeZoneNameStr = null;
if (TimeZoneName != null)
{
- parts.Add(TimeZoneName switch
- {
- "long" => "zzz",
- "short" => "zzz",
- "shortOffset" => "zzz",
- "longOffset" => "zzz",
- "shortGeneric" => "zzz",
- "longGeneric" => "zzz",
- _ => "zzz"
- });
+ timeZoneNameStr = GetFormattedTimeZoneName(dateTime);
}
// Handle DayPeriod option (extended day periods like "in the morning")
@@ -959,10 +942,15 @@ private string FormatWithComponents(DateTime dateTime, int? originalYear = null)
}
}
- return formatted + " " + GetExtendedDayPeriod(dateTime.Hour);
+ var dayPeriodResult = formatted + " " + GetExtendedDayPeriod(dateTime.Hour);
+ if (timeZoneNameStr != null)
+ {
+ dayPeriodResult += " " + timeZoneNameStr;
+ }
+ return dayPeriodResult;
}
- if (parts.Count == 0 && eraValue == null)
+ if (parts.Count == 0 && eraValue == null && timeZoneNameStr == null)
{
// Default format if no components specified
return dateTime.ToString("G", CultureInfo);
@@ -993,6 +981,19 @@ private string FormatWithComponents(DateTime dateTime, int? originalYear = null)
}
}
+ // Append timezone name if specified
+ if (timeZoneNameStr != null)
+ {
+ if (result.Length > 0)
+ {
+ result += " " + timeZoneNameStr;
+ }
+ else
+ {
+ result = timeZoneNameStr;
+ }
+ }
+
return result;
}
@@ -1019,6 +1020,77 @@ private string GetHourFormat()
return timePattern.Contains('H') ? "h24" : "h12";
}
+ ///
+ /// Computes the formatted hour string based on the hourCycle, hour option, and actual hour value.
+ /// Returns the formatted hour string and whether AM/PM should be shown.
+ /// Per ECMA-402: h11=0-11 (12hr), h12=1-12 (12hr), h23=0-23 (24hr), h24=1-24 (24hr).
+ /// 24-hour formats always pad to 2 digits; 12-hour formats pad only for "2-digit" option.
+ ///
+ ///
+ /// Computes the formatted hour value based on HourCycle and locale defaults.
+ ///
+ /// The 0-23 hour value
+ /// Output: formatted hour string
+ /// Output: whether 12-hour format is used (needs AM/PM)
+ /// If true, always pad h23/h24 hours (used by style-based formatting)
+ private void ComputeHourValue(int hour, out string hourStr, out bool use12Hour, bool padByDefault = false)
+ {
+ int hourValue;
+
+ if (string.Equals(HourCycle, "h11", StringComparison.Ordinal))
+ {
+ hourValue = hour % 12; // 0-11
+ use12Hour = true;
+ }
+ else if (string.Equals(HourCycle, "h24", StringComparison.Ordinal))
+ {
+ hourValue = hour == 0 ? 24 : hour; // 1-24
+ use12Hour = false;
+ }
+ else if (string.Equals(HourCycle, "h23", StringComparison.Ordinal))
+ {
+ hourValue = hour; // 0-23
+ use12Hour = false;
+ }
+ else if (string.Equals(HourCycle, "h12", StringComparison.Ordinal))
+ {
+ hourValue = hour % 12 == 0 ? 12 : hour % 12; // 1-12
+ use12Hour = true;
+ }
+ else
+ {
+ // No explicit hourCycle - derive from locale using CLDR defaults
+ // (not from .NET CultureInfo which may reflect system user overrides)
+ var defaultHc = DateTimeFormatPrototype.GetDefaultHourCycle(Locale);
+ if (string.Equals(defaultHc, "h11", StringComparison.Ordinal))
+ {
+ hourValue = hour % 12; // 0-11
+ use12Hour = true;
+ }
+ else if (string.Equals(defaultHc, "h23", StringComparison.Ordinal))
+ {
+ hourValue = hour; // 0-23
+ use12Hour = false;
+ }
+ else if (string.Equals(defaultHc, "h24", StringComparison.Ordinal))
+ {
+ hourValue = hour == 0 ? 24 : hour; // 1-24
+ use12Hour = false;
+ }
+ else
+ {
+ // h12 default
+ hourValue = hour % 12 == 0 ? 12 : hour % 12; // 1-12
+ use12Hour = true;
+ }
+ }
+
+ // Per ECMA-402: 24-hour formats (h23, h24) always pad to 2 digits.
+ // 12-hour formats only pad when Hour option is "2-digit".
+ var pad = !use12Hour || string.Equals(Hour, "2-digit", StringComparison.Ordinal);
+ hourStr = pad ? hourValue.ToString("D2", CultureInfo.InvariantCulture) : hourValue.ToString(CultureInfo.InvariantCulture);
+ }
+
private string BuildFormatString(List parts)
{
// Simple join - a more sophisticated implementation would use
@@ -1038,21 +1110,23 @@ private string BuildFormatString(List parts)
}
var firstChar = part[0];
+ // Escaped literals starting with ' are pre-computed hour values (treated as time component)
+ var isHourLiteral = firstChar == '\'';
if (result.Length > 0)
{
// Add separator based on what we're joining
- if (firstChar is 'h' or 'H' or 'm' or 's' or 'f' or 't')
+ if (firstChar is 'h' or 'H' or 'm' or 's' or 'f' or 't' || isHourLiteral)
{
if (!hasTime)
{
if (hasDate)
{
- result.Append(' ');
+ result.Append("', '"); // Literal ", " between date and time
}
hasTime = true;
}
- else if (firstChar is not 't' and not 'f')
+ else if (firstChar is not 't' and not 'f' and not '\'')
{
result.Append(':');
}
@@ -1102,7 +1176,7 @@ private string BuildFormatString(List parts)
}
else
{
- if (firstChar is 'h' or 'H' or 'm' or 's' or 'f')
+ if (firstChar is 'h' or 'H' or 'm' or 's' or 'f' || isHourLiteral)
{
hasTime = true;
}
@@ -1132,12 +1206,23 @@ private string BuildFormatString(List parts)
///
/// The .NET DateTime to format
/// Optional original JavaScript year (for dates outside .NET DateTime range)
- internal List FormatToParts(DateTime dateTime, int? originalYear = null)
+ /// If true, skip timezone conversion (for plain Temporal types)
+ internal List FormatToParts(DateTime dateTime, int? originalYear = null, bool isPlain = false)
{
// Convert to specified timezone if one was provided
- if (TimeZone != null)
+ // For plain Temporal types (isPlain=true), skip timezone conversion
+ if (!isPlain)
{
- dateTime = ConvertToTimeZone(dateTime, TimeZone);
+ if (TimeZone != null)
+ {
+ dateTime = ConvertToTimeZone(dateTime, TimeZone);
+ }
+ else if (dateTime.Kind == DateTimeKind.Utc)
+ {
+ // No explicit timezone: convert UTC to engine's default timezone
+ var defaultTz = _engine.Options.TimeSystem.DefaultTimeZone;
+ dateTime = TimeZoneInfo.ConvertTimeFromUtc(dateTime, defaultTz);
+ }
}
var result = new List();
@@ -1145,7 +1230,7 @@ internal List FormatToParts(DateTime dateTime, int? originalYear =
if (DateStyle != null || TimeStyle != null)
{
// For style-based formatting, use a simpler approach
- FormatStyleToParts(dateTime, result, originalYear);
+ FormatStyleToParts(dateTime, result, originalYear, isPlain);
}
else
{
@@ -1169,7 +1254,7 @@ internal List FormatToParts(DateTime dateTime, int? originalYear =
return result;
}
- private void FormatStyleToParts(DateTime dateTime, List result, int? originalYear)
+ private void FormatStyleToParts(DateTime dateTime, List result, int? originalYear, bool isPlain = false)
{
// For style-based formatting, decompose into proper parts
// Map styles to component options and use component-based parts generation
@@ -1189,7 +1274,7 @@ private void FormatStyleToParts(DateTime dateTime, List result, in
if (hasTime)
{
- FormatTimeStyleToParts(dateTime, result);
+ FormatTimeStyleToParts(dateTime, result, isPlain);
}
}
@@ -1304,14 +1389,13 @@ private void AddLunisolarDateParts(List result, ChineseCalendarHel
}
}
- private void FormatTimeStyleToParts(DateTime dateTime, List result)
+ private void FormatTimeStyleToParts(DateTime dateTime, List result, bool isPlain = false)
{
var style = TimeStyle;
- var use12Hour = IsUsing12HourFormat();
- var hour = use12Hour ? (dateTime.Hour % 12 == 0 ? 12 : dateTime.Hour % 12) : dateTime.Hour;
+ ComputeHourValue(dateTime.Hour, out var hourStr, out var use12Hour, padByDefault: true);
// Hour
- result.Add(new DateTimePart("hour", hour.ToString(CultureInfo)));
+ result.Add(new DateTimePart("hour", hourStr));
// Minute (always for time styles)
result.Add(new DateTimePart("literal", ":"));
@@ -1331,46 +1415,132 @@ private void FormatTimeStyleToParts(DateTime dateTime, List result
result.Add(new DateTimePart("dayPeriod", dateTime.Hour < 12 ? "AM" : "PM"));
}
- // Time zone name (for long and full)
- if (string.Equals(style, "full", StringComparison.Ordinal))
+ // Time zone name (for long and full) - omit for plain Temporal types
+ if (!isPlain)
{
- result.Add(new DateTimePart("literal", " "));
- result.Add(new DateTimePart("timeZoneName", GetTimeZoneDisplayName(true)));
+ if (string.Equals(style, "full", StringComparison.Ordinal))
+ {
+ result.Add(new DateTimePart("literal", " "));
+ result.Add(new DateTimePart("timeZoneName", GetTimeZoneDisplayName(dateTime, longName: true, generic: false)));
+ }
+ else if (string.Equals(style, "long", StringComparison.Ordinal))
+ {
+ result.Add(new DateTimePart("literal", " "));
+ result.Add(new DateTimePart("timeZoneName", GetTimeZoneDisplayName(dateTime, longName: false, generic: false)));
+ }
}
- else if (string.Equals(style, "long", StringComparison.Ordinal))
+ }
+
+ private string GetTimeZoneDisplayName(DateTime utcDateTime, bool longName, bool generic)
+ {
+ if (TimeZone != null)
{
- result.Add(new DateTimePart("literal", " "));
- result.Add(new DateTimePart("timeZoneName", GetTimeZoneDisplayName(false)));
+ if (string.Equals(TimeZone, "UTC", StringComparison.OrdinalIgnoreCase))
+ {
+ return longName ? "Coordinated Universal Time" : "UTC";
+ }
+
+ // Handle offset timezone format like "+00:00", "+03:00", "-07:30"
+ var offset = TryParseOffset(TimeZone);
+ if (offset.HasValue)
+ {
+ return FormatGmtOffset(offset.Value, longName);
+ }
+
+ // Try CLDR metazone data first (provides locale-correct names)
+ var isDst = false;
+ try
+ {
+ var tzInfo = TimeZoneInfo.FindSystemTimeZoneById(TimeZone);
+ isDst = tzInfo.IsDaylightSavingTime(DateTime.SpecifyKind(utcDateTime, DateTimeKind.Utc));
+ }
+ catch
+ {
+ // If timezone not found, isDst stays false
+ }
+
+ var cldrName = Data.MetaZoneData.GetDisplayName(TimeZone, isDst, longName, generic);
+ if (cldrName != null)
+ {
+ return cldrName;
+ }
+
+ // Fallback to .NET TimeZoneInfo names
+ try
+ {
+ var tzInfo = TimeZoneInfo.FindSystemTimeZoneById(TimeZone);
+ if (longName)
+ {
+ return isDst ? tzInfo.DaylightName : tzInfo.StandardName;
+ }
+ var parts = TimeZone.Split('/');
+ return parts[parts.Length - 1].Replace('_', ' ');
+ }
+ catch
+ {
+ var parts = TimeZone.Split('/');
+ return longName ? TimeZone : parts[parts.Length - 1];
+ }
}
+ return longName ? TimeZoneInfo.Local.StandardName : TimeZoneInfo.Local.Id;
}
- private bool IsUsing12HourFormat()
+ ///
+ /// Formats a GMT offset display name. Short: "GMT+1", Long: "GMT+01:00".
+ ///
+ private static string FormatGmtOffset(TimeSpan offset, bool longName)
{
- // Check hourCycle first
- if (HourCycle != null)
+ if (offset == TimeSpan.Zero)
{
- return string.Equals(HourCycle, "h11", StringComparison.Ordinal) ||
- string.Equals(HourCycle, "h12", StringComparison.Ordinal);
+ return "GMT";
}
- // Default based on locale - US uses 12-hour, most others use 24-hour
- var lang = Locale.Split('-')[0].ToLowerInvariant();
- return string.Equals(lang, "en", StringComparison.Ordinal);
+ var sign = offset < TimeSpan.Zero ? "-" : "+";
+ var absOffset = offset < TimeSpan.Zero ? offset.Negate() : offset;
+
+ if (longName)
+ {
+ return $"GMT{sign}{absOffset.Hours:D2}:{absOffset.Minutes:D2}";
+ }
+
+ if (absOffset.Minutes == 0)
+ {
+ return $"GMT{sign}{absOffset.Hours}";
+ }
+
+ return $"GMT{sign}{absOffset.Hours}:{absOffset.Minutes:D2}";
}
- private string GetTimeZoneDisplayName(bool longName)
+ ///
+ /// Gets the formatted timezone name based on the TimeZoneName option.
+ ///
+ private string GetFormattedTimeZoneName(DateTime dateTime)
{
- if (TimeZone != null)
+ if (string.Equals(TimeZoneName, "long", StringComparison.Ordinal))
{
- if (string.Equals(TimeZone, "UTC", StringComparison.OrdinalIgnoreCase))
- {
- return longName ? "Coordinated Universal Time" : "UTC";
- }
- // For other timezones, use the ID or a short form
- var parts = TimeZone.Split('/');
- return longName ? TimeZone : parts[parts.Length - 1];
+ return GetTimeZoneDisplayName(dateTime, longName: true, generic: false);
+ }
+ if (string.Equals(TimeZoneName, "longGeneric", StringComparison.Ordinal))
+ {
+ return GetTimeZoneDisplayName(dateTime, longName: true, generic: true);
+ }
+ if (string.Equals(TimeZoneName, "short", StringComparison.Ordinal))
+ {
+ return GetTimeZoneDisplayName(dateTime, longName: false, generic: false);
+ }
+ if (string.Equals(TimeZoneName, "shortGeneric", StringComparison.Ordinal))
+ {
+ return GetTimeZoneDisplayName(dateTime, longName: false, generic: true);
+ }
+ if (string.Equals(TimeZoneName, "longOffset", StringComparison.Ordinal))
+ {
+ return "GMT" + dateTime.ToString("zzz", CultureInfo);
+ }
+ if (string.Equals(TimeZoneName, "shortOffset", StringComparison.Ordinal))
+ {
+ return "GMT" + dateTime.ToString("zzz", CultureInfo);
}
- return longName ? TimeZoneInfo.Local.DisplayName : TimeZoneInfo.Local.Id;
+ return GetTimeZoneDisplayName(dateTime, longName: false, generic: false);
}
private void FormatComponentsToParts(DateTime dateTime, List result, int? originalYear = null)
@@ -1443,21 +1613,17 @@ private void FormatComponentsToParts(DateTime dateTime, List resul
}
}
- // Hour
+ // Hour - use pre-computed value to handle all hour cycles (h11/h12/h23/h24)
+ bool hourUse12Hour = false;
if (Hour != null)
{
if (result.Count > 0)
{
result.Add(new DateTimePart("literal", hasDate ? ", " : ""));
}
- var use12Hour = string.Equals(GetHourFormat(), "h12", StringComparison.Ordinal);
- var format = Hour switch
- {
- "numeric" => use12Hour ? "%h" : "%H", // Use % for single character
- "2-digit" => use12Hour ? "hh" : "HH",
- _ => use12Hour ? "%h" : "%H"
- };
- result.Add(new DateTimePart("hour", dateTime.ToString(format, CultureInfo)));
+ ComputeHourValue(dateTime.Hour, out var hourStr, out var use12Hr);
+ hourUse12Hour = use12Hr;
+ result.Add(new DateTimePart("hour", hourStr));
hasTime = true;
}
@@ -1508,7 +1674,7 @@ private void FormatComponentsToParts(DateTime dateTime, List resul
}
result.Add(new DateTimePart("dayPeriod", GetExtendedDayPeriod(dateTime.Hour)));
}
- else if (Hour != null && string.Equals(GetHourFormat(), "h12", StringComparison.Ordinal))
+ else if (Hour != null && hourUse12Hour)
{
result.Add(new DateTimePart("literal", " "));
result.Add(new DateTimePart("dayPeriod", dateTime.ToString("tt", CultureInfo)));
@@ -1518,7 +1684,7 @@ private void FormatComponentsToParts(DateTime dateTime, List resul
if (TimeZoneName != null)
{
result.Add(new DateTimePart("literal", " "));
- result.Add(new DateTimePart("timeZoneName", dateTime.ToString("zzz", CultureInfo)));
+ result.Add(new DateTimePart("timeZoneName", GetFormattedTimeZoneName(dateTime)));
}
// If no parts were added, use default format
diff --git a/Jint/Native/Temporal/DefaultTimeZoneProvider.cs b/Jint/Native/Temporal/DefaultTimeZoneProvider.cs
index 0a0fb15b9f..995b69c1aa 100644
--- a/Jint/Native/Temporal/DefaultTimeZoneProvider.cs
+++ b/Jint/Native/Temporal/DefaultTimeZoneProvider.cs
@@ -59,6 +59,14 @@ private static bool IsWindowsPlatform()
["Europe/Dublin"] = "GMT Standard Time",
["Europe/Paris"] = "Romance Standard Time",
["CET"] = "Romance Standard Time", // IANA backward compatibility link
+ ["EET"] = "FLE Standard Time", // Eastern European Time (UTC+2)
+ ["MET"] = "Romance Standard Time", // Middle European Time (UTC+1)
+ ["WET"] = "GMT Standard Time", // Western European Time (UTC+0)
+ ["America/Ciudad_Juarez"] = "US Mountain Standard Time", // Added in TZDB 2022g
+ ["Antarctica/Troll"] = "W. Europe Standard Time", // UTC+0/+2 (approximation)
+ ["Antarctica/Vostok"] = "Qyzylorda Standard Time", // UTC+5 (since Dec 2023)
+ ["Asia/Urumqi"] = "Central Asia Standard Time", // UTC+6
+ ["Asia/Kashgar"] = "Central Asia Standard Time", // Link to Asia/Urumqi
["Europe/Berlin"] = "W. Europe Standard Time",
["Europe/Rome"] = "W. Europe Standard Time",
["Europe/Madrid"] = "Romance Standard Time",
@@ -112,6 +120,10 @@ private static bool IsWindowsPlatform()
["GMT"] = "UTC",
};
+ // Case-insensitive lookup mapping IANA IDs to their canonical casing.
+ // Built from IanaToWindows keys at static init time.
+ private static readonly Dictionary IanaCanonicalCasing = BuildCanonicalCasingDict();
+
///
/// Singleton instance of the default provider.
///
@@ -140,13 +152,16 @@ public long GetOffsetNanosecondsFor(string timeZoneId, BigInteger epochNanosecon
throw new ArgumentException($"Unknown time zone: {timeZoneId}", nameof(timeZoneId));
}
- var epochTicks = (long) (epochNanoseconds / NanosecondsPerTick) + UnixEpochTicks;
+ var bigTicks = epochNanoseconds / NanosecondsPerTick + UnixEpochTicks;
- // Clamp to valid .NET DateTime range
- if (epochTicks < DateTime.MinValue.Ticks)
+ // Clamp to valid .NET DateTime range (using BigInteger comparison to avoid overflow)
+ long epochTicks;
+ if (bigTicks < DateTime.MinValue.Ticks)
epochTicks = DateTime.MinValue.Ticks;
- if (epochTicks > DateTime.MaxValue.Ticks)
+ else if (bigTicks > DateTime.MaxValue.Ticks)
epochTicks = DateTime.MaxValue.Ticks;
+ else
+ epochTicks = (long) bigTicks;
var utcDateTime = new DateTime(epochTicks, DateTimeKind.Utc);
var offset = tz.GetUtcOffset(utcDateTime);
@@ -216,8 +231,8 @@ public BigInteger[] GetPossibleInstantsFor(
year, month, day, hour, minute, second, millisecond, microsecond, nanosecond, offsets[i]);
}
- // Sort by offset (earlier offset = later instant)
- System.Array.Sort(results, (a, b) => b.CompareTo(a));
+ // Sort ascending (earliest epoch nanosecond first) per spec
+ System.Array.Sort(results);
return results;
}
@@ -257,13 +272,13 @@ public BigInteger[] GetPossibleInstantsFor(
return null;
}
- var epochTicks = (long) (epochNanoseconds / NanosecondsPerTick) + UnixEpochTicks;
- if (epochTicks < DateTime.MinValue.Ticks || epochTicks > DateTime.MaxValue.Ticks)
+ var bigTicksNext = epochNanoseconds / NanosecondsPerTick + UnixEpochTicks;
+ if (bigTicksNext < DateTime.MinValue.Ticks || bigTicksNext > DateTime.MaxValue.Ticks)
{
return null;
}
- var currentUtc = new DateTime(epochTicks, DateTimeKind.Utc);
+ var currentUtc = new DateTime((long) bigTicksNext, DateTimeKind.Utc);
// Find the next transition - this is an approximation
foreach (var rule in adjustmentRules)
@@ -325,13 +340,13 @@ public BigInteger[] GetPossibleInstantsFor(
return null;
}
- var epochTicks = (long) (epochNanoseconds / NanosecondsPerTick) + UnixEpochTicks;
- if (epochTicks < DateTime.MinValue.Ticks || epochTicks > DateTime.MaxValue.Ticks)
+ var bigTicksPrev = epochNanoseconds / NanosecondsPerTick + UnixEpochTicks;
+ if (bigTicksPrev < DateTime.MinValue.Ticks || bigTicksPrev > DateTime.MaxValue.Ticks)
{
return null;
}
- var currentUtc = new DateTime(epochTicks, DateTimeKind.Utc);
+ var currentUtc = new DateTime((long) bigTicksPrev, DateTimeKind.Utc);
BigInteger? lastTransition = null;
foreach (var rule in adjustmentRules)
@@ -424,15 +439,62 @@ public bool IsValidTimeZone(string timeZoneId)
if (string.IsNullOrEmpty(timeZoneId))
return null;
- // Handle UTC variants
- if (timeZoneId.Equals("UTC", StringComparison.OrdinalIgnoreCase) ||
- timeZoneId.Equals("Etc/UTC", StringComparison.OrdinalIgnoreCase) ||
- timeZoneId.Equals("Etc/GMT", StringComparison.OrdinalIgnoreCase) ||
- timeZoneId.Equals("GMT", StringComparison.OrdinalIgnoreCase))
+ // Handle UTC variants - preserve the original identifier per spec
+ // TimeZoneEquals handles comparing different UTC names
+ if (timeZoneId.Equals("UTC", StringComparison.OrdinalIgnoreCase))
{
return "UTC";
}
+ if (timeZoneId.Equals("Etc/UTC", StringComparison.OrdinalIgnoreCase))
+ {
+ return "Etc/UTC";
+ }
+
+ if (timeZoneId.Equals("Etc/GMT", StringComparison.OrdinalIgnoreCase))
+ {
+ return "Etc/GMT";
+ }
+
+ if (timeZoneId.Equals("GMT", StringComparison.OrdinalIgnoreCase))
+ {
+ return "GMT";
+ }
+
+ // Handle Etc/GMT+N and Etc/GMT-N timezone identifiers (case-insensitive)
+ // These are IANA names, not offset strings. Valid: Etc/GMT-0..Etc/GMT-14, Etc/GMT+0..Etc/GMT+12
+ // Handle Etc/GMT0 (IANA Link name, no sign)
+ if (timeZoneId.Equals("Etc/GMT0", StringComparison.OrdinalIgnoreCase))
+ {
+ return "Etc/GMT0";
+ }
+
+ // Invalid: zero-padded like Etc/GMT-01, Etc/GMT+00, out of range like Etc/GMT-24
+ if (timeZoneId.StartsWith("Etc/GMT", StringComparison.OrdinalIgnoreCase) && timeZoneId.Length > 7)
+ {
+ var suffix = timeZoneId.Substring(7);
+ if ((suffix[0] == '+' || suffix[0] == '-') && suffix.Length >= 2 && suffix.Length <= 3)
+ {
+ // Reject zero-padded: if 2 digits and first is 0 (e.g., +01, -09, +00)
+ if (suffix.Length == 3 && suffix[1] == '0')
+ {
+ return null;
+ }
+
+ if (int.TryParse(suffix.AsSpan(1), System.Globalization.NumberStyles.Integer,
+ System.Globalization.CultureInfo.InvariantCulture, out var offset))
+ {
+ var maxOffset = suffix[0] == '-' ? 14 : 12;
+ if (offset >= 0 && offset <= maxOffset)
+ {
+ return $"Etc/GMT{suffix}";
+ }
+ }
+ }
+
+ return null;
+ }
+
// Handle offset strings
// Valid formats: +HH (3 chars), +HH:MM (6 chars), or +HHMM (5 chars) - no seconds allowed
if (timeZoneId.Length >= 3 && (timeZoneId[0] == '+' || timeZoneId[0] == '-'))
@@ -490,10 +552,10 @@ public bool IsValidTimeZone(string timeZoneId)
}
// On Windows, try to find IANA ID
- // First check if input was already IANA
- if (IanaToWindows.ContainsKey(timeZoneId))
+ // First check if input was already IANA (case-insensitive match)
+ if (IanaCanonicalCasing.TryGetValue(timeZoneId, out var canonicalIana))
{
- return timeZoneId;
+ return canonicalIana;
}
// Try reverse lookup
@@ -557,6 +619,63 @@ public string GetDefaultTimeZone()
return local.Id;
}
+ ///
+ /// Gets the primary IANA identifier for a timezone, resolving aliases.
+ /// E.g., "Asia/Calcutta" → "Asia/Kolkata", "America/Atka" → "America/Adak".
+ ///
+ public string? GetPrimaryTimeZoneIdentifier(string timeZoneId)
+ {
+ if (string.IsNullOrEmpty(timeZoneId))
+ {
+ return null;
+ }
+
+ // UTC variants all map to "UTC"
+ if (timeZoneId.Equals("UTC", StringComparison.OrdinalIgnoreCase) ||
+ timeZoneId.Equals("Etc/UTC", StringComparison.OrdinalIgnoreCase) ||
+ timeZoneId.Equals("Etc/GMT", StringComparison.OrdinalIgnoreCase) ||
+ timeZoneId.Equals("Etc/UCT", StringComparison.OrdinalIgnoreCase) ||
+ timeZoneId.Equals("Etc/GMT0", StringComparison.OrdinalIgnoreCase) ||
+ timeZoneId.Equals("Etc/GMT+0", StringComparison.OrdinalIgnoreCase) ||
+ timeZoneId.Equals("Etc/GMT-0", StringComparison.OrdinalIgnoreCase) ||
+ timeZoneId.Equals("Etc/Greenwich", StringComparison.OrdinalIgnoreCase) ||
+ timeZoneId.Equals("Etc/Universal", StringComparison.OrdinalIgnoreCase) ||
+ timeZoneId.Equals("Etc/Zulu", StringComparison.OrdinalIgnoreCase) ||
+ timeZoneId.Equals("UCT", StringComparison.OrdinalIgnoreCase) ||
+ timeZoneId.Equals("GMT", StringComparison.OrdinalIgnoreCase) ||
+ timeZoneId.Equals("GMT+0", StringComparison.OrdinalIgnoreCase) ||
+ timeZoneId.Equals("GMT-0", StringComparison.OrdinalIgnoreCase) ||
+ timeZoneId.Equals("GMT0", StringComparison.OrdinalIgnoreCase) ||
+ timeZoneId.Equals("Greenwich", StringComparison.OrdinalIgnoreCase) ||
+ timeZoneId.Equals("Universal", StringComparison.OrdinalIgnoreCase) ||
+ timeZoneId.Equals("Zulu", StringComparison.OrdinalIgnoreCase))
+ {
+ return "UTC";
+ }
+
+ // Offset strings are their own primary identifier
+ if (timeZoneId.Length >= 3 && (timeZoneId[0] == '+' || timeZoneId[0] == '-'))
+ {
+ return CanonicalizeTimeZone(timeZoneId);
+ }
+
+#if NET6_0_OR_GREATER
+ // Use .NET's IANA→Windows mapping to find the primary identifier
+ // Both "Asia/Calcutta" and "Asia/Kolkata" map to "India Standard Time"
+ // Then convert back to get the primary IANA name
+ if (TimeZoneInfo.TryConvertIanaIdToWindowsId(timeZoneId, out var windowsId))
+ {
+ if (TimeZoneInfo.TryConvertWindowsIdToIanaId(windowsId, out var primaryId))
+ {
+ return primaryId;
+ }
+ }
+#endif
+
+ // Fallback: resolve and return canonicalized form
+ return CanonicalizeTimeZone(timeZoneId);
+ }
+
private TimeZoneInfo? ResolveTimeZone(string timeZoneId)
{
return _timeZoneCache.GetOrAdd(timeZoneId, id =>
@@ -590,6 +709,13 @@ public string GetDefaultTimeZone()
return TimeZoneInfo.CreateCustomTimeZone(id, roundedOffset, id, id);
}
+ // On Windows, .NET resolves non-IANA identifiers (Java abbreviations like ACT, BST, etc.)
+ // Reject identifiers that don't follow IANA naming conventions
+ if (!IsValidIanaIdentifier(id))
+ {
+ return null;
+ }
+
// Try direct lookup first
try
{
@@ -600,7 +726,7 @@ public string GetDefaultTimeZone()
// Continue to mapping
}
- // Try IANA to Windows mapping
+ // Try IANA to Windows mapping (case-insensitive via dictionary comparer)
if (IsWindowsPlatform() &&
IanaToWindows.TryGetValue(id, out var windowsId))
{
@@ -614,6 +740,17 @@ public string GetDefaultTimeZone()
}
}
+#if NET6_0_OR_GREATER
+ // Case-insensitive fallback: try all system timezones
+ foreach (var systemTz in TimeZoneInfo.GetSystemTimeZones())
+ {
+ if (string.Equals(systemTz.Id, id, StringComparison.OrdinalIgnoreCase))
+ {
+ return systemTz;
+ }
+ }
+#endif
+
return null;
});
}
@@ -709,12 +846,64 @@ private static BigInteger DateTimeToEpochNanoseconds(
return totalNs;
}
+ private static Dictionary BuildCanonicalCasingDict()
+ {
+ var dict = new Dictionary(IanaToWindows.Count, StringComparer.OrdinalIgnoreCase);
+ foreach (var key in IanaToWindows.Keys)
+ {
+ dict[key] = key;
+ }
+
+#if NET6_0_OR_GREATER
+ // Also add system timezone IDs (on .NET 6+ these include IANA IDs)
+ foreach (var tz in TimeZoneInfo.GetSystemTimeZones())
+ {
+ if (!dict.ContainsKey(tz.Id))
+ {
+ dict[tz.Id] = tz.Id;
+ }
+ }
+#endif
+
+ return dict;
+ }
+
private static long DaysSinceEpoch(int year, int month, int day)
{
// Use the same algorithm as TemporalHelpers.IsoDateToDays for consistency
return TemporalHelpers.IsoDateToDays(year, month, day);
}
+ ///
+ /// Validates that a timezone identifier follows IANA naming conventions.
+ /// Rejects Java/ICU-specific abbreviations like ACT, BST, JST that .NET may resolve.
+ ///
+ private static bool IsValidIanaIdentifier(string id)
+ {
+ // IDs containing '/' are Area/Location format (always valid IANA)
+ if (id.Contains('/'))
+ {
+ return true;
+ }
+
+ // IDs containing digits with letters (like EST5EDT, CST6CDT) are IANA compound TZ names
+ // Known single-word IANA identifiers (Zone and Link names from TZDB)
+ return s_validSingleWordIanaIds.Contains(id);
+ }
+
+ private static readonly HashSet s_validSingleWordIanaIds = new(StringComparer.OrdinalIgnoreCase)
+ {
+ "CET", "CST6CDT", "EET", "EST", "EST5EDT", "HST",
+ "MET", "MST", "MST7MDT", "PST8PDT", "WET",
+ "GMT", "GMT0", "GMT+0", "GMT-0", "UTC", "UCT",
+ "Universal", "Greenwich", "Zulu", "W-SU",
+ "Cuba", "Egypt", "Eire", "GB", "GB-Eire",
+ "Hongkong", "Iceland", "Iran", "Israel", "Jamaica",
+ "Japan", "Kwajalein", "Libya", "NZ", "NZ-CHAT",
+ "Navajo", "PRC", "Poland", "Portugal", "ROC", "ROK",
+ "Singapore", "Turkey",
+ };
+
private static DateTime GetTransitionDateTime(TimeZoneInfo.TransitionTime transition, int year)
{
if (transition.IsFixedDateRule)
diff --git a/Jint/Native/Temporal/Duration/DurationPrototype.cs b/Jint/Native/Temporal/Duration/DurationPrototype.cs
index e6fbf909ec..aac61041ef 100644
--- a/Jint/Native/Temporal/Duration/DurationPrototype.cs
+++ b/Jint/Native/Temporal/Duration/DurationPrototype.cs
@@ -1562,12 +1562,30 @@ private JsString ToJSON(JsValue thisObject, JsCallArguments arguments)
///
/// https://tc39.es/proposal-temporal/#sec-temporal.duration.prototype.tolocalestring
///
- private JsString ToLocaleString(JsValue thisObject, JsCallArguments arguments)
+ private JsValue ToLocaleString(JsValue thisObject, JsCallArguments arguments)
{
var duration = ValidateDuration(thisObject);
- // For now, just return the ISO string representation
- // Full Intl.DurationFormat integration would require more work
- return new JsString(TemporalHelpers.FormatDuration(duration.DurationRecord));
+ var locales = arguments.At(0);
+ var options = arguments.At(1);
+
+ // Per spec: new Intl.DurationFormat(locales, options).format(this)
+ var durationFormat = (Intl.JsDurationFormat) _realm.Intrinsics.DurationFormat.Construct([locales, options], Undefined);
+ // Convert Temporal DurationRecord to Intl DurationRecord
+ var dr = duration.DurationRecord;
+ var intlRecord = new Intl.JsDurationFormat.DurationRecord
+ {
+ Years = dr.Years,
+ Months = dr.Months,
+ Weeks = dr.Weeks,
+ Days = dr.Days,
+ Hours = dr.Hours,
+ Minutes = dr.Minutes,
+ Seconds = dr.Seconds,
+ Milliseconds = dr.Milliseconds,
+ Microseconds = dr.Microseconds,
+ Nanoseconds = dr.Nanoseconds,
+ };
+ return durationFormat.Format(intlRecord);
}
///
diff --git a/Jint/Native/Temporal/ITimeZoneProvider.cs b/Jint/Native/Temporal/ITimeZoneProvider.cs
index b85cede530..da3e3f06c8 100644
--- a/Jint/Native/Temporal/ITimeZoneProvider.cs
+++ b/Jint/Native/Temporal/ITimeZoneProvider.cs
@@ -80,4 +80,11 @@ BigInteger[] GetPossibleInstantsFor(
/// Gets the system's default time zone identifier.
///
string GetDefaultTimeZone();
+
+ ///
+ /// Resolves a time zone alias to its primary IANA identifier.
+ /// E.g., "Asia/Calcutta" → "Asia/Kolkata", "US/Eastern" → "America/New_York".
+ /// Returns null if the time zone ID is not recognized.
+ ///
+ string? GetPrimaryTimeZoneIdentifier(string timeZoneId);
}
diff --git a/Jint/Native/Temporal/Instant/InstantPrototype.cs b/Jint/Native/Temporal/Instant/InstantPrototype.cs
index a2fcbebb73..f9344e0573 100644
--- a/Jint/Native/Temporal/Instant/InstantPrototype.cs
+++ b/Jint/Native/Temporal/Instant/InstantPrototype.cs
@@ -787,17 +787,8 @@ private static void FormatIsoYear(ref ValueStringBuilder sb, int year)
private static void FormatOffset(ref ValueStringBuilder sb, long offsetNs)
{
- var sign = offsetNs >= 0 ? '+' : '-';
- offsetNs = System.Math.Abs(offsetNs);
-
- var hours = offsetNs / 3_600_000_000_000L;
- offsetNs %= 3_600_000_000_000L;
- var minutes = offsetNs / 60_000_000_000L;
-
- sb.Append(sign);
- sb.Append(hours.ToString("D2", CultureInfo.InvariantCulture));
- sb.Append(':');
- sb.Append(minutes.ToString("D2", CultureInfo.InvariantCulture));
+ // Use FormatDateTimeUTCOffsetRounded: round to nearest minute, format as ±HH:MM
+ sb.Append(TemporalHelpers.FormatOffsetRounded(offsetNs));
}
@@ -811,13 +802,26 @@ private JsString ToJSON(JsValue thisObject, JsCallArguments arguments)
}
///
- /// https://tc39.es/proposal-temporal/#sec-temporal.instant.prototype.tolocalestring
+ /// https://tc39.es/proposal-temporal/#sup-temporal.instant.prototype.tolocalestring
///
- private JsString ToLocaleString(JsValue thisObject, JsCallArguments arguments)
+ private JsValue ToLocaleString(JsValue thisObject, JsCallArguments arguments)
{
var instant = ValidateInstant(thisObject);
- // For now, just return the ISO string representation
- return new JsString(FormatInstant(instant.EpochNanoseconds, "UTC", -1));
+ var locales = arguments.At(0);
+ var options = arguments.At(1);
+
+ // Per spec: CreateDateTimeFormat with required=~any~, defaults=~all~
+ var dtf = _realm.Intrinsics.DateTimeFormat.CreateDateTimeFormat(
+ locales, options, required: Intl.DateTimeRequired.Any, defaults: Intl.DateTimeDefaults.All);
+
+ // Convert epoch nanoseconds to UTC DateTime (DTF will convert to its timezone)
+ const long nsPerTick = 100;
+ var ticks = (long) (instant.EpochNanoseconds / nsPerTick);
+ var unixEpochTicks = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc).Ticks;
+ var totalTicks = unixEpochTicks + ticks;
+ var dateTime = new DateTime(totalTicks, DateTimeKind.Utc);
+
+ return dtf.Format(dateTime);
}
///
diff --git a/Jint/Native/Temporal/PlainDate/PlainDateConstructor.cs b/Jint/Native/Temporal/PlainDate/PlainDateConstructor.cs
index 9a78d0dfc4..05327598da 100644
--- a/Jint/Native/Temporal/PlainDate/PlainDateConstructor.cs
+++ b/Jint/Native/Temporal/PlainDate/PlainDateConstructor.cs
@@ -89,7 +89,7 @@ private JsPlainDate From(JsValue thisObject, JsCallArguments arguments)
private JsPlainDate ToTemporalDateFromObjectWithOptions(ObjectInstance obj, JsValue options)
{
- // Read and convert properties in alphabetical order per spec: calendar, day, month, monthCode, year
+ // Read and convert properties in alphabetical order per spec: calendar, day, era, eraYear, month, monthCode, year
// Each property must be fully read and converted before moving to the next
// 1. calendar
@@ -111,6 +111,9 @@ private JsPlainDate ToTemporalDateFromObjectWithOptions(ObjectInstance obj, JsVa
var day = TemporalHelpers.ToPositiveIntegerWithTruncation(_realm, dayValue);
+ // 2.5. era/eraYear - read for era-supporting calendars (alphabetically between day and month)
+ var eraYear = TemporalHelpers.ReadEraFields(_realm, obj, calendar);
+
// 3. month - read and convert immediately
var monthValue = obj.Get("month");
int month = 0;
@@ -167,20 +170,31 @@ private JsPlainDate ToTemporalDateFromObjectWithOptions(ObjectInstance obj, JsVa
month = monthFromCode;
}
- // 5. year - read and convert immediately (TYPE validation happens here)
- var yearValue = obj.Get("year");
- if (yearValue.IsUndefined())
+ // 5. year - use eraYear if computed, otherwise read from property
+ int year;
+ if (eraYear.HasValue)
{
- Throw.TypeError(_realm, "Missing required property: year");
+ // Year was already computed from era/eraYear
+ year = eraYear.Value;
+ // Still read the year property for observable side effects, but don't require it
+ obj.Get("year");
}
+ else
+ {
+ var yearValue = obj.Get("year");
+ if (yearValue.IsUndefined())
+ {
+ Throw.TypeError(_realm, "Missing required property: year");
+ }
- var year = TemporalHelpers.ToIntegerWithTruncationAsInt(_realm, yearValue);
+ year = TemporalHelpers.ToIntegerWithTruncationAsInt(_realm, yearValue);
+ }
// 6. Read options AFTER all fields (but BEFORE algorithmic validation)
var overflow = TemporalHelpers.GetOverflowOption(_realm, options);
- // NOW validate monthCode suitability (ISO calendar checks) - after year type validation AND options reading
- if (monthCodeStr is not null)
+ // Validate monthCode suitability - only for ISO/Gregorian calendars
+ if (monthCodeStr is not null && TemporalHelpers.IsGregorianBasedCalendar(calendar))
{
// For ISO 8601 calendar: validate monthCode is valid (01-12, no leap months)
if (monthCodeStr.Length == 4 && monthCodeStr[3] == 'L')
@@ -199,7 +213,7 @@ private JsPlainDate ToTemporalDateFromObjectWithOptions(ObjectInstance obj, JsVa
Throw.TypeError(_realm, "month or monthCode is required");
}
- var date = TemporalHelpers.RegulateIsoDate(year, month, day, overflow);
+ var date = TemporalHelpers.CalendarDateToISO(_realm, calendar, year, month, day, overflow);
if (date is null)
{
Throw.RangeError(_realm, "Invalid date");
@@ -344,6 +358,9 @@ private JsPlainDate ToTemporalDateFromFields(ObjectInstance obj, string overflow
var day = TemporalHelpers.ToPositiveIntegerWithTruncation(_realm, dayValue);
+ // 2.5. era/eraYear - read for era-supporting calendars
+ var eraYear = TemporalHelpers.ReadEraFields(_realm, obj, calendar);
+
// 3. month - read and convert immediately
var monthValue = obj.Get("month");
int month = 0;
@@ -400,14 +417,23 @@ private JsPlainDate ToTemporalDateFromFields(ObjectInstance obj, string overflow
}
}
- // 5. year - read and convert immediately
- var yearValue = obj.Get("year");
- if (yearValue.IsUndefined())
+ // 5. year - use eraYear if computed, otherwise read from property
+ int year;
+ if (eraYear.HasValue)
{
- Throw.TypeError(_realm, "Missing required property: year");
+ year = eraYear.Value;
+ obj.Get("year");
}
+ else
+ {
+ var yearValue = obj.Get("year");
+ if (yearValue.IsUndefined())
+ {
+ Throw.TypeError(_realm, "Missing required property: year");
+ }
- var year = TemporalHelpers.ToIntegerWithTruncationAsInt(_realm, yearValue);
+ year = TemporalHelpers.ToIntegerWithTruncationAsInt(_realm, yearValue);
+ }
// Validate: both month and monthCode provided - they must match
if (month != 0 && monthFromCode.HasValue && month != monthFromCode.Value)
@@ -426,7 +452,7 @@ private JsPlainDate ToTemporalDateFromFields(ObjectInstance obj, string overflow
Throw.TypeError(_realm, "month or monthCode is required");
}
- var date = TemporalHelpers.RegulateIsoDate(year, month, day, overflow);
+ var date = TemporalHelpers.CalendarDateToISO(_realm, calendar, year, month, day, overflow);
if (date is null)
{
Throw.RangeError(_realm, "Invalid date");
diff --git a/Jint/Native/Temporal/PlainDate/PlainDatePrototype.cs b/Jint/Native/Temporal/PlainDate/PlainDatePrototype.cs
index 39f7d4029a..50bc54b850 100644
--- a/Jint/Native/Temporal/PlainDate/PlainDatePrototype.cs
+++ b/Jint/Native/Temporal/PlainDate/PlainDatePrototype.cs
@@ -1,4 +1,5 @@
using System.Globalization;
+using System.Numerics;
using System.Text;
using Jint.Native.Object;
using Jint.Native.Symbol;
@@ -86,24 +87,39 @@ private JsPlainDate ValidatePlainDate(JsValue thisObject)
private JsValue GetEra(JsValue thisObject, JsCallArguments arguments)
{
- ValidatePlainDate(thisObject);
- return Undefined;
+ var pd = ValidatePlainDate(thisObject);
+ var era = TemporalHelpers.CalendarEra(pd.Calendar, pd.IsoDate.Year);
+ return era is not null ? new JsString(era) : Undefined;
}
private JsValue GetEraYear(JsValue thisObject, JsCallArguments arguments)
{
- ValidatePlainDate(thisObject);
- return Undefined;
+ var pd = ValidatePlainDate(thisObject);
+ var eraYear = TemporalHelpers.CalendarEraYear(pd.Calendar, pd.IsoDate.Year);
+ return eraYear.HasValue ? JsNumber.Create(eraYear.Value) : Undefined;
}
- private JsNumber GetYear(JsValue thisObject, JsCallArguments arguments) => JsNumber.Create(ValidatePlainDate(thisObject).IsoDate.Year);
+ private JsNumber GetYear(JsValue thisObject, JsCallArguments arguments)
+ {
+ var pd = ValidatePlainDate(thisObject);
+ return JsNumber.Create(TemporalHelpers.CalendarYear(pd.Calendar, pd.IsoDate.Year));
+ }
private JsNumber GetMonth(JsValue thisObject, JsCallArguments arguments) => JsNumber.Create(ValidatePlainDate(thisObject).IsoDate.Month);
private JsString GetMonthCode(JsValue thisObject, JsCallArguments arguments) => new JsString($"M{ValidatePlainDate(thisObject).IsoDate.Month:D2}");
private JsNumber GetDay(JsValue thisObject, JsCallArguments arguments) => JsNumber.Create(ValidatePlainDate(thisObject).IsoDate.Day);
private JsNumber GetDayOfWeek(JsValue thisObject, JsCallArguments arguments) => JsNumber.Create(ValidatePlainDate(thisObject).IsoDate.DayOfWeek());
private JsNumber GetDayOfYear(JsValue thisObject, JsCallArguments arguments) => JsNumber.Create(ValidatePlainDate(thisObject).IsoDate.DayOfYear());
- private JsNumber GetWeekOfYear(JsValue thisObject, JsCallArguments arguments) => JsNumber.Create(ValidatePlainDate(thisObject).IsoDate.WeekOfYear());
- private JsNumber GetYearOfWeek(JsValue thisObject, JsCallArguments arguments) => JsNumber.Create(ValidatePlainDate(thisObject).IsoDate.YearOfWeek());
+ private JsValue GetWeekOfYear(JsValue thisObject, JsCallArguments arguments)
+ {
+ var pd = ValidatePlainDate(thisObject);
+ return string.Equals(pd.Calendar, "iso8601", StringComparison.Ordinal) ? JsNumber.Create(pd.IsoDate.WeekOfYear()) : Undefined;
+ }
+
+ private JsValue GetYearOfWeek(JsValue thisObject, JsCallArguments arguments)
+ {
+ var pd = ValidatePlainDate(thisObject);
+ return string.Equals(pd.Calendar, "iso8601", StringComparison.Ordinal) ? JsNumber.Create(pd.IsoDate.YearOfWeek()) : Undefined;
+ }
private JsNumber GetDaysInWeek(JsValue thisObject, JsCallArguments arguments)
{
@@ -279,11 +295,6 @@ private JsPlainDate WithCalendar(JsValue thisObject, JsCallArguments arguments)
var calendarArg = arguments.At(0);
var calendar = ToTemporalCalendarIdentifier(calendarArg);
- if (!string.Equals(calendar, "iso8601", StringComparison.Ordinal))
- {
- Throw.RangeError(_realm, $"Unsupported calendar: {calendar}");
- }
-
return _constructor.Construct(plainDate.IsoDate, calendar);
}
@@ -342,6 +353,13 @@ private JsDuration Until(JsValue thisObject, JsCallArguments arguments)
{
var plainDate = ValidatePlainDate(thisObject);
var other = _constructor.ToTemporalDate(arguments.At(0), "constrain");
+
+ // Step 3: Calendar equality check (before reading options per spec)
+ if (!string.Equals(plainDate.Calendar, other.Calendar, StringComparison.Ordinal))
+ {
+ Throw.RangeError(_realm, "Calendars must match for date difference operations");
+ }
+
var optionsArg = arguments.At(1);
// Date units for PlainDate operations (no time units allowed)
@@ -378,6 +396,13 @@ private JsDuration Since(JsValue thisObject, JsCallArguments arguments)
{
var plainDate = ValidatePlainDate(thisObject);
var other = _constructor.ToTemporalDate(arguments.At(0), "constrain");
+
+ // Step 3: Calendar equality check (before reading options per spec)
+ if (!string.Equals(plainDate.Calendar, other.Calendar, StringComparison.Ordinal))
+ {
+ Throw.RangeError(_realm, "Calendars must match for date difference operations");
+ }
+
var optionsArg = arguments.At(1);
// Date units for PlainDate operations (no time units allowed)
@@ -544,12 +569,28 @@ private JsString ToJSON(JsValue thisObject, JsCallArguments arguments)
}
///
- /// https://tc39.es/proposal-temporal/#sec-temporal.plaindate.prototype.tolocalestring
+ /// https://tc39.es/proposal-temporal/#sup-temporal.plaindate.prototype.tolocalestring
///
- private JsString ToLocaleString(JsValue thisObject, JsCallArguments arguments)
+ private JsValue ToLocaleString(JsValue thisObject, JsCallArguments arguments)
{
var plainDate = ValidatePlainDate(thisObject);
- return new JsString(FormatDate(plainDate.IsoDate, plainDate.Calendar));
+ var locales = arguments.At(0);
+ var options = arguments.At(1);
+
+ // Per spec: CreateDateTimeFormat with required=~date~, defaults=~date~
+ var dtf = _realm.Intrinsics.DateTimeFormat.CreateDateTimeFormat(
+ locales, options, required: Intl.DateTimeRequired.Date, defaults: Intl.DateTimeDefaults.Date);
+
+ // Calendar mismatch check: PlainDate calendar must be iso8601 or match DTF calendar
+ var cal = plainDate.Calendar;
+ if (!string.Equals(cal, "iso8601", StringComparison.Ordinal) &&
+ dtf.Calendar != null && !string.Equals(cal, dtf.Calendar, StringComparison.Ordinal))
+ {
+ Throw.RangeError(_realm, $"Calendar mismatch: PlainDate uses '{cal}' but DateTimeFormat uses '{dtf.Calendar}'");
+ }
+
+ // Use noon time to avoid DST issues, Unspecified kind (plain = no TZ adjustment)
+ return dtf.Format(new DateTime(plainDate.IsoDate.Year, plainDate.IsoDate.Month, plainDate.IsoDate.Day, 12, 0, 0, DateTimeKind.Unspecified), isPlain: true);
}
///
@@ -656,24 +697,22 @@ private JsZonedDateTime ToZonedDateTime(JsValue thisObject, JsCallArguments argu
return null!;
}
- // Convert plainTime to IsoTime (or use midnight)
- IsoTime time;
+ // Per spec step 8-10: If temporalTime is undefined, use GetStartOfDay; otherwise use GetEpochNanosecondsFor
+ BigInteger epochNs;
if (plainTimeValue.IsUndefined())
{
- time = new IsoTime(0, 0, 0, 0, 0, 0);
+ // Step 8: Let epochNs be GetStartOfDay(timeZone, plainDate.[[ISODate]])
+ var provider = _engine.Options.Temporal.TimeZoneProvider;
+ epochNs = TemporalHelpers.GetStartOfDay(_realm, provider, timeZone, plainDate.IsoDate);
}
else
{
+ // Step 10: Use GetEpochNanosecondsFor with compatible disambiguation
var plainTime = _realm.Intrinsics.TemporalPlainTime.ToTemporalTime(plainTimeValue, "constrain");
- time = plainTime.IsoTime;
+ var dateTime = new IsoDateTime(plainDate.IsoDate, plainTime.IsoTime);
+ epochNs = GetEpochFromDateTime(dateTime, timeZone, "compatible");
}
- // Combine date and time
- var dateTime = new IsoDateTime(plainDate.IsoDate, time);
-
- // Get the epoch nanoseconds for this date-time in the given time zone
- var epochNs = GetEpochFromDateTime(dateTime, timeZone, "compatible");
-
if (!InstantConstructor.IsValidEpochNanoseconds(epochNs))
{
Throw.RangeError(_realm, "Resulting ZonedDateTime is outside the valid range");
@@ -685,62 +724,7 @@ private JsZonedDateTime ToZonedDateTime(JsValue thisObject, JsCallArguments argu
private System.Numerics.BigInteger GetEpochFromDateTime(IsoDateTime dateTime, string timeZone, string disambiguation)
{
- var provider = _engine.Options.Temporal.TimeZoneProvider;
- var localNs = IsoDateTimeToNanoseconds(dateTime);
-
- var possibleInstants = provider.GetPossibleInstantsFor(timeZone,
- dateTime.Year, dateTime.Month, dateTime.Day,
- dateTime.Hour, dateTime.Minute, dateTime.Second,
- dateTime.Millisecond, dateTime.Microsecond, dateTime.Nanosecond);
-
- if (possibleInstants.Length == 1)
- {
- return possibleInstants[0];
- }
-
- if (possibleInstants.Length == 0)
- {
- // Gap - use disambiguation
- var before = provider.GetPossibleInstantsFor(timeZone,
- dateTime.Year, dateTime.Month, dateTime.Day,
- 0, 0, 0, 0, 0, 0);
- if (before.Length > 0)
- {
- var instant = before[before.Length - 1];
- var offsetBefore = provider.GetOffsetNanosecondsFor(timeZone, instant);
- var offsetAfter = provider.GetOffsetNanosecondsFor(timeZone, instant + 1);
-
- switch (disambiguation)
- {
- case "compatible":
- case "later":
- return localNs - offsetAfter;
- case "earlier":
- return localNs - offsetBefore;
- case "reject":
- Throw.RangeError(_realm, "No such time exists in this time zone (gap)");
- break;
- }
- }
-
- // Fallback
- return localNs;
- }
-
- // Ambiguous (fold/overlap)
- switch (disambiguation)
- {
- case "compatible":
- case "earlier":
- return possibleInstants[0];
- case "later":
- return possibleInstants[possibleInstants.Length - 1];
- case "reject":
- Throw.RangeError(_realm, "Time is ambiguous in this time zone (fold)");
- break;
- }
-
- return possibleInstants[0];
+ return TemporalHelpers.GetEpochNanosecondsFor(_realm, _engine.Options.Temporal.TimeZoneProvider, timeZone, dateTime, disambiguation);
}
private static System.Numerics.BigInteger IsoDateTimeToNanoseconds(IsoDateTime dateTime)
diff --git a/Jint/Native/Temporal/PlainDateTime/PlainDateTimeConstructor.cs b/Jint/Native/Temporal/PlainDateTime/PlainDateTimeConstructor.cs
index 63bf5a8c80..3e3d18138f 100644
--- a/Jint/Native/Temporal/PlainDateTime/PlainDateTimeConstructor.cs
+++ b/Jint/Native/Temporal/PlainDateTime/PlainDateTimeConstructor.cs
@@ -222,7 +222,7 @@ internal JsPlainDateTime ToTemporalDateTime(JsValue item, string overflow)
Throw.RangeError(_realm, "Invalid date-time string");
}
- return Construct(parsed.DateTime.Value, "iso8601");
+ return Construct(parsed.DateTime.Value, TemporalHelpers.ExtractCalendarIdentifierFromString(str));
}
if (item.IsObject())
@@ -261,6 +261,9 @@ private JsPlainDateTime ToTemporalDateTimeFromFields(ObjectInstance obj, JsValue
var day = TemporalHelpers.ToPositiveIntegerWithTruncation(_realm, dayValue);
+ // 2.5. era/eraYear - read for era-supporting calendars (alphabetically between day and hour)
+ var eraYear = TemporalHelpers.ReadEraFields(_realm, obj, calendar);
+
// 3. hour - read and convert immediately
var hourValue = obj.Get("hour");
var hour = hourValue.IsUndefined() ? 0 : TemporalHelpers.ToIntegerWithTruncationAsInt(_realm, hourValue);
@@ -330,14 +333,23 @@ private JsPlainDateTime ToTemporalDateTimeFromFields(ObjectInstance obj, JsValue
var secondValue = obj.Get("second");
var second = secondValue.IsUndefined() ? 0 : TemporalHelpers.ToIntegerWithTruncationAsInt(_realm, secondValue);
- // 11. year - read and convert immediately (TYPE validation happens here)
- var yearValue = obj.Get("year");
- if (yearValue.IsUndefined())
+ // 11. year - use eraYear if computed, otherwise read from property
+ int year;
+ if (eraYear.HasValue)
{
- Throw.TypeError(_realm, "Missing required property: year");
+ year = eraYear.Value;
+ obj.Get("year");
}
+ else
+ {
+ var yearValue = obj.Get("year");
+ if (yearValue.IsUndefined())
+ {
+ Throw.TypeError(_realm, "Missing required property: year");
+ }
- var year = TemporalHelpers.ToIntegerWithTruncationAsInt(_realm, yearValue);
+ year = TemporalHelpers.ToIntegerWithTruncationAsInt(_realm, yearValue);
+ }
// 12. Read options.overflow AFTER all fields (but BEFORE algorithmic validation)
var overflow = optionsValue.IsUndefined() ? "constrain" : TemporalHelpers.GetOverflowOption(_realm, optionsValue);
@@ -376,13 +388,13 @@ private JsPlainDateTime ToTemporalDateTimeFromFields(ObjectInstance obj, JsValue
Throw.TypeError(_realm, "month or monthCode is required");
}
- // Validate year range only (month/day will be validated by RegulateIsoDate based on overflow)
- if (year < -271821 || year > 275760)
+ // Validate year range for ISO calendars only (non-ISO will validate during conversion)
+ if (TemporalHelpers.IsGregorianBasedCalendar(calendar) && (year < -271821 || year > 275760))
{
Throw.RangeError(_realm, "Date is outside valid range");
}
- var date = TemporalHelpers.RegulateIsoDate(year, month, day, overflow);
+ var date = TemporalHelpers.CalendarDateToISO(_realm, calendar, year, month, day, overflow);
if (date is null)
{
Throw.RangeError(_realm, "Invalid date");
diff --git a/Jint/Native/Temporal/PlainDateTime/PlainDateTimePrototype.cs b/Jint/Native/Temporal/PlainDateTime/PlainDateTimePrototype.cs
index c36b9008bb..0a30f467aa 100644
--- a/Jint/Native/Temporal/PlainDateTime/PlainDateTimePrototype.cs
+++ b/Jint/Native/Temporal/PlainDateTime/PlainDateTimePrototype.cs
@@ -95,17 +95,23 @@ private JsPlainDateTime ValidatePlainDateTime(JsValue thisObject)
private JsValue GetEra(JsValue thisObject, JsCallArguments arguments)
{
- ValidatePlainDateTime(thisObject);
- return Undefined;
+ var pdt = ValidatePlainDateTime(thisObject);
+ var era = TemporalHelpers.CalendarEra(pdt.Calendar, pdt.IsoDateTime.Year);
+ return era is not null ? new JsString(era) : Undefined;
}
private JsValue GetEraYear(JsValue thisObject, JsCallArguments arguments)
{
- ValidatePlainDateTime(thisObject);
- return Undefined;
+ var pdt = ValidatePlainDateTime(thisObject);
+ var eraYear = TemporalHelpers.CalendarEraYear(pdt.Calendar, pdt.IsoDateTime.Year);
+ return eraYear.HasValue ? JsNumber.Create(eraYear.Value) : Undefined;
}
- private JsNumber GetYear(JsValue thisObject, JsCallArguments arguments) => JsNumber.Create(ValidatePlainDateTime(thisObject).IsoDateTime.Year);
+ private JsNumber GetYear(JsValue thisObject, JsCallArguments arguments)
+ {
+ var pdt = ValidatePlainDateTime(thisObject);
+ return JsNumber.Create(TemporalHelpers.CalendarYear(pdt.Calendar, pdt.IsoDateTime.Year));
+ }
private JsNumber GetMonth(JsValue thisObject, JsCallArguments arguments) => JsNumber.Create(ValidatePlainDateTime(thisObject).IsoDateTime.Month);
private JsString GetMonthCode(JsValue thisObject, JsCallArguments arguments) => new JsString($"M{ValidatePlainDateTime(thisObject).IsoDateTime.Month:D2}");
private JsNumber GetDay(JsValue thisObject, JsCallArguments arguments) => JsNumber.Create(ValidatePlainDateTime(thisObject).IsoDateTime.Day);
@@ -117,8 +123,17 @@ private JsValue GetEraYear(JsValue thisObject, JsCallArguments arguments)
private JsNumber GetNanosecond(JsValue thisObject, JsCallArguments arguments) => JsNumber.Create(ValidatePlainDateTime(thisObject).IsoDateTime.Nanosecond);
private JsNumber GetDayOfWeek(JsValue thisObject, JsCallArguments arguments) => JsNumber.Create(ValidatePlainDateTime(thisObject).IsoDateTime.Date.DayOfWeek());
private JsNumber GetDayOfYear(JsValue thisObject, JsCallArguments arguments) => JsNumber.Create(ValidatePlainDateTime(thisObject).IsoDateTime.Date.DayOfYear());
- private JsNumber GetWeekOfYear(JsValue thisObject, JsCallArguments arguments) => JsNumber.Create(ValidatePlainDateTime(thisObject).IsoDateTime.Date.WeekOfYear());
- private JsNumber GetYearOfWeek(JsValue thisObject, JsCallArguments arguments) => JsNumber.Create(ValidatePlainDateTime(thisObject).IsoDateTime.Date.YearOfWeek());
+ private JsValue GetWeekOfYear(JsValue thisObject, JsCallArguments arguments)
+ {
+ var pdt = ValidatePlainDateTime(thisObject);
+ return string.Equals(pdt.Calendar, "iso8601", StringComparison.Ordinal) ? JsNumber.Create(pdt.IsoDateTime.Date.WeekOfYear()) : Undefined;
+ }
+
+ private JsValue GetYearOfWeek(JsValue thisObject, JsCallArguments arguments)
+ {
+ var pdt = ValidatePlainDateTime(thisObject);
+ return string.Equals(pdt.Calendar, "iso8601", StringComparison.Ordinal) ? JsNumber.Create(pdt.IsoDateTime.Date.YearOfWeek()) : Undefined;
+ }
private JsNumber GetDaysInWeek(JsValue thisObject, JsCallArguments arguments)
{
@@ -369,11 +384,6 @@ private JsPlainDateTime WithCalendar(JsValue thisObject, JsCallArguments argumen
// Use ToTemporalCalendarIdentifier for spec-compliant conversion (handles Temporal objects)
var calendar = TemporalHelpers.ToTemporalCalendarIdentifier(_realm, calendarArg);
- if (!string.Equals(calendar, "iso8601", StringComparison.Ordinal))
- {
- Throw.RangeError(_realm, $"Unsupported calendar: {calendar}");
- }
-
return _constructor.Construct(plainDateTime.IsoDateTime, calendar);
}
@@ -451,6 +461,13 @@ private JsDuration Until(JsValue thisObject, JsCallArguments arguments)
{
var plainDateTime = ValidatePlainDateTime(thisObject);
var other = _constructor.ToTemporalDateTime(arguments.At(0), "constrain");
+
+ // Calendar equality check (before reading options per spec)
+ if (!string.Equals(plainDateTime.Calendar, other.Calendar, StringComparison.Ordinal))
+ {
+ Throw.RangeError(_realm, "Calendars must match for date-time difference operations");
+ }
+
var optionsArg = arguments.At(1);
// All temporal units allowed for PlainDateTime operations
@@ -485,6 +502,13 @@ private JsDuration Since(JsValue thisObject, JsCallArguments arguments)
{
var plainDateTime = ValidatePlainDateTime(thisObject);
var other = _constructor.ToTemporalDateTime(arguments.At(0), "constrain");
+
+ // Calendar equality check (before reading options per spec)
+ if (!string.Equals(plainDateTime.Calendar, other.Calendar, StringComparison.Ordinal))
+ {
+ Throw.RangeError(_realm, "Calendars must match for date-time difference operations");
+ }
+
var optionsArg = arguments.At(1);
// All temporal units allowed for PlainDateTime operations
@@ -867,12 +891,29 @@ private JsString ToJSON(JsValue thisObject, JsCallArguments arguments)
}
///
- /// https://tc39.es/proposal-temporal/#sec-temporal.plaindatetime.prototype.tolocalestring
+ /// https://tc39.es/proposal-temporal/#sup-temporal.plaindatetime.prototype.tolocalestring
///
- private JsString ToLocaleString(JsValue thisObject, JsCallArguments arguments)
+ private JsValue ToLocaleString(JsValue thisObject, JsCallArguments arguments)
{
var plainDateTime = ValidatePlainDateTime(thisObject);
- return new JsString(FormatDateTime(plainDateTime.IsoDateTime, plainDateTime.Calendar));
+ var locales = arguments.At(0);
+ var options = arguments.At(1);
+
+ // Per spec: CreateDateTimeFormat with required=~any~, defaults=~all~
+ var dtf = _realm.Intrinsics.DateTimeFormat.CreateDateTimeFormat(
+ locales, options, required: Intl.DateTimeRequired.Any, defaults: Intl.DateTimeDefaults.All);
+
+ // Calendar mismatch check: if not iso8601 and not matching DTF calendar → RangeError
+ var cal = plainDateTime.Calendar;
+ if (!string.Equals(cal, "iso8601", StringComparison.Ordinal) &&
+ dtf.Calendar != null && !string.Equals(cal, dtf.Calendar, StringComparison.Ordinal))
+ {
+ Throw.RangeError(_realm, $"Calendar mismatch: PlainDateTime uses '{cal}' but DateTimeFormat uses '{dtf.Calendar}'");
+ }
+
+ var d = plainDateTime.IsoDateTime.Date;
+ var t = plainDateTime.IsoDateTime.Time;
+ return dtf.Format(new DateTime(d.Year, d.Month, d.Day, t.Hour, t.Minute, t.Second, t.Millisecond, DateTimeKind.Unspecified), isPlain: true);
}
///
@@ -968,62 +1009,7 @@ private static bool IsValidDisambiguation(string value)
private System.Numerics.BigInteger GetEpochFromDateTime(IsoDateTime dateTime, string timeZone, string disambiguation)
{
- var provider = _engine.Options.Temporal.TimeZoneProvider;
- var localNs = IsoDateTimeToNanoseconds(dateTime);
-
- var possibleInstants = provider.GetPossibleInstantsFor(timeZone,
- dateTime.Year, dateTime.Month, dateTime.Day,
- dateTime.Hour, dateTime.Minute, dateTime.Second,
- dateTime.Millisecond, dateTime.Microsecond, dateTime.Nanosecond);
-
- if (possibleInstants.Length == 1)
- {
- return possibleInstants[0];
- }
-
- if (possibleInstants.Length == 0)
- {
- // Gap - use disambiguation
- var before = provider.GetPossibleInstantsFor(timeZone,
- dateTime.Year, dateTime.Month, dateTime.Day,
- 0, 0, 0, 0, 0, 0);
- if (before.Length > 0)
- {
- var instant = before[before.Length - 1];
- var offsetBefore = provider.GetOffsetNanosecondsFor(timeZone, instant);
- var offsetAfter = provider.GetOffsetNanosecondsFor(timeZone, instant + 1);
-
- switch (disambiguation)
- {
- case "compatible":
- case "later":
- return localNs - offsetAfter;
- case "earlier":
- return localNs - offsetBefore;
- case "reject":
- Throw.RangeError(_realm, "No such time exists in this time zone (gap)");
- break;
- }
- }
-
- // Fallback
- return localNs;
- }
-
- // Ambiguous (fold/overlap)
- switch (disambiguation)
- {
- case "compatible":
- case "earlier":
- return possibleInstants[0];
- case "later":
- return possibleInstants[possibleInstants.Length - 1];
- case "reject":
- Throw.RangeError(_realm, "Time is ambiguous in this time zone (overlap)");
- break;
- }
-
- return possibleInstants[0];
+ return TemporalHelpers.GetEpochNanosecondsFor(_realm, _engine.Options.Temporal.TimeZoneProvider, timeZone, dateTime, disambiguation);
}
private static System.Numerics.BigInteger IsoDateTimeToNanoseconds(IsoDateTime dateTime)
diff --git a/Jint/Native/Temporal/PlainMonthDay/PlainMonthDayConstructor.cs b/Jint/Native/Temporal/PlainMonthDay/PlainMonthDayConstructor.cs
index 4aa3c66ee6..228e9badba 100644
--- a/Jint/Native/Temporal/PlainMonthDay/PlainMonthDayConstructor.cs
+++ b/Jint/Native/Temporal/PlainMonthDay/PlainMonthDayConstructor.cs
@@ -160,13 +160,13 @@ internal JsPlainMonthDay ToTemporalMonthDay(JsValue item, string overflow)
if (item.IsString())
{
var str = item.ToString();
- var parsed = ParseMonthDayString(str);
+ var parsed = ParseMonthDayString(str, out var parsedCalendar);
if (parsed is null)
{
Throw.RangeError(_realm, "Invalid month-day string");
}
- return Construct(parsed.Value, "iso8601");
+ return Construct(parsed.Value, parsedCalendar);
}
if (item.IsObject())
@@ -204,6 +204,9 @@ private JsPlainMonthDay ToTemporalMonthDayFromFields(ObjectInstance obj, JsValue
var day = TemporalHelpers.ToPositiveIntegerWithTruncation(_realm, dayValue);
+ // 2.5. era/eraYear - read for era-supporting calendars (alphabetically between day and month)
+ var eraYear = TemporalHelpers.ReadEraFields(_realm, obj, calendar);
+
// 3. month - read and convert immediately
var monthValue = obj.Get("month");
int month = 0;
@@ -249,15 +252,26 @@ private JsPlainMonthDay ToTemporalMonthDayFromFields(ObjectInstance obj, JsValue
monthFromCode = TemporalHelpers.ParseMonthCode(_realm, monthCodeStr);
}
- // 5. year - read and convert immediately (TYPE validation happens here)
- var yearValue = obj.Get("year");
- var year = yearValue.IsUndefined() ? 1972 : TemporalHelpers.ToIntegerWithTruncationAsInt(_realm, yearValue);
+ // 5. year - use eraYear if computed, otherwise read from property
+ int year;
+ var yearExplicitlyProvided = eraYear.HasValue;
+ if (eraYear.HasValue)
+ {
+ year = eraYear.Value;
+ obj.Get("year");
+ }
+ else
+ {
+ var yearValue = obj.Get("year");
+ yearExplicitlyProvided = !yearValue.IsUndefined();
+ year = yearValue.IsUndefined() ? 1972 : TemporalHelpers.ToIntegerWithTruncationAsInt(_realm, yearValue);
+ }
// 6. Read options.overflow AFTER all fields (but BEFORE algorithmic validation)
var overflow = optionsValue.IsUndefined() ? "constrain" : TemporalHelpers.GetOverflowOption(_realm, optionsValue);
- // NOW validate monthCode suitability (ISO calendar checks) - after year type validation AND options reading
- if (monthCodeStr is not null)
+ // Validate monthCode suitability - only for ISO/Gregorian calendars
+ if (monthCodeStr is not null && TemporalHelpers.IsGregorianBasedCalendar(calendar))
{
// For ISO 8601 calendar: validate monthCode is valid (01-12, no leap months)
if (monthCodeStr.Length == 4 && monthCodeStr[3] == 'L')
@@ -272,6 +286,13 @@ private JsPlainMonthDay ToTemporalMonthDayFromFields(ObjectInstance obj, JsValue
}
// Now validate and combine month/monthCode
+ // For non-ISO calendars, monthCode is required unless year is explicitly provided
+ // (month alone is ambiguous in calendars with leap months)
+ if (!string.Equals(calendar, "iso8601", StringComparison.Ordinal) && monthCodeStr is null && !yearExplicitlyProvided)
+ {
+ Throw.TypeError(_realm, "monthCode is required for non-ISO calendars when year is not provided");
+ }
+
// Validate: both month and monthCode provided - they must match
if (month != 0 && monthFromCode.HasValue && month != monthFromCode.Value)
{
@@ -289,6 +310,24 @@ private JsPlainMonthDay ToTemporalMonthDayFromFields(ObjectInstance obj, JsValue
Throw.TypeError(_realm, "month or monthCode is required");
}
+ // For non-ISO calendars, convert calendar year/month/day to ISO
+ if (!TemporalHelpers.IsGregorianBasedCalendar(calendar))
+ {
+ // When year is not explicitly provided, find the calendar year that maps to ISO 1972
+ var calendarYear = yearExplicitlyProvided
+ ? year
+ : TemporalHelpers.FindCalendarReferenceYear(calendar, 1972, month, day);
+
+ var calDate = TemporalHelpers.CalendarDateToISO(_realm, calendar, calendarYear, month, day, overflow);
+ if (calDate is null)
+ {
+ Throw.RangeError(_realm, "Invalid month-day");
+ }
+
+ // The converted ISO date becomes the reference date
+ return Construct(calDate.Value, calendar);
+ }
+
// Use input year for validation, but always use 1972 as reference year in result
// Per spec (calendar.html CalendarMonthDayToISOReferenceDate):
// "The reference year is always 1972"
@@ -302,8 +341,10 @@ private JsPlainMonthDay ToTemporalMonthDayFromFields(ObjectInstance obj, JsValue
return Construct(new IsoDate(1972, date.Value.Month, date.Value.Day), calendar);
}
- private static IsoDate? ParseMonthDayString(string input)
+ private static IsoDate? ParseMonthDayString(string input, out string parsedCalendar)
{
+ parsedCalendar = "iso8601";
+
// Empty strings are invalid
if (string.IsNullOrEmpty(input))
{
@@ -317,7 +358,18 @@ private JsPlainMonthDay ToTemporalMonthDayFromFields(ObjectInstance obj, JsValue
// Annotation parsing error
return null;
}
- // Note: calendar is ignored for PlainMonthDay (always uses iso8601)
+ // Canonicalize calendar annotation if present
+ var hasNonIsoCalendar = false;
+ if (calendar is not null)
+ {
+ var canonical = TemporalHelpers.CanonicalizeCalendar(calendar);
+ if (canonical is null)
+ {
+ return null; // Invalid calendar
+ }
+ parsedCalendar = canonical;
+ hasNonIsoCalendar = !string.Equals(canonical, "iso8601", StringComparison.Ordinal);
+ }
// PlainMonthDay cannot have UTC designator
if (coreString.Contains('Z'))
@@ -368,6 +420,7 @@ private JsPlainMonthDay ToTemporalMonthDayFromFields(ObjectInstance obj, JsValue
var date = TemporalHelpers.RegulateIsoDate(1972, month, day, "reject");
if (date is not null)
{
+ if (hasNonIsoCalendar) return null;
return date.Value;
}
}
@@ -381,6 +434,7 @@ private JsPlainMonthDay ToTemporalMonthDayFromFields(ObjectInstance obj, JsValue
var date = TemporalHelpers.RegulateIsoDate(1972, month, day, "reject");
if (date is not null)
{
+ if (hasNonIsoCalendar) return null;
return date.Value;
}
}
@@ -395,6 +449,7 @@ private JsPlainMonthDay ToTemporalMonthDayFromFields(ObjectInstance obj, JsValue
var date = TemporalHelpers.RegulateIsoDate(1972, month, day, "reject");
if (date is not null)
{
+ if (hasNonIsoCalendar) return null;
return date.Value;
}
}
@@ -427,6 +482,7 @@ private JsPlainMonthDay ToTemporalMonthDayFromFields(ObjectInstance obj, JsValue
var date = TemporalHelpers.RegulateIsoDate(1972, month, day, "reject");
if (date is not null)
{
+ if (hasNonIsoCalendar) return null;
return date.Value;
}
}
@@ -435,15 +491,22 @@ private JsPlainMonthDay ToTemporalMonthDayFromFields(ObjectInstance obj, JsValue
}
// Try parsing as full date and extract month-day
- return TryParseFullDateAsMonthDay(coreString);
+ return TryParseFullDateAsMonthDay(coreString, hasNonIsoCalendar);
}
- private static IsoDate? TryParseFullDateAsMonthDay(string input)
+ private static IsoDate? TryParseFullDateAsMonthDay(string input, bool hasNonIsoCalendar)
{
var parsed = TemporalHelpers.ParseIsoDate(input);
if (parsed is not null)
{
- // For PlainMonthDay, we only extract month and day — year range validation is not needed
+ // For non-iso8601 calendars, the parsed year is used for calendar conversion
+ // so it must be within valid ISO limits
+ if (hasNonIsoCalendar && !TemporalHelpers.IsValidIsoDateTime(parsed.Value.Year, parsed.Value.Month, parsed.Value.Day))
+ {
+ return null;
+ }
+
+ // For PlainMonthDay, we only extract month and day — year range validation is not needed for iso8601
// Use 1972 as reference year (leap year, so Feb 29 is valid)
return new IsoDate(1972, parsed.Value.Month, parsed.Value.Day);
}
diff --git a/Jint/Native/Temporal/PlainMonthDay/PlainMonthDayPrototype.cs b/Jint/Native/Temporal/PlainMonthDay/PlainMonthDayPrototype.cs
index a94522cb17..4b130e177b 100644
--- a/Jint/Native/Temporal/PlainMonthDay/PlainMonthDayPrototype.cs
+++ b/Jint/Native/Temporal/PlainMonthDay/PlainMonthDayPrototype.cs
@@ -153,6 +153,13 @@ private JsPlainMonthDay With(JsValue thisObject, JsCallArguments arguments)
// Read options BEFORE any validation (per spec)
var overflow = TemporalHelpers.GetOverflowOption(_realm, optionsArg);
+ // For non-ISO calendars, monthCode is required when month is provided
+ if (!string.Equals(md.Calendar, "iso8601", StringComparison.Ordinal) &&
+ !monthProp.IsUndefined() && monthCodeProp.IsUndefined())
+ {
+ Throw.TypeError(_realm, "monthCode is required for non-ISO calendars");
+ }
+
// NOW validate monthCode (after options are read)
int? monthFromCode = null;
if (monthCode is not null)
@@ -224,19 +231,32 @@ private JsString ToTemporalString(JsValue thisObject, JsCallArguments arguments)
var options = TemporalHelpers.GetOptionsObject(_realm, optionsValue);
var showCalendar = GetCalendarNameOption(options);
- // Include the reference year when calendar is shown
- // Per spec: YYYY-MM-DD format with calendar annotation
- var includeYear = string.Equals(showCalendar, "always", StringComparison.Ordinal) ||
- string.Equals(showCalendar, "critical", StringComparison.Ordinal) ||
- (string.Equals(showCalendar, "auto", StringComparison.Ordinal) &&
- !string.Equals(md.Calendar, "iso8601", StringComparison.Ordinal));
+ // For non-ISO calendars, always include the year (month-day alone is ambiguous)
+ // The year is needed regardless of calendarName option
+ var isNonIsoCalendar = !string.Equals(md.Calendar, "iso8601", StringComparison.Ordinal);
+
+ // Include the calendar annotation based on the calendarName option
+ var includeCalendar = string.Equals(showCalendar, "always", StringComparison.Ordinal) ||
+ string.Equals(showCalendar, "critical", StringComparison.Ordinal) ||
+ (string.Equals(showCalendar, "auto", StringComparison.Ordinal) && isNonIsoCalendar);
+
+ // Include year for non-ISO calendar or when calendar annotation is shown
+ var includeYear = isNonIsoCalendar || includeCalendar;
string result;
if (includeYear)
{
- // Add critical flag (!) if showCalendar is "critical"
- var criticalFlag = string.Equals(showCalendar, "critical", StringComparison.Ordinal) ? "!" : "";
- result = $"{md.IsoDate.Year:D4}-{md.IsoDate.Month:D2}-{md.IsoDate.Day:D2}[{criticalFlag}u-ca={md.Calendar}]";
+ var yearStr = TemporalHelpers.PadIsoYear(md.IsoDate.Year);
+ if (includeCalendar)
+ {
+ // Add critical flag (!) if showCalendar is "critical"
+ var criticalFlag = string.Equals(showCalendar, "critical", StringComparison.Ordinal) ? "!" : "";
+ result = $"{yearStr}-{md.IsoDate.Month:D2}-{md.IsoDate.Day:D2}[{criticalFlag}u-ca={md.Calendar}]";
+ }
+ else
+ {
+ result = $"{yearStr}-{md.IsoDate.Month:D2}-{md.IsoDate.Day:D2}";
+ }
}
else
{
@@ -252,17 +272,38 @@ private JsString ToTemporalString(JsValue thisObject, JsCallArguments arguments)
private JsString ToJSON(JsValue thisObject, JsCallArguments arguments)
{
var md = ValidatePlainMonthDay(thisObject);
+ // toJSON uses "auto" for calendarName: include year+calendar for non-ISO
+ if (!string.Equals(md.Calendar, "iso8601", StringComparison.Ordinal))
+ {
+ var yearStr = TemporalHelpers.PadIsoYear(md.IsoDate.Year);
+ return new JsString($"{yearStr}-{md.IsoDate.Month:D2}-{md.IsoDate.Day:D2}[u-ca={md.Calendar}]");
+ }
+
return new JsString($"{md.IsoDate.Month:D2}-{md.IsoDate.Day:D2}");
}
///
- /// https://tc39.es/proposal-temporal/#sec-temporal.plainmonthday.prototype.tolocalestring
+ /// https://tc39.es/proposal-temporal/#sup-temporal.plainmonthday.prototype.tolocalestring
///
- private JsString ToLocaleString(JsValue thisObject, JsCallArguments arguments)
+ private JsValue ToLocaleString(JsValue thisObject, JsCallArguments arguments)
{
var md = ValidatePlainMonthDay(thisObject);
- // For now, just return MM-DD format
- return new JsString($"{md.IsoDate.Month:D2}-{md.IsoDate.Day:D2}");
+ var locales = arguments.At(0);
+ var options = arguments.At(1);
+
+ // Per spec: CreateDateTimeFormat with required=~date~, defaults=~date~
+ // But for PlainMonthDay, we use month-day specific defaults (no year)
+ var dtf = _realm.Intrinsics.DateTimeFormat.CreateDateTimeFormat(
+ locales, options, required: Intl.DateTimeRequired.Date, defaults: Intl.DateTimeDefaults.MonthDay);
+
+ // Calendar mismatch check: PlainMonthDay calendar must match DTF calendar exactly
+ var cal = md.Calendar;
+ if (dtf.Calendar != null && !string.Equals(cal, dtf.Calendar, StringComparison.Ordinal))
+ {
+ Throw.RangeError(_realm, $"Calendar mismatch: PlainMonthDay uses '{cal}' but DateTimeFormat uses '{dtf.Calendar}'");
+ }
+
+ return dtf.Format(new DateTime(md.IsoDate.Year, md.IsoDate.Month, md.IsoDate.Day, 12, 0, 0, DateTimeKind.Unspecified), isPlain: true);
}
///
@@ -288,13 +329,27 @@ private JsPlainDate ToPlainDate(JsValue thisObject, JsCallArguments arguments)
}
var obj = item.AsObject();
- var yearProp = obj.Get("year");
- if (yearProp.IsUndefined())
+
+ // Read era/eraYear for era-supporting calendars (alphabetically before year)
+ var eraYear = TemporalHelpers.ReadEraFields(_realm, obj, md.Calendar);
+
+ int year;
+ if (eraYear.HasValue)
+ {
+ year = eraYear.Value;
+ obj.Get("year");
+ }
+ else
{
- Throw.TypeError(_realm, "year is required");
+ var yearProp = obj.Get("year");
+ if (yearProp.IsUndefined())
+ {
+ Throw.TypeError(_realm, "year is required");
+ }
+
+ year = TemporalHelpers.ToIntegerWithTruncationAsInt(_realm, yearProp);
}
- var year = TemporalHelpers.ToIntegerWithTruncationAsInt(_realm, yearProp);
var date = TemporalHelpers.RegulateIsoDate(year, md.IsoDate.Month, md.IsoDate.Day, "constrain");
if (date is null)
{
diff --git a/Jint/Native/Temporal/PlainTime/PlainTimePrototype.cs b/Jint/Native/Temporal/PlainTime/PlainTimePrototype.cs
index a1d8c8a879..51b709247e 100644
--- a/Jint/Native/Temporal/PlainTime/PlainTimePrototype.cs
+++ b/Jint/Native/Temporal/PlainTime/PlainTimePrototype.cs
@@ -528,12 +528,21 @@ private JsString ToJSON(JsValue thisObject, JsCallArguments arguments)
}
///
- /// https://tc39.es/proposal-temporal/#sec-temporal.plaintime.prototype.tolocalestring
+ /// https://tc39.es/proposal-temporal/#sup-temporal.plaintime.prototype.tolocalestring
///
- private JsString ToLocaleString(JsValue thisObject, JsCallArguments arguments)
+ private JsValue ToLocaleString(JsValue thisObject, JsCallArguments arguments)
{
var plainTime = ValidatePlainTime(thisObject);
- return new JsString(FormatTime(plainTime.IsoTime, -1));
+ var locales = arguments.At(0);
+ var options = arguments.At(1);
+
+ // Per spec: CreateDateTimeFormat with required=~time~, defaults=~time~
+ var dtf = _realm.Intrinsics.DateTimeFormat.CreateDateTimeFormat(
+ locales, options, required: Intl.DateTimeRequired.Time, defaults: Intl.DateTimeDefaults.Time);
+
+ // Format: use 1970-01-01 as reference date, Unspecified kind (plain = no TZ adjustment)
+ var t = plainTime.IsoTime;
+ return dtf.Format(new DateTime(1970, 1, 1, t.Hour, t.Minute, t.Second, t.Millisecond, DateTimeKind.Unspecified), isPlain: true);
}
///
diff --git a/Jint/Native/Temporal/PlainYearMonth/PlainYearMonthConstructor.cs b/Jint/Native/Temporal/PlainYearMonth/PlainYearMonthConstructor.cs
index 976eb00c83..cca58d790d 100644
--- a/Jint/Native/Temporal/PlainYearMonth/PlainYearMonthConstructor.cs
+++ b/Jint/Native/Temporal/PlainYearMonth/PlainYearMonthConstructor.cs
@@ -189,7 +189,7 @@ internal JsPlainYearMonth ToTemporalYearMonth(JsValue item, string overflow)
if (item.IsString())
{
var str = item.ToString();
- var parsed = ParseYearMonthString(str);
+ var parsed = ParseYearMonthString(str, out var parsedCalendar);
if (parsed is null)
{
Throw.RangeError(_realm, "Invalid year-month string");
@@ -200,7 +200,10 @@ internal JsPlainYearMonth ToTemporalYearMonth(JsValue item, string overflow)
Throw.RangeError(_realm, "Year-month is outside the representable range");
}
- return Construct(parsed.Value, "iso8601");
+ // Per spec: ISOYearMonthFromFields uses day=1 for the ISO calendar reference day.
+ // Non-iso8601 calendars preserve the parsed day as the reference ISO day.
+ var refDay = string.Equals(parsedCalendar, "iso8601", StringComparison.Ordinal) ? 1 : parsed.Value.Day;
+ return Construct(new IsoDate(parsed.Value.Year, parsed.Value.Month, refDay), parsedCalendar);
}
if (item.IsObject())
@@ -231,7 +234,10 @@ private JsPlainYearMonth ToTemporalYearMonthFromObjectWithOptions(ObjectInstance
calendar = TemporalHelpers.ToTemporalCalendarIdentifier(_realm, calendarValue);
}
- // 2. month - read and convert immediately
+ // 2. era/eraYear - read for era-supporting calendars (alphabetically between calendar and month)
+ var eraYear = TemporalHelpers.ReadEraFields(_realm, obj, calendar);
+
+ // 3. month - read and convert immediately
var monthValue = obj.Get("month");
int month = 0;
if (!monthValue.IsUndefined())
@@ -239,7 +245,7 @@ private JsPlainYearMonth ToTemporalYearMonthFromObjectWithOptions(ObjectInstance
month = TemporalHelpers.ToPositiveIntegerWithTruncation(_realm, monthValue);
}
- // 3. monthCode - read and convert immediately, validate well-formedness
+ // 4. monthCode - read and convert immediately, validate well-formedness
var monthCodeValue = obj.Get("monthCode");
string? monthCodeStr = null;
int? monthFromCode = null;
@@ -284,20 +290,29 @@ private JsPlainYearMonth ToTemporalYearMonthFromObjectWithOptions(ObjectInstance
month = monthFromCode.Value;
}
- // 4. year - read and convert immediately (TYPE validation happens here)
- var yearValue = obj.Get("year");
- if (yearValue.IsUndefined())
+ // 5. year - use eraYear if computed, otherwise read from property
+ int year;
+ if (eraYear.HasValue)
{
- Throw.TypeError(_realm, "Missing required property: year");
+ year = eraYear.Value;
+ obj.Get("year");
}
+ else
+ {
+ var yearValue = obj.Get("year");
+ if (yearValue.IsUndefined())
+ {
+ Throw.TypeError(_realm, "Missing required property: year");
+ }
- var year = TemporalHelpers.ToIntegerWithTruncationAsInt(_realm, yearValue);
+ year = TemporalHelpers.ToIntegerWithTruncationAsInt(_realm, yearValue);
+ }
- // 5. Read options AFTER all fields (but BEFORE algorithmic validation)
+ // 6. Read options AFTER all fields (but BEFORE algorithmic validation)
var overflow = TemporalHelpers.GetOverflowOption(_realm, options);
- // NOW validate monthCode suitability (ISO calendar checks) - after year type validation AND options reading
- if (monthCodeStr is not null)
+ // Validate monthCode suitability - only for ISO/Gregorian calendars
+ if (monthCodeStr is not null && TemporalHelpers.IsGregorianBasedCalendar(calendar))
{
// For ISO 8601 calendar: validate monthCode is valid (01-12, no leap months)
if (monthCodeStr.Length == 4 && monthCodeStr[3] == 'L')
@@ -317,7 +332,19 @@ private JsPlainYearMonth ToTemporalYearMonthFromObjectWithOptions(ObjectInstance
Throw.TypeError(_realm, "month or monthCode is required");
}
- // Validate month range
+ // For non-ISO calendars, convert calendar year/month to ISO
+ if (!TemporalHelpers.IsGregorianBasedCalendar(calendar))
+ {
+ var date = TemporalHelpers.CalendarDateToISO(_realm, calendar, year, month, 1, overflow);
+ if (date is null)
+ {
+ Throw.RangeError(_realm, "Invalid year-month");
+ }
+
+ return Construct(date.Value, calendar);
+ }
+
+ // Validate month range (ISO calendars)
if (month < 1 || month > 12)
{
if (string.Equals(overflow, "constrain", StringComparison.Ordinal))
@@ -357,7 +384,10 @@ private JsPlainYearMonth ToTemporalYearMonthFromFields(ObjectInstance obj, strin
calendar = TemporalHelpers.ToTemporalCalendarIdentifier(_realm, calendarValue);
}
- // 2. month - read and convert immediately
+ // 2. era/eraYear - read for era-supporting calendars (alphabetically between calendar and month)
+ var eraYear = TemporalHelpers.ReadEraFields(_realm, obj, calendar);
+
+ // 3. month - read and convert immediately
var monthValue = obj.Get("month");
int month = 0;
if (!monthValue.IsUndefined())
@@ -365,7 +395,7 @@ private JsPlainYearMonth ToTemporalYearMonthFromFields(ObjectInstance obj, strin
month = TemporalHelpers.ToPositiveIntegerWithTruncation(_realm, monthValue);
}
- // 3. monthCode - read and convert immediately, validate well-formedness
+ // 4. monthCode - read and convert immediately, validate well-formedness
var monthCodeValue = obj.Get("monthCode");
string? monthCodeStr = null;
int? monthFromCode = null;
@@ -410,17 +440,26 @@ private JsPlainYearMonth ToTemporalYearMonthFromFields(ObjectInstance obj, strin
month = monthFromCode.Value;
}
- // 4. year - read and convert immediately (TYPE validation happens here)
- var yearValue = obj.Get("year");
- if (yearValue.IsUndefined())
+ // 5. year - use eraYear if computed, otherwise read from property
+ int year;
+ if (eraYear.HasValue)
{
- Throw.TypeError(_realm, "Missing required property: year");
+ year = eraYear.Value;
+ obj.Get("year");
}
+ else
+ {
+ var yearValue = obj.Get("year");
+ if (yearValue.IsUndefined())
+ {
+ Throw.TypeError(_realm, "Missing required property: year");
+ }
- var year = TemporalHelpers.ToIntegerWithTruncationAsInt(_realm, yearValue);
+ year = TemporalHelpers.ToIntegerWithTruncationAsInt(_realm, yearValue);
+ }
- // NOW validate monthCode suitability(ISO calendar checks) - after year type validation
- if (monthCodeStr is not null)
+ // Validate monthCode suitability - only for ISO/Gregorian calendars
+ if (monthCodeStr is not null && TemporalHelpers.IsGregorianBasedCalendar(calendar))
{
// For ISO 8601 calendar: validate monthCode is valid (01-12, no leap months)
if (monthCodeStr.Length == 4 && monthCodeStr[3] == 'L')
@@ -442,7 +481,19 @@ private JsPlainYearMonth ToTemporalYearMonthFromFields(ObjectInstance obj, strin
// Note: overflow option is already read in From() method before calling this method, per spec
- // Validate month range
+ // For non-ISO calendars, convert calendar year/month to ISO
+ if (!TemporalHelpers.IsGregorianBasedCalendar(calendar))
+ {
+ var date = TemporalHelpers.CalendarDateToISO(_realm, calendar, year, month, 1, overflow);
+ if (date is null)
+ {
+ Throw.RangeError(_realm, "Invalid year-month");
+ }
+
+ return Construct(date.Value, calendar);
+ }
+
+ // Validate month range (ISO calendars)
if (month < 1 || month > 12)
{
if (string.Equals(overflow, "constrain", StringComparison.Ordinal))
@@ -464,8 +515,10 @@ private JsPlainYearMonth ToTemporalYearMonthFromFields(ObjectInstance obj, strin
return Construct(new IsoDate(year, month, 1), calendar);
}
- private static IsoDate? ParseYearMonthString(string input)
+ private static IsoDate? ParseYearMonthString(string input, out string parsedCalendar)
{
+ parsedCalendar = "iso8601";
+
// Empty strings are invalid
if (string.IsNullOrEmpty(input))
{
@@ -479,7 +532,21 @@ private JsPlainYearMonth ToTemporalYearMonthFromFields(ObjectInstance obj, strin
// Annotation parsing error
return null;
}
- // Note: calendar is ignored for PlainYearMonth (always uses iso8601)
+
+ // Canonicalize calendar annotation if present
+ if (calendar is not null)
+ {
+ var canonical = TemporalHelpers.CanonicalizeCalendar(calendar);
+ if (canonical is null)
+ {
+ return null; // Invalid calendar
+ }
+ parsedCalendar = canonical;
+ }
+
+ // Per spec: DateSpecYearMonth (YYYY-MM) with non-iso8601 calendar annotation is invalid.
+ // Only full date formats (AnnotatedDateTime) support non-iso8601 calendars.
+ var hasNonIsoCalendar = calendar is not null && !string.Equals(parsedCalendar, "iso8601", StringComparison.Ordinal);
// Check for negative zero year
var firstDash = coreString.IndexOf('-', 1);
@@ -551,6 +618,7 @@ private JsPlainYearMonth ToTemporalYearMonthFromFields(ObjectInstance obj, strin
return null;
}
+ if (hasNonIsoCalendar) return null;
if (month >= 1 && month <= 12)
{
return new IsoDate(year, month, 1);
@@ -559,6 +627,7 @@ private JsPlainYearMonth ToTemporalYearMonthFromFields(ObjectInstance obj, strin
// Other suffix (like '[' for annotations) - also valid
else
{
+ if (hasNonIsoCalendar) return null;
if (month >= 1 && month <= 12)
{
return new IsoDate(year, month, 1);
@@ -568,6 +637,7 @@ private JsPlainYearMonth ToTemporalYearMonthFromFields(ObjectInstance obj, strin
else
{
// No suffix - just YYYY-MM
+ if (hasNonIsoCalendar) return null;
if (month >= 1 && month <= 12)
{
return new IsoDate(year, month, 1);
@@ -606,6 +676,7 @@ private JsPlainYearMonth ToTemporalYearMonthFromFields(ObjectInstance obj, strin
int.TryParse(monthStr, NumberStyles.Integer, CultureInfo.InvariantCulture, out var month) &&
month >= 1 && month <= 12)
{
+ if (hasNonIsoCalendar) return null;
return new IsoDate(year, month, 1);
}
}
@@ -619,17 +690,18 @@ private JsPlainYearMonth ToTemporalYearMonthFromFields(ObjectInstance obj, strin
int.TryParse(monthStr, NumberStyles.Integer, CultureInfo.InvariantCulture, out var month) &&
month >= 1 && month <= 12)
{
+ if (hasNonIsoCalendar) return null;
return new IsoDate(year, month, 1);
}
}
}
}
- // Try parsing as full date and extract year/month
+ // Try parsing as full date and extract year/month (preserve day as reference day per spec)
var parsed = TemporalHelpers.ParseIsoDate(coreString);
if (parsed is not null)
{
- return new IsoDate(parsed.Value.Year, parsed.Value.Month, 1);
+ return parsed.Value;
}
// Try parsing date-time string
@@ -651,7 +723,7 @@ private JsPlainYearMonth ToTemporalYearMonthFromFields(ObjectInstance obj, strin
return null;
}
- return new IsoDate(parsed.Value.Year, parsed.Value.Month, 1);
+ return parsed.Value;
}
}
diff --git a/Jint/Native/Temporal/PlainYearMonth/PlainYearMonthPrototype.cs b/Jint/Native/Temporal/PlainYearMonth/PlainYearMonthPrototype.cs
index df6f0a47dd..0e34364b77 100644
--- a/Jint/Native/Temporal/PlainYearMonth/PlainYearMonthPrototype.cs
+++ b/Jint/Native/Temporal/PlainYearMonth/PlainYearMonthPrototype.cs
@@ -72,7 +72,11 @@ private JsPlainYearMonth ValidatePlainYearMonth(JsValue thisObject)
// Getters
private JsString GetCalendarId(JsValue thisObject, JsCallArguments arguments) => new JsString(ValidatePlainYearMonth(thisObject).Calendar);
- private JsNumber GetYear(JsValue thisObject, JsCallArguments arguments) => JsNumber.Create(ValidatePlainYearMonth(thisObject).IsoDate.Year);
+ private JsNumber GetYear(JsValue thisObject, JsCallArguments arguments)
+ {
+ var ym = ValidatePlainYearMonth(thisObject);
+ return JsNumber.Create(TemporalHelpers.CalendarYear(ym.Calendar, ym.IsoDate.Year));
+ }
private JsNumber GetMonth(JsValue thisObject, JsCallArguments arguments) => JsNumber.Create(ValidatePlainYearMonth(thisObject).IsoDate.Month);
private JsString GetMonthCode(JsValue thisObject, JsCallArguments arguments) => new JsString($"M{ValidatePlainYearMonth(thisObject).IsoDate.Month:D2}");
private JsNumber GetDaysInMonth(JsValue thisObject, JsCallArguments arguments) => JsNumber.Create(ValidatePlainYearMonth(thisObject).IsoDate.DaysInMonth());
@@ -85,13 +89,15 @@ private JsNumber GetMonthsInYear(JsValue thisObject, JsCallArguments arguments)
private JsBoolean GetInLeapYear(JsValue thisObject, JsCallArguments arguments) => IsoDate.IsLeapYear(ValidatePlainYearMonth(thisObject).IsoDate.Year) ? JsBoolean.True : JsBoolean.False;
private JsValue GetEraYear(JsValue thisObject, JsCallArguments arguments)
{
- ValidatePlainYearMonth(thisObject);
- return Undefined; // ISO calendar has no era
+ var ym = ValidatePlainYearMonth(thisObject);
+ var eraYear = TemporalHelpers.CalendarEraYear(ym.Calendar, ym.IsoDate.Year);
+ return eraYear.HasValue ? JsNumber.Create(eraYear.Value) : Undefined;
}
private JsValue GetEra(JsValue thisObject, JsCallArguments arguments)
{
- ValidatePlainYearMonth(thisObject);
- return Undefined; // ISO calendar has no era
+ var ym = ValidatePlainYearMonth(thisObject);
+ var era = TemporalHelpers.CalendarEra(ym.Calendar, ym.IsoDate.Year);
+ return era is not null ? new JsString(era) : Undefined;
}
///
@@ -316,6 +322,13 @@ private JsDuration Until(JsValue thisObject, JsCallArguments arguments)
{
var ym = ValidatePlainYearMonth(thisObject);
var other = _constructor.ToTemporalYearMonth(arguments.At(0), "constrain");
+
+ // Calendar equality check (before reading options per spec)
+ if (!string.Equals(ym.Calendar, other.Calendar, StringComparison.Ordinal))
+ {
+ Throw.RangeError(_realm, "Calendars must match for year-month difference operations");
+ }
+
var optionsArg = arguments.At(1);
// Only year and month units allowed for PlainYearMonth
@@ -350,6 +363,13 @@ private JsDuration Since(JsValue thisObject, JsCallArguments arguments)
{
var ym = ValidatePlainYearMonth(thisObject);
var other = _constructor.ToTemporalYearMonth(arguments.At(0), "constrain");
+
+ // Calendar equality check (before reading options per spec)
+ if (!string.Equals(ym.Calendar, other.Calendar, StringComparison.Ordinal))
+ {
+ Throw.RangeError(_realm, "Calendars must match for year-month difference operations");
+ }
+
var optionsArg = arguments.At(1);
// Only year and month units allowed for PlainYearMonth
@@ -493,24 +513,32 @@ private JsString ToTemporalString(JsValue thisObject, JsCallArguments arguments)
var options = TemporalHelpers.GetOptionsObject(_realm, optionsValue);
var showCalendar = GetCalendarNameOption(options);
- // Include the reference day when calendar is shown
- // Per spec: YYYY-MM-DD format with calendar annotation
- var includeDay = string.Equals(showCalendar, "always", StringComparison.Ordinal) ||
- string.Equals(showCalendar, "critical", StringComparison.Ordinal) ||
- (string.Equals(showCalendar, "auto", StringComparison.Ordinal) &&
- !string.Equals(ym.Calendar, "iso8601", StringComparison.Ordinal));
+ // For non-ISO calendars, always include the reference day (year-month alone is ambiguous)
+ var isNonIsoCalendar = !string.Equals(ym.Calendar, "iso8601", StringComparison.Ordinal);
- var year = ym.IsoDate.Year;
- var yearStr = (year < 0 || year > 9999)
- ? $"{(year >= 0 ? '+' : '-')}{System.Math.Abs(year):D6}"
- : $"{year:D4}";
+ // Include the calendar annotation based on the calendarName option
+ var includeCalendar = string.Equals(showCalendar, "always", StringComparison.Ordinal) ||
+ string.Equals(showCalendar, "critical", StringComparison.Ordinal) ||
+ (string.Equals(showCalendar, "auto", StringComparison.Ordinal) && isNonIsoCalendar);
+
+ // Include reference day for non-ISO calendar or when calendar annotation is shown
+ var includeDay = isNonIsoCalendar || includeCalendar;
+
+ var yearStr = TemporalHelpers.PadIsoYear(ym.IsoDate.Year);
string result;
if (includeDay)
{
- // Add critical flag (!) if showCalendar is "critical"
- var criticalFlag = string.Equals(showCalendar, "critical", StringComparison.Ordinal) ? "!" : "";
- result = $"{yearStr}-{ym.IsoDate.Month:D2}-{ym.IsoDate.Day:D2}[{criticalFlag}u-ca={ym.Calendar}]";
+ if (includeCalendar)
+ {
+ // Add critical flag (!) if showCalendar is "critical"
+ var criticalFlag = string.Equals(showCalendar, "critical", StringComparison.Ordinal) ? "!" : "";
+ result = $"{yearStr}-{ym.IsoDate.Month:D2}-{ym.IsoDate.Day:D2}[{criticalFlag}u-ca={ym.Calendar}]";
+ }
+ else
+ {
+ result = $"{yearStr}-{ym.IsoDate.Month:D2}-{ym.IsoDate.Day:D2}";
+ }
}
else
{
@@ -526,25 +554,38 @@ private JsString ToTemporalString(JsValue thisObject, JsCallArguments arguments)
private JsString ToJSON(JsValue thisObject, JsCallArguments arguments)
{
var ym = ValidatePlainYearMonth(thisObject);
- var year = ym.IsoDate.Year;
- var yearStr = (year < 0 || year > 9999)
- ? $"{(year >= 0 ? '+' : '-')}{System.Math.Abs(year):D6}"
- : $"{year:D4}";
+ var yearStr = TemporalHelpers.PadIsoYear(ym.IsoDate.Year);
+ // toJSON uses "auto" for calendarName: include day+calendar for non-ISO
+ if (!string.Equals(ym.Calendar, "iso8601", StringComparison.Ordinal))
+ {
+ return new JsString($"{yearStr}-{ym.IsoDate.Month:D2}-{ym.IsoDate.Day:D2}[u-ca={ym.Calendar}]");
+ }
+
return new JsString($"{yearStr}-{ym.IsoDate.Month:D2}");
}
///
- /// https://tc39.es/proposal-temporal/#sec-temporal.plainyearmonth.prototype.tolocalestring
+ /// https://tc39.es/proposal-temporal/#sup-temporal.plainyearmonth.prototype.tolocalestring
///
- private JsString ToLocaleString(JsValue thisObject, JsCallArguments arguments)
+ private JsValue ToLocaleString(JsValue thisObject, JsCallArguments arguments)
{
var ym = ValidatePlainYearMonth(thisObject);
- // For now, just return ISO format
- var year = ym.IsoDate.Year;
- var yearStr = (year < 0 || year > 9999)
- ? $"{(year >= 0 ? '+' : '-')}{System.Math.Abs(year):D6}"
- : $"{year:D4}";
- return new JsString($"{yearStr}-{ym.IsoDate.Month:D2}");
+ var locales = arguments.At(0);
+ var options = arguments.At(1);
+
+ // Per spec: CreateDateTimeFormat with required=~date~, defaults=~date~
+ // But for PlainYearMonth, we use year-month specific defaults (no day)
+ var dtf = _realm.Intrinsics.DateTimeFormat.CreateDateTimeFormat(
+ locales, options, required: Intl.DateTimeRequired.Date, defaults: Intl.DateTimeDefaults.YearMonth);
+
+ // Calendar mismatch check: PlainYearMonth calendar must match DTF calendar exactly
+ var cal = ym.Calendar;
+ if (dtf.Calendar != null && !string.Equals(cal, dtf.Calendar, StringComparison.Ordinal))
+ {
+ Throw.RangeError(_realm, $"Calendar mismatch: PlainYearMonth uses '{cal}' but DateTimeFormat uses '{dtf.Calendar}'");
+ }
+
+ return dtf.Format(new DateTime(ym.IsoDate.Year, ym.IsoDate.Month, ym.IsoDate.Day, 12, 0, 0, DateTimeKind.Unspecified), isPlain: true);
}
///
diff --git a/Jint/Native/Temporal/TemporalHelpers.cs b/Jint/Native/Temporal/TemporalHelpers.cs
index bf3cb3086f..455ad9e74f 100644
--- a/Jint/Native/Temporal/TemporalHelpers.cs
+++ b/Jint/Native/Temporal/TemporalHelpers.cs
@@ -13,6 +13,19 @@ namespace Jint.Native.Temporal;
///
internal static class TemporalHelpers
{
+ ///
+ /// Formats an ISO year as 4-digit (0000-9999) or 6-digit with sign prefix.
+ ///
+ internal static string PadIsoYear(int year)
+ {
+ if (year < 0 || year > 9999)
+ {
+ return $"{(year >= 0 ? '+' : '-')}{System.Math.Abs(year):D6}";
+ }
+
+ return $"{year:D4}";
+ }
+
// Polyfill for Math.Clamp which doesn't exist in netstandard2.0
private static int Clamp(int value, int min, int max)
{
@@ -262,7 +275,22 @@ public static IsoDateTime EpochNanosecondsToIsoDateTime(BigInteger epochNs)
}
///
- /// Formats an offset in nanoseconds as an ISO 8601 offset string.
+ /// FormatDateTimeUTCOffsetRounded: Rounds offset to nearest minute and formats as ±HH:MM.
+ /// Used by ZonedDateTime.toString() and Instant.toString() per the Temporal spec.
+ ///
+ public static string FormatOffsetRounded(long offsetNanoseconds)
+ {
+ // Round to nearest minute (halfExpand)
+ var sign = offsetNanoseconds >= 0 ? "+" : "-";
+ var absNs = System.Math.Abs(offsetNanoseconds);
+ var totalMinutes = (absNs + NanosecondsPerMinute / 2) / NanosecondsPerMinute;
+ var hours = totalMinutes / 60;
+ var minutes = totalMinutes % 60;
+ return $"{sign}{hours:D2}:{minutes:D2}";
+ }
+
+ ///
+ /// Formats an offset in nanoseconds as an ISO 8601 offset string (full precision).
///
public static string FormatOffsetString(long offsetNanoseconds)
{
@@ -1257,8 +1285,10 @@ public static bool IsSubMinuteOffset(string offset)
}
///
- /// Validates a time zone ID against the provider and returns the canonical form.
+ /// Validates a time zone ID against the provider and returns the case-corrected form.
+ /// Per spec, returns [[Identifier]] (not [[PrimaryIdentifier]]).
/// Throws RangeError if the ID is not a valid time zone.
+ /// https://tc39.es/proposal-temporal/#sec-temporal-totemporaltimezoneidentifier
///
public static string ValidateTimeZoneId(Engine engine, Realm realm, string timeZoneId)
{
@@ -1277,6 +1307,37 @@ public static string ValidateTimeZoneId(Engine engine, Realm realm, string timeZ
return timeZoneId;
}
+ ///
+ /// Compares two time zone identifiers for equality per TC39 TimeZoneEquals.
+ /// Resolves IANA aliases to their primary identifiers before comparing.
+ /// https://tc39.es/proposal-temporal/#sec-timezoneequals
+ ///
+ public static bool TimeZoneEquals(Engine engine, string one, string two)
+ {
+ if (string.Equals(one, two, StringComparison.OrdinalIgnoreCase))
+ {
+ return true;
+ }
+
+ var provider = engine.Options.Temporal.TimeZoneProvider;
+ var primary1 = provider.GetPrimaryTimeZoneIdentifier(one);
+ var primary2 = provider.GetPrimaryTimeZoneIdentifier(two);
+ if (primary1 is not null && primary2 is not null)
+ {
+ return string.Equals(primary1, primary2, StringComparison.OrdinalIgnoreCase);
+ }
+
+ // Fallback: compare canonicalized forms
+ var canonical1 = provider.CanonicalizeTimeZone(one);
+ var canonical2 = provider.CanonicalizeTimeZone(two);
+ if (canonical1 is not null && canonical2 is not null)
+ {
+ return string.Equals(canonical1, canonical2, StringComparison.OrdinalIgnoreCase);
+ }
+
+ return false;
+ }
+
///
/// Parses a time zone string and returns the canonical time zone identifier.
/// Handles IANA IDs, offset strings, and ISO date-time strings with embedded time zones.
@@ -1319,15 +1380,19 @@ public static string ParseTemporalTimeZoneString(Engine engine, Realm realm, str
}
// Check if this looks like an ISO datetime string (contains T/t separator)
- // T must be at position >= 4 to be a date-time separator (after YYYY year portion)
- // This avoids treating timezone names like "UTC" as ISO datetime strings
+ // ISO datetime strings start with digits (year portion): "2024-07-02T..."
+ // IANA timezone names start with letters: "Asia/Kolkata", "America/Los_Angeles"
+ // Only look for T separator if the input starts with a digit (indicating a date)
var tPos = -1;
- for (var i = 4; i < input.Length; i++)
+ if (input.Length > 4 && char.IsDigit(input[0]))
{
- if (input[i] == 'T' || input[i] == 't')
+ for (var i = 4; i < input.Length; i++)
{
- tPos = i;
- break;
+ if (input[i] == 'T' || input[i] == 't')
+ {
+ tPos = i;
+ break;
+ }
}
}
@@ -1491,11 +1556,412 @@ public static bool ISOYearMonthWithinLimits(int year, int month)
// Check if it's a valid calendar ID and return canonical form
var lower = ToAsciiLowerCase(calendar);
- // Only iso8601 is supported for now
- if (string.Equals(lower, "iso8601", StringComparison.Ordinal))
- return "iso8601";
+ // Map of known calendar identifiers to their canonical forms
+ // https://tc39.es/ecma402/#sec-availablecalendars
+ return lower switch
+ {
+ "iso8601" => "iso8601",
+ "gregory" or "gregorian" => "gregory",
+ "buddhist" => "buddhist",
+ "chinese" => "chinese",
+ "coptic" => "coptic",
+ "dangi" => "dangi",
+ "ethioaa" or "ethiopic-amete-alem" => "ethioaa",
+ "ethiopic" => "ethiopic",
+ "hebrew" => "hebrew",
+ "indian" => "indian",
+ "islamic" => "islamic",
+ "islamic-civil" => "islamic-civil",
+ "islamic-rgsa" => "islamic-rgsa",
+ "islamic-tbla" => "islamic-tbla",
+ "islamic-umalqura" => "islamic-umalqura",
+ "islamicc" => "islamic-civil",
+ "japanese" => "japanese",
+ "persian" => "persian",
+ "roc" => "roc",
+ _ => null // Unsupported calendar
+ };
+ }
+
+ ///
+ /// Converts calendar-system year/month/day to ISO year/month/day.
+ /// For non-ISO calendars, the input fields are in the calendar's system
+ /// and need to be converted to the proleptic Gregorian (ISO 8601) calendar.
+ ///
+ public static IsoDate? CalendarDateToISO(Realm realm, string calendar, int year, int month, int day, string overflow)
+ {
+ if (calendar is "iso8601" or "gregory")
+ {
+ return RegulateIsoDate(year, month, day, overflow);
+ }
+
+ if (calendar is "islamic-civil")
+ {
+ return IslamicCivilDateToISO(year, month, day, overflow);
+ }
+
+ if (calendar is "islamic-tbla")
+ {
+ return IslamicTblaDateToISO(year, month, day, overflow);
+ }
+
+ // For other calendars, fall back to treating fields as ISO (best effort)
+ return RegulateIsoDate(year, month, day, overflow);
+ }
+
+ ///
+ /// For PlainMonthDay without an explicit year: finds a calendar year such that
+ /// converting (calendarYear, month, day) to ISO produces an ISO date with the given
+ /// ISO reference year (typically 1972).
+ ///
+ internal static int FindCalendarReferenceYear(string calendar, int isoReferenceYear, int month, int day)
+ {
+ if (calendar is "islamic-civil" or "islamic-tbla")
+ {
+ // Approximate Islamic year for a given ISO year
+ var approxYear = (int) ((isoReferenceYear - 622.0) * 33.0 / 32.0);
+
+ // Try years around the approximation to find one mapping to the ISO reference year
+ for (var y = approxYear - 1; y <= approxYear + 1; y++)
+ {
+ var isoDate = CalendarDateToISO(null!, calendar, y, month, day, "constrain");
+ if (isoDate?.Year == isoReferenceYear)
+ {
+ return y;
+ }
+ }
+
+ return approxYear;
+ }
+
+ // Default: assume calendar year ≈ ISO year
+ return isoReferenceYear;
+ }
+
+ ///
+ /// Returns the number of days in a given month of the Islamic Civil (tabular) calendar.
+ /// Odd months have 30 days, even months have 29 days.
+ /// Month 12 has 30 days in leap years.
+ ///
+ private static int IslamicCivilDaysInMonth(int year, int month)
+ {
+ if (month <= 0 || month > 12)
+ {
+ return 0;
+ }
+
+ // Odd months (1, 3, 5, 7, 9, 11) have 30 days
+ // Even months (2, 4, 6, 8, 10) have 29 days
+ // Month 12 has 30 days in leap years, 29 otherwise
+ if (month % 2 == 1)
+ {
+ return 30;
+ }
+
+ if (month == 12 && IsIslamicCivilLeapYear(year))
+ {
+ return 30;
+ }
+
+ return 29;
+ }
+
+ ///
+ /// Returns whether the given year is a leap year in the Islamic Civil calendar.
+ /// Uses the 30-year cycle: years 2, 5, 7, 10, 13, 16, 18, 21, 24, 26, 29 are leap years.
+ ///
+ private static bool IsIslamicCivilLeapYear(int year)
+ {
+ return (14 + 11 * year) % 30 < 11;
+ }
+
+ ///
+ /// Converts an Islamic Civil (tabular) calendar date to ISO date via Julian Day Number.
+ /// The Islamic Civil calendar uses a fixed 30-year cycle with known leap year positions.
+ /// Epoch: July 16, 622 CE Julian (civil epoch, Friday).
+ ///
+ private static IsoDate? IslamicCivilDateToISO(int year, int month, int day, string overflow)
+ {
+ // Validate/constrain in the Islamic calendar system first
+ if (string.Equals(overflow, "constrain", StringComparison.Ordinal))
+ {
+ month = Clamp(month, 1, 12);
+ day = Clamp(day, 1, IslamicCivilDaysInMonth(year, month));
+ }
+ else // "reject"
+ {
+ if (month < 1 || month > 12 || day < 1 || day > IslamicCivilDaysInMonth(year, month))
+ {
+ return null;
+ }
+ }
+
+ // Convert to Julian Day Number
+ // Formula from Calendrical Calculations (Reingold & Dershowitz)
+ var jdn = IslamicToJulianDay(year, month, day, 1948439L);
+ return JulianDayToISO(jdn);
+ }
+
+ ///
+ /// Converts an Islamic Tabular (Thursday epoch) calendar date to ISO date via Julian Day Number.
+ /// Similar to Islamic Civil but with a different epoch (Thursday instead of Friday).
+ ///
+ private static IsoDate? IslamicTblaDateToISO(int year, int month, int day, string overflow)
+ {
+ // Same leap year and month structure as Islamic Civil
+ if (string.Equals(overflow, "constrain", StringComparison.Ordinal))
+ {
+ month = Clamp(month, 1, 12);
+ day = Clamp(day, 1, IslamicCivilDaysInMonth(year, month));
+ }
+ else
+ {
+ if (month < 1 || month > 12 || day < 1 || day > IslamicCivilDaysInMonth(year, month))
+ {
+ return null;
+ }
+ }
- return null; // Unsupported calendar
+ // Islamic Tabular uses Thursday epoch (1 day earlier than Civil)
+ var jdn = IslamicToJulianDay(year, month, day, 1948438L);
+ return JulianDayToISO(jdn);
+ }
+
+ ///
+ /// Computes the Julian Day Number for an Islamic calendar date with the given epoch.
+ ///
+ private static long IslamicToJulianDay(int year, int month, int day, long epoch)
+ {
+ var monthDays = (long) System.Math.Ceiling(29.5001 * (month - 1));
+ var yearDays = (year - 1) * 354L + (long) System.Math.Floor((3.0 + 11.0 * year) / 30.0);
+ return monthDays + yearDays + day + epoch;
+ }
+
+ ///
+ /// Converts a Julian Day Number to an ISO (proleptic Gregorian) date.
+ /// Uses the standard algorithm for JDN to Gregorian conversion.
+ ///
+ private static IsoDate? JulianDayToISO(long jdn)
+ {
+ // Algorithm from Meeus, "Astronomical Algorithms"
+ var a = jdn + 32044L;
+ var b = (4 * a + 3) / 146097;
+ var c = a - 146097 * b / 4;
+ var d = (4 * c + 3) / 1461;
+ var e = c - 1461 * d / 4;
+ var m = (5 * e + 2) / 153;
+
+ var isoDay = (int) (e - (153 * m + 2) / 5 + 1);
+ var isoMonth = (int) (m + 3 - 12 * (m / 10));
+ var isoYear = (int) (100 * b + d - 4800 + m / 10);
+
+ if (!IsValidIsoDateTime(isoYear, isoMonth, isoDay))
+ {
+ return null;
+ }
+
+ return new IsoDate(isoYear, isoMonth, isoDay);
+ }
+
+ ///
+ /// Returns true if the calendar uses the same Gregorian (proleptic) arithmetic as iso8601.
+ /// These calendars differ only in era/epoch, not in month/day/year calculations.
+ ///
+ internal static bool IsGregorianBasedCalendar(string calendar)
+ {
+ return calendar is "iso8601" or "gregory" or "japanese" or "roc" or "buddhist";
+ }
+
+ ///
+ /// Returns true if the calendar supports era/eraYear fields.
+ ///
+ internal static bool CalendarUsesEras(string calendar)
+ {
+ return calendar is "gregory" or "japanese" or "roc" or "buddhist" or "coptic" or "ethiopic" or "ethioaa" or "indian"
+ or "hebrew" or "islamic-civil" or "islamic-tbla" or "islamic-umalqura" or "persian";
+ }
+
+ ///
+ /// Returns the era string for a given calendar and ISO year, or null if not supported.
+ ///
+ internal static string? CalendarEra(string calendar, int isoYear)
+ {
+ switch (calendar)
+ {
+ case "gregory":
+ return isoYear >= 1 ? "ce" : "bce";
+ case "roc":
+ return isoYear >= 1912 ? "minguo" : "before-roc";
+ case "buddhist":
+ return "be";
+ case "japanese":
+ // Simplified: use CE/BCE for years outside specific eras
+ if (isoYear >= 2019) return "reiwa";
+ if (isoYear >= 1989) return "heisei";
+ if (isoYear >= 1926) return "showa";
+ if (isoYear >= 1912) return "taisho";
+ if (isoYear >= 1868) return "meiji";
+ return isoYear >= 1 ? "ce" : "bce";
+ default:
+ return null;
+ }
+ }
+
+ ///
+ /// Returns the eraYear for a given calendar and ISO year, or null if not supported.
+ ///
+ internal static int? CalendarEraYear(string calendar, int isoYear)
+ {
+ switch (calendar)
+ {
+ case "gregory":
+ return isoYear >= 1 ? isoYear : 1 - isoYear;
+ case "roc":
+ return isoYear >= 1912 ? isoYear - 1911 : 1912 - isoYear;
+ case "buddhist":
+ return isoYear + 543;
+ case "japanese":
+ if (isoYear >= 2019) return isoYear - 2018;
+ if (isoYear >= 1989) return isoYear - 1988;
+ if (isoYear >= 1926) return isoYear - 1925;
+ if (isoYear >= 1912) return isoYear - 1911;
+ if (isoYear >= 1868) return isoYear - 1867;
+ return isoYear >= 1 ? isoYear : 1 - isoYear;
+ default:
+ return null;
+ }
+ }
+
+ ///
+ /// Returns the calendar-specific year for Gregorian-based calendars.
+ /// For non-Gregorian-based calendars, returns the ISO year unchanged (best effort).
+ ///
+ internal static int CalendarYear(string calendar, int isoYear)
+ {
+ switch (calendar)
+ {
+ case "gregory":
+ return isoYear;
+ case "roc":
+ return isoYear - 1911;
+ case "buddhist":
+ return isoYear + 543;
+ case "japanese":
+ return isoYear; // Japanese year getter returns ISO year
+ default:
+ return isoYear;
+ }
+ }
+
+ ///
+ /// Reads era and eraYear properties from a property bag for era-supporting calendars.
+ /// Returns the computed year if era/eraYear are present, or null if they should be ignored.
+ /// Throws TypeError if only one of era/eraYear is present.
+ /// Throws RangeError if eraYear is invalid (Infinity, NaN, etc.).
+ ///
+ internal static int? ReadEraFields(Realm realm, ObjectInstance obj, string calendar)
+ {
+ if (!CalendarUsesEras(calendar))
+ {
+ // For calendars that don't use eras, era/eraYear are not read at all
+ return null;
+ }
+
+ var eraValue = obj.Get("era");
+ var eraYearValue = obj.Get("eraYear");
+
+ var hasEra = !eraValue.IsUndefined();
+ var hasEraYear = !eraYearValue.IsUndefined();
+
+ if (hasEra && hasEraYear)
+ {
+ // Convert era to string
+ var era = TypeConverter.ToString(eraValue);
+ // Convert eraYear to integer (this throws RangeError for Infinity/NaN)
+ var eraYear = ToIntegerWithTruncationAsInt(realm, eraYearValue);
+
+ // Compute year from era + eraYear using calendar-specific era mapping
+ return ComputeYearFromEra(realm, calendar, era, eraYear);
+ }
+
+ if (hasEra || hasEraYear)
+ {
+ // Only one of era/eraYear is present - this is an error
+ Throw.TypeError(realm, "Both era and eraYear must be provided together");
+ }
+
+ // Neither era nor eraYear present
+ return null;
+ }
+
+ ///
+ /// Computes the ISO year from a calendar-specific era and eraYear.
+ /// Throws RangeError if the era is not valid for the given calendar.
+ ///
+ private static int ComputeYearFromEra(Realm realm, string calendar, string era, int eraYear)
+ {
+ switch (calendar)
+ {
+ case "gregory":
+ if (era is "gregory" or "ce" or "ad")
+ return eraYear;
+ if (era is "gregory-inverse" or "bce" or "bc")
+ return 1 - eraYear;
+ break;
+ case "japanese":
+ if (era is "reiwa" or "heisei" or "showa" or "taisho" or "meiji" or "ce" or "bce")
+ return eraYear; // simplified
+ break;
+ case "roc":
+ if (era is "minguo" or "roc")
+ return eraYear;
+ if (era is "before-roc" or "before-roc-inverse")
+ return 1 - eraYear;
+ break;
+ case "buddhist":
+ if (era is "buddhist" or "be")
+ return eraYear;
+ break;
+ case "coptic":
+ if (era is "coptic" or "era1")
+ return eraYear;
+ if (era is "coptic-inverse" or "era0")
+ return 1 - eraYear;
+ break;
+ case "ethiopic":
+ if (era is "ethiopic" or "incar" or "era1")
+ return eraYear;
+ if (era is "ethiopic-inverse" or "era0")
+ return 1 - eraYear;
+ if (era is "ethioaa" or "mundi")
+ return eraYear; // different epoch
+ break;
+ case "ethioaa":
+ if (era is "ethioaa" or "mundi")
+ return eraYear;
+ break;
+ case "indian":
+ if (era is "saka" or "indian")
+ return eraYear;
+ break;
+ case "hebrew":
+ if (era is "am" or "hebrew")
+ return eraYear;
+ break;
+ case "islamic-civil":
+ case "islamic-tbla":
+ case "islamic-umalqura":
+ if (era is "islamic" or "ah")
+ return eraYear;
+ break;
+ case "persian":
+ if (era is "persian" or "ap")
+ return eraYear;
+ break;
+ }
+
+ Throw.RangeError(realm, $"Invalid era '{era}' for calendar '{calendar}'");
+ return 0;
}
///
@@ -1623,7 +2089,7 @@ internal static bool LooksLikeIsoDateString(string input)
/// Extracts the calendar annotation from an ISO date/datetime string.
/// Returns the calendar ID if found and valid, empty string if found but invalid, null if not found.
///
- private static string? ExtractCalendarFromIsoString(string input)
+ internal static string? ExtractCalendarFromIsoString(string input)
{
// Look for [u-ca=...] annotation
var bracketStart = input.IndexOf('[');
@@ -1643,13 +2109,9 @@ internal static bool LooksLikeIsoDateString(string input)
var calendarStart = ucaIndex + 5;
var calendarValue = annotation.Substring(calendarStart);
var lower = ToAsciiLowerCase(calendarValue);
- if (string.Equals(lower, "iso8601", StringComparison.Ordinal))
- {
- return "iso8601";
- }
- // Other calendars not supported - return empty string to indicate "found but invalid"
- return string.Empty;
+ // Return the extracted calendar value for canonicalization
+ return lower;
}
bracketStart = input.IndexOf('[', bracketEnd);
@@ -1658,6 +2120,24 @@ internal static bool LooksLikeIsoDateString(string input)
return null; // No calendar annotation found
}
+ ///
+ /// Extracts and canonicalizes the calendar from a string, defaulting to "iso8601".
+ ///
+ internal static string ExtractCalendarIdentifierFromString(string input)
+ {
+ var extracted = ExtractCalendarFromIsoString(input);
+ if (extracted is not null)
+ {
+ var canonical = CanonicalizeCalendar(extracted);
+ if (canonical is not null)
+ {
+ return canonical;
+ }
+ }
+
+ return "iso8601";
+ }
+
///
/// Converts a string to ASCII lower case (not Turkish İ).
///
@@ -1730,8 +2210,8 @@ public static IsoYearMonthRecord BalanceISOYearMonth(int year, int month)
///
public static IsoDate CalendarDateAdd(Realm? realm, string calendar, IsoDate isoDate, DurationRecord duration, string overflow)
{
- // For ISO calendar
- if (string.Equals(calendar, "iso8601", StringComparison.Ordinal))
+ // For ISO calendar and Gregorian-based calendars (they share the same arithmetic)
+ if (IsGregorianBasedCalendar(calendar))
{
// Step 1: Add years and months, then balance
var balanced = BalanceISOYearMonth(
@@ -3498,13 +3978,16 @@ private static RelativeToFields PrepareCalendarFieldsForRelativeTo(
}
// Step 2: Read all other fields in alphabetical order
- // Property keys in alphabetical order: day, hour, microsecond, millisecond, minute,
+ // Property keys in alphabetical order: day, era, eraYear, hour, microsecond, millisecond, minute,
// month, monthCode, nanosecond, offset, second, timeZone, year
// day - to-positive-integer-with-truncation
var dayValue = fields.Get("day");
int? day = dayValue.IsUndefined() ? null : ToPositiveIntegerWithTruncation(realm, dayValue);
+ // era/eraYear - read for era-supporting calendars (alphabetically between day and hour)
+ var eraYear = ReadEraFields(realm, fields, calendar);
+
// hour - to-integer-with-truncation
var hourValue = fields.Get("hour");
int? hour = hourValue.IsUndefined()
@@ -3597,11 +4080,20 @@ private static RelativeToFields PrepareCalendarFieldsForRelativeTo(
engine, realm, timeZoneValue);
}
- // year - to-integer-with-truncation
- var yearValue = fields.Get("year");
- int? year = yearValue.IsUndefined()
- ? null
- : (int) ToIntegerWithTruncation(realm, yearValue);
+ // year - use eraYear if computed, otherwise read from property
+ int? year;
+ if (eraYear.HasValue)
+ {
+ year = eraYear.Value;
+ fields.Get("year");
+ }
+ else
+ {
+ var yearValue = fields.Get("year");
+ year = yearValue.IsUndefined()
+ ? null
+ : (int) ToIntegerWithTruncation(realm, yearValue);
+ }
return new RelativeToFields(calendar, day, hour, microsecond, millisecond, minute,
month, monthCode, nanosecond, offset, second, timeZone, year);
@@ -4579,6 +5071,69 @@ public static BigInteger GetEpochNanosecondsFor(
return DisambiguatePossibleEpochNanoseconds(realm, timeZoneProvider, possibleEpochNs, timeZone, isoDateTime, disambiguation);
}
+ ///
+ /// GetStartOfDay ( timeZone, isoDate )
+ /// https://tc39.es/proposal-temporal/#sec-temporal-getstartofday
+ /// Determines the exact time that corresponds to the first valid wall-clock time on the given date in the given timezone.
+ ///
+ public static BigInteger GetStartOfDay(
+ Realm realm,
+ ITimeZoneProvider provider,
+ string timeZone,
+ IsoDate isoDate)
+ {
+ // Step 1: Let isoDateTime be CombineISODateAndTimeRecord(isoDate, MidnightTimeRecord())
+ var midnight = new IsoDateTime(isoDate, new IsoTime(0, 0, 0, 0, 0, 0));
+
+ // Step 2: Let possibleEpochNs be GetPossibleEpochNanoseconds(timeZone, isoDateTime)
+ var possibleEpochNs = GetPossibleEpochNanoseconds(realm, provider, timeZone, midnight);
+
+ // Step 3: If possibleEpochNs is not empty, return possibleEpochNs[0]
+ if (possibleEpochNs.Length > 0)
+ {
+ return possibleEpochNs[0];
+ }
+
+ // Step 5: Midnight is in a DST gap. Find the first valid local time after midnight.
+ // The transition instant that creates the gap IS the start of day.
+ // Search for the transition near UTC midnight for this date.
+ var utcMidnightNs = (BigInteger) IsoDateToDays(isoDate.Year, isoDate.Month, isoDate.Day) * NanosecondsPerDay;
+
+ // Search from 24 hours before UTC midnight to cover all possible timezone offsets (max ±14h)
+ var searchFrom = utcMidnightNs - NanosecondsPerDay;
+ var transition = provider.GetNextTransition(timeZone, searchFrom);
+
+ // Find the transition whose new local time lands on our target date
+ while (transition.HasValue && transition.Value < utcMidnightNs + NanosecondsPerDay)
+ {
+ // Get the offset AFTER the transition
+ var offsetAfter = provider.GetOffsetNanosecondsFor(timeZone, transition.Value);
+ var localNsAfter = transition.Value + offsetAfter;
+
+ // Check if the local time after transition is on our target date and >= 00:00
+ var localDays = localNsAfter / NanosecondsPerDay;
+ var localTimeNs = localNsAfter - localDays * NanosecondsPerDay;
+ if (localTimeNs < 0)
+ {
+ localDays--;
+ localTimeNs += NanosecondsPerDay;
+ }
+
+ var targetDays = (BigInteger) IsoDateToDays(isoDate.Year, isoDate.Month, isoDate.Day);
+ if (localDays == targetDays)
+ {
+ return transition.Value;
+ }
+
+ // Move to next transition
+ transition = provider.GetNextTransition(timeZone, transition.Value);
+ }
+
+ // Fallback: should not reach here for valid timezones
+ Throw.RangeError(realm, "Could not determine start of day");
+ return BigInteger.Zero;
+ }
+
///
/// GetISODateTimeFor ( timeZone, epochNanoseconds )
/// Converts epoch nanoseconds to an ISO date-time in the given timezone.
@@ -4694,7 +5249,13 @@ private static DurationRecord DifferenceZonedDateTime(
var intermediateDateTime = new IsoDateTime(intermediateDate, startDateTime.Time);
// Step 6: Get epoch nanoseconds for intermediate
- var intermediateNs = GetEpochNanosecondsFor(realm, timeZoneProvider, timeZone, intermediateDateTime, "compatible");
+ // Per spec (issue #3141): when the intermediate datetime equals the start datetime,
+ // use ns1 directly to avoid re-disambiguation during DST fall-back
+ var intermediateNs = (intermediateDate.Year == startDateTime.Year &&
+ intermediateDate.Month == startDateTime.Month &&
+ intermediateDate.Day == startDateTime.Day)
+ ? ns1
+ : GetEpochNanosecondsFor(realm, timeZoneProvider, timeZone, intermediateDateTime, "compatible");
// Step 7: Compute time difference from actual epoch nanoseconds
var timeDifference = TimeDurationFromEpochNanosecondsDifference(ns2, intermediateNs);
@@ -5703,8 +6264,8 @@ internal static DurationRecord CalendarDateUntil(string calendar, IsoDate one, I
return new DurationRecord(0, 0, 0, 0, 0, 0, 0, 0, 0, 0);
}
- // Step 3: For ISO calendar
- if (!string.Equals(calendar, "iso8601", StringComparison.Ordinal))
+ // Step 3: For ISO and Gregorian-based calendars (same arithmetic)
+ if (!IsGregorianBasedCalendar(calendar))
{
throw new NotSupportedException($"Calendar '{calendar}' not yet supported");
}
diff --git a/Jint/Native/Temporal/ZonedDateTime/ZonedDateTimeConstructor.cs b/Jint/Native/Temporal/ZonedDateTime/ZonedDateTimeConstructor.cs
index 5197138e8a..167fdd3afe 100644
--- a/Jint/Native/Temporal/ZonedDateTime/ZonedDateTimeConstructor.cs
+++ b/Jint/Native/Temporal/ZonedDateTime/ZonedDateTimeConstructor.cs
@@ -203,6 +203,9 @@ private JsZonedDateTime ToZonedDateTimeFromFieldsWithOptions(ObjectInstance obj,
var day = TemporalHelpers.ToPositiveIntegerWithTruncation(_realm, dayValue);
+ // 2.5. era/eraYear - read for era-supporting calendars (alphabetically between day and hour)
+ var eraYear = TemporalHelpers.ReadEraFields(_realm, obj, calendar);
+
// 3. hour - read and convert immediately
var hourValue = obj.Get("hour");
var hour = hourValue.IsUndefined() ? 0 : TemporalHelpers.ToIntegerWithTruncationAsInt(_realm, hourValue);
@@ -302,14 +305,23 @@ private JsZonedDateTime ToZonedDateTimeFromFieldsWithOptions(ObjectInstance obj,
var timeZone = ToTemporalTimeZoneIdentifier(timeZoneProp);
- // 13. year - read and convert immediately (TYPE validation happens here)
- var yearValue = obj.Get("year");
- if (yearValue.IsUndefined())
+ // 13. year - use eraYear if computed, otherwise read from property
+ int year;
+ if (eraYear.HasValue)
{
- Throw.TypeError(_realm, "Missing required property: year");
+ year = eraYear.Value;
+ obj.Get("year");
}
+ else
+ {
+ var yearValue = obj.Get("year");
+ if (yearValue.IsUndefined())
+ {
+ Throw.TypeError(_realm, "Missing required property: year");
+ }
- var year = TemporalHelpers.ToIntegerWithTruncationAsInt(_realm, yearValue);
+ year = TemporalHelpers.ToIntegerWithTruncationAsInt(_realm, yearValue);
+ }
// 14-16. Read options AFTER all fields(but BEFORE algorithmic validation)
// Alphabetical order: disambiguation, offset, overflow
@@ -317,8 +329,8 @@ private JsZonedDateTime ToZonedDateTimeFromFieldsWithOptions(ObjectInstance obj,
var offsetOption = GetOffsetOption(options);
var overflow = TemporalHelpers.GetOverflowOption(_realm, options);
- // NOW validate monthCode suitability(ISO calendar checks) - after year type validation AND options reading
- if (monthCodeStr is not null)
+ // Validate monthCode suitability - only for ISO/Gregorian calendars
+ if (monthCodeStr is not null && TemporalHelpers.IsGregorianBasedCalendar(calendar))
{
// For ISO 8601 calendar: validate monthCode is valid (01-12, no leap months)
if (monthCodeStr.Length == 4 && monthCodeStr[3] == 'L')
@@ -337,8 +349,8 @@ private JsZonedDateTime ToZonedDateTimeFromFieldsWithOptions(ObjectInstance obj,
Throw.TypeError(_realm, "month or monthCode is required");
}
- // Regulate date
- var date = TemporalHelpers.RegulateIsoDate(year, month, day, overflow);
+ // Regulate date (with calendar conversion for non-ISO calendars)
+ var date = TemporalHelpers.CalendarDateToISO(_realm, calendar, year, month, day, overflow);
if (date is null)
{
Throw.RangeError(_realm, "Invalid date");
@@ -418,10 +430,56 @@ private JsZonedDateTime ParseZonedDateTimeString(string input, string overflow,
var timeZone = ToTemporalTimeZoneIdentifier(new JsString(result.TimeZone));
- // Convert to epoch nanoseconds
- var epochNs = GetEpochFromIsoDateTime(result.DateTime.Value, timeZone, disambiguation, result.OffsetNanoseconds, result.HasUtcDesignator, offsetOption);
+ // Per spec ToTemporalZonedDateTime steps 15-21:
+ // Determine offsetBehaviour
+ string offsetBehaviour;
+ if (result.HasUtcDesignator)
+ {
+ offsetBehaviour = "exact";
+ }
+ else if (result.OffsetNanoseconds.HasValue)
+ {
+ offsetBehaviour = "option";
+ }
+ else
+ {
+ offsetBehaviour = "wall";
+ }
+
+ // Per spec step 3/12: For strings, matchBehaviour = ~match-minutes~
+ // Unless offset has sub-minute precision (seconds), then ~match-exactly~
+ var matchBehaviour = result.OffsetHasSubMinutePrecision ? "match-exactly" : "match-minutes";
- return Construct(epochNs, timeZone, result.Calendar ?? "iso8601");
+ var offsetNs = result.OffsetNanoseconds ?? 0;
+
+ var provider = _engine.Options.Temporal.TimeZoneProvider;
+ var isoDateTime = result.DateTime.Value;
+
+ BigInteger epochNs;
+ if (result.TimeIsStartOfDay)
+ {
+ // Per spec InterpretISODateTimeOffset step 1: time is ~start-of-day~
+ epochNs = TemporalHelpers.GetStartOfDay(_realm, provider, timeZone, isoDateTime.Date);
+ }
+ else
+ {
+ // Convert to epoch nanoseconds using the spec's InterpretISODateTimeOffset
+ epochNs = TemporalHelpers.InterpretISODateTimeOffset(
+ _realm,
+ provider,
+ isoDateTime.Date,
+ isoDateTime.Time,
+ offsetBehaviour,
+ offsetNs,
+ timeZone,
+ disambiguation,
+ offsetOption,
+ matchBehaviour);
+ }
+
+ var calendarId = result.Calendar ?? "iso8601";
+ var canonicalCalendar = TemporalHelpers.CanonicalizeCalendar(calendarId) ?? calendarId;
+ return Construct(epochNs, timeZone, canonicalCalendar);
}
private BigInteger GetEpochFromIsoDateTime(IsoDateTime dateTime, string timeZone, string disambiguation, long? offsetNs, bool hasUtcDesignator, string offsetOption)
@@ -549,89 +607,7 @@ private BigInteger GetEpochFromIsoDateTime(IsoDateTime dateTime, string timeZone
private BigInteger GetInstantFor(ITimeZoneProvider provider, string timeZone, IsoDateTime dateTime, string disambiguation)
{
- var possibleInstants = provider.GetPossibleInstantsFor(timeZone,
- dateTime.Year, dateTime.Month, dateTime.Day,
- dateTime.Hour, dateTime.Minute, dateTime.Second,
- dateTime.Millisecond, dateTime.Microsecond, dateTime.Nanosecond);
-
- // Per spec GetPossibleEpochNanoseconds, validate each possible instant is within valid range
- // https://tc39.es/proposal-temporal/#sec-temporal-getpossibleepochnanoseconds step 4
- foreach (var instant in possibleInstants)
- {
- if (!InstantConstructor.IsValidEpochNanoseconds(instant))
- {
- Throw.RangeError(_realm, "Instant is outside the valid range");
- }
- }
-
- if (possibleInstants.Length == 1)
- {
- return possibleInstants[0];
- }
-
- if (possibleInstants.Length == 0)
- {
- // Gap (spring forward) - time doesn't exist
- if (string.Equals(disambiguation, "reject", StringComparison.Ordinal))
- {
- Throw.RangeError(_realm, "The specified date-time does not exist in the time zone (gap)");
- }
-
- // Find the transition point
- var localNs = IsoDateTimeToNanoseconds(dateTime);
-
- if (string.Equals(disambiguation, "earlier", StringComparison.Ordinal))
- {
- // Use the instant just before the gap
- // Subtract one nanosecond from where the gap starts
- var beforeGap = provider.GetPossibleInstantsFor(timeZone,
- dateTime.Year, dateTime.Month, dateTime.Day,
- dateTime.Hour - 1, dateTime.Minute, dateTime.Second,
- dateTime.Millisecond, dateTime.Microsecond, dateTime.Nanosecond);
- if (beforeGap.Length > 0)
- {
- return beforeGap[beforeGap.Length - 1];
- }
- }
-
- // Default to 'compatible' or 'later' - use the instant after the gap
- // The gap is typically 1 hour, so add 1 hour to the local time
- var afterGapDateTime = AddHoursToIsoDateTime(dateTime, 1);
- var afterGapInstants = provider.GetPossibleInstantsFor(timeZone,
- afterGapDateTime.Year, afterGapDateTime.Month, afterGapDateTime.Day,
- afterGapDateTime.Hour, afterGapDateTime.Minute, afterGapDateTime.Second,
- afterGapDateTime.Millisecond, afterGapDateTime.Microsecond, afterGapDateTime.Nanosecond);
-
- if (afterGapInstants.Length > 0)
- {
- return afterGapInstants[0];
- }
-
- // Fallback: estimate the instant
- var standardOffset = provider.GetOffsetNanosecondsFor(timeZone, BigInteger.Zero);
- return localNs - standardOffset;
- }
-
- // Ambiguous (fall back) - multiple possible instants
- if (string.Equals(disambiguation, "reject", StringComparison.Ordinal))
- {
- Throw.RangeError(_realm, "The specified date-time is ambiguous in the time zone (overlap)");
- }
-
- if (string.Equals(disambiguation, "earlier", StringComparison.Ordinal))
- {
- // Use the earlier instant (before the clocks were set back)
- return possibleInstants[0];
- }
-
- if (string.Equals(disambiguation, "later", StringComparison.Ordinal))
- {
- // Use the later instant (after the clocks were set back)
- return possibleInstants[possibleInstants.Length - 1];
- }
-
- // Default 'compatible' - use the earlier instant (same as 'earlier')
- return possibleInstants[0];
+ return TemporalHelpers.GetEpochNanosecondsFor(_realm, provider, timeZone, dateTime, disambiguation);
}
private static IsoDateTime AddHoursToIsoDateTime(IsoDateTime dateTime, int hours)
@@ -697,7 +673,7 @@ private static ParsedZonedDateTimeResult ParseZonedDateTimeStringInternal(string
var dateResult = TemporalHelpers.ParseIsoDate(datePart);
if (dateResult is null)
{
- return new ParsedZonedDateTimeResult(null, null, null, null, false, "Invalid date");
+ return new ParsedZonedDateTimeResult(null, null, null, null, false, false, false, "Invalid date");
}
// Default time to 00:00:00
@@ -740,15 +716,15 @@ private static ParsedZonedDateTimeResult ParseZonedDateTimeStringInternal(string
// Validate: No junk after the last bracket annotation
if (lastBracketEnd >= 0 && lastBracketEnd + 1 < input.Length)
{
- return new ParsedZonedDateTimeResult(null, null, null, null, false, "Unexpected characters after valid content");
+ return new ParsedZonedDateTimeResult(null, null, null, null, false, false, false, "Unexpected characters after valid content");
}
if (parsedTimeZone is null)
{
- return new ParsedZonedDateTimeResult(null, null, null, null, false, "ZonedDateTime string must include timezone annotation");
+ return new ParsedZonedDateTimeResult(null, null, null, null, false, false, false, "ZonedDateTime string must include timezone annotation");
}
- return new ParsedZonedDateTimeResult(parsedDateTime, parsedTimeZone, parsedCalendar, null, false, null);
+ return new ParsedZonedDateTimeResult(parsedDateTime, parsedTimeZone, parsedCalendar, null, false, false, true, null);
}
var dateString = input.Substring(0, tIndex);
@@ -757,7 +733,7 @@ private static ParsedZonedDateTimeResult ParseZonedDateTimeStringInternal(string
var date = TemporalHelpers.ParseIsoDate(dateString);
if (date is null)
{
- return new ParsedZonedDateTimeResult(null, null, null, null, false, "Invalid date");
+ return new ParsedZonedDateTimeResult(null, null, null, null, false, false, false, "Invalid date");
}
// Parse time and extract offset/time zone/calendar
@@ -767,6 +743,7 @@ private static ParsedZonedDateTimeResult ParseZonedDateTimeStringInternal(string
string? timeZone = null;
string? calendar = null;
long? offsetNs = null;
+ var offsetHasSubMinutePrecision = false;
// Find where time ends
for (var i = 0; i < remainder.Length; i++)
@@ -801,7 +778,7 @@ private static ParsedZonedDateTimeResult ParseZonedDateTimeStringInternal(string
var time = TemporalHelpers.ParseIsoTime(timeString);
if (time is null)
{
- return new ParsedZonedDateTimeResult(null, null, null, null, false, "Invalid time");
+ return new ParsedZonedDateTimeResult(null, null, null, null, false, false, false, "Invalid time");
}
// Parse offset if present
@@ -827,8 +804,14 @@ private static ParsedZonedDateTimeResult ParseZonedDateTimeStringInternal(string
offsetNs = TemporalHelpers.ParseOffsetString(offsetStr);
if (offsetNs is null)
{
- return new ParsedZonedDateTimeResult(null, null, null, null, false, "Invalid offset");
+ return new ParsedZonedDateTimeResult(null, null, null, null, false, false, false, "Invalid offset");
}
+
+ // Per spec: detect sub-minute precision in offset string
+ // UTCOffset[+SubMinutePrecision] with more than one MinuteSecond means it has seconds
+ // Format: ±HH:MM (6 chars) = minute precision, ±HH:MM:SS or longer = sub-minute precision
+ // Also handle ±HHMM (5 chars) vs ±HHMMSS (7 chars)
+ offsetHasSubMinutePrecision = HasOffsetSubMinutePrecision(offsetStr);
}
// Parse bracket annotations
@@ -844,7 +827,7 @@ private static ParsedZonedDateTimeResult ParseZonedDateTimeStringInternal(string
var endBracket = remainder.IndexOf(']', pos);
if (endBracket < 0)
{
- return new ParsedZonedDateTimeResult(null, null, null, null, false, "Unclosed bracket");
+ return new ParsedZonedDateTimeResult(null, null, null, null, false, false, false, "Unclosed bracket");
}
var annotation = remainder.Substring(pos + 1, endBracket - pos - 1);
@@ -859,7 +842,7 @@ private static ParsedZonedDateTimeResult ParseZonedDateTimeStringInternal(string
var key = content.Substring(0, equalsIndex);
if (!TemporalHelpers.IsLowercaseAnnotationKey(key))
{
- return new ParsedZonedDateTimeResult(null, null, null, null, false, "Annotation keys must be lowercase");
+ return new ParsedZonedDateTimeResult(null, null, null, null, false, false, false, "Annotation keys must be lowercase");
}
if (content.StartsWith("u-ca=", StringComparison.Ordinal))
@@ -879,7 +862,7 @@ private static ParsedZonedDateTimeResult ParseZonedDateTimeStringInternal(string
else if (isCritical)
{
// Unknown key annotation with critical flag
- return new ParsedZonedDateTimeResult(null, null, null, null, false, "Critical unknown annotation");
+ return new ParsedZonedDateTimeResult(null, null, null, null, false, false, false, "Critical unknown annotation");
}
// Non-critical unknown annotations are accepted but ignored
}
@@ -889,7 +872,7 @@ private static ParsedZonedDateTimeResult ParseZonedDateTimeStringInternal(string
timeZoneCount++;
if (timeZoneCount > 1)
{
- return new ParsedZonedDateTimeResult(null, null, null, null, false, "Multiple time zone annotations");
+ return new ParsedZonedDateTimeResult(null, null, null, null, false, false, false, "Multiple time zone annotations");
}
timeZone = content;
@@ -901,19 +884,19 @@ private static ParsedZonedDateTimeResult ParseZonedDateTimeStringInternal(string
// Validate: Multiple calendar annotations with any critical flag is invalid
if (calendarCount > 1 && hasCriticalCalendar)
{
- return new ParsedZonedDateTimeResult(null, null, null, null, false, "Multiple calendar annotations with critical flag");
+ return new ParsedZonedDateTimeResult(null, null, null, null, false, false, false, "Multiple calendar annotations with critical flag");
}
// Validate: No junk after the last bracket annotation
if (pos < remainder.Length)
{
- return new ParsedZonedDateTimeResult(null, null, null, null, false, "Unexpected characters after valid content");
+ return new ParsedZonedDateTimeResult(null, null, null, null, false, false, false, "Unexpected characters after valid content");
}
// ZonedDateTime requires a time zone
if (timeZone is null)
{
- return new ParsedZonedDateTimeResult(null, null, null, null, false, "ZonedDateTime string must include a time zone");
+ return new ParsedZonedDateTimeResult(null, null, null, null, false, false, false, "ZonedDateTime string must include a time zone");
}
var dateTime = new IsoDateTime(date.Value, time.Value);
@@ -923,7 +906,7 @@ private static ParsedZonedDateTimeResult ParseZonedDateTimeStringInternal(string
// For offset="use", only the resulting instant needs to be valid
// For offset="prefer"/"reject", the wall-clock datetime needs to be valid (checked via CheckISODaysRange)
- return new ParsedZonedDateTimeResult(dateTime, timeZone, calendar, offsetNs, hasUtcDesignator, null);
+ return new ParsedZonedDateTimeResult(dateTime, timeZone, calendar, offsetNs, hasUtcDesignator, offsetHasSubMinutePrecision, false, null);
}
///
@@ -944,12 +927,15 @@ private string ParseConstructorTimeZone(string input)
// If the whole string is just a timezone identifier (IANA name or UTC offset), accept it
var bracketStart = input.IndexOf('[');
var hasDateTimeSeparator = false;
- for (var i = 4; i < input.Length; i++)
+ if (input.Length > 4 && char.IsDigit(input[0]))
{
- if (input[i] == 'T' || input[i] == 't')
+ for (var i = 4; i < input.Length; i++)
{
- hasDateTimeSeparator = true;
- break;
+ if (input[i] == 'T' || input[i] == 't')
+ {
+ hasDateTimeSeparator = true;
+ break;
+ }
}
}
@@ -1088,7 +1074,26 @@ private string GetOffsetOption(JsValue options)
}
///
- /// Checks if an annotation key is valid (lowercase letters, digits, and hyphens only).
+ /// Per spec: determines if an offset string has sub-minute precision (contains seconds).
+ /// Used to set matchBehaviour to ~match-exactly~ vs ~match-minutes~.
+ /// https://tc39.es/proposal-temporal/#sec-temporal-totemporalzoneddatetime step 23-24
///
- private readonly record struct ParsedZonedDateTimeResult(IsoDateTime? DateTime, string? TimeZone, string? Calendar, long? OffsetNanoseconds, bool HasUtcDesignator, string? Error);
+ private static bool HasOffsetSubMinutePrecision(string offsetStr)
+ {
+ // ±HH (3 chars) or ±HH:MM (6 chars) or ±HHMM (5 chars) = minute precision
+ // ±HH:MM:SS (9+ chars) or ±HHMMSS (7 chars) = sub-minute precision
+ if (offsetStr.Length <= 3)
+ return false; // ±HH
+
+ if (offsetStr[3] == ':')
+ {
+ // Colon format: ±HH:MM = 6 chars, ±HH:MM:SS = 9+ chars
+ return offsetStr.Length > 6;
+ }
+
+ // No-colon format: ±HHMM = 5 chars, ±HHMMSS = 7 chars
+ return offsetStr.Length > 5;
+ }
+
+ private readonly record struct ParsedZonedDateTimeResult(IsoDateTime? DateTime, string? TimeZone, string? Calendar, long? OffsetNanoseconds, bool HasUtcDesignator, bool OffsetHasSubMinutePrecision, bool TimeIsStartOfDay, string? Error);
}
diff --git a/Jint/Native/Temporal/ZonedDateTime/ZonedDateTimePrototype.cs b/Jint/Native/Temporal/ZonedDateTime/ZonedDateTimePrototype.cs
index 3ee33abf3d..cbc126d1e3 100644
--- a/Jint/Native/Temporal/ZonedDateTime/ZonedDateTimePrototype.cs
+++ b/Jint/Native/Temporal/ZonedDateTime/ZonedDateTimePrototype.cs
@@ -111,20 +111,26 @@ private JsString GetTimeZoneId(JsValue thisObject, JsCallArguments arguments) =>
private JsValue GetEra(JsValue thisObject, JsCallArguments arguments)
{
- ValidateZonedDateTime(thisObject);
- // ISO 8601 calendar doesn't have eras
- return Undefined;
+ var zdt = ValidateZonedDateTime(thisObject);
+ var isoDateTime = zdt.GetIsoDateTime();
+ var era = TemporalHelpers.CalendarEra(zdt.Calendar, isoDateTime.Year);
+ return era is not null ? new JsString(era) : Undefined;
}
private JsValue GetEraYear(JsValue thisObject, JsCallArguments arguments)
{
- ValidateZonedDateTime(thisObject);
- // ISO 8601 calendar doesn't have eras
- return Undefined;
+ var zdt = ValidateZonedDateTime(thisObject);
+ var isoDateTime = zdt.GetIsoDateTime();
+ var eraYear = TemporalHelpers.CalendarEraYear(zdt.Calendar, isoDateTime.Year);
+ return eraYear.HasValue ? JsNumber.Create(eraYear.Value) : Undefined;
}
- private JsNumber GetYear(JsValue thisObject, JsCallArguments arguments) =>
- JsNumber.Create(ValidateZonedDateTime(thisObject).GetIsoDateTime().Year);
+ private JsNumber GetYear(JsValue thisObject, JsCallArguments arguments)
+ {
+ var zdt = ValidateZonedDateTime(thisObject);
+ var isoDateTime = zdt.GetIsoDateTime();
+ return JsNumber.Create(TemporalHelpers.CalendarYear(zdt.Calendar, isoDateTime.Year));
+ }
private JsNumber GetMonth(JsValue thisObject, JsCallArguments arguments) =>
JsNumber.Create(ValidateZonedDateTime(thisObject).GetIsoDateTime().Month);
@@ -182,16 +188,26 @@ private JsNumber GetDayOfWeek(JsValue thisObject, JsCallArguments arguments) =>
private JsNumber GetDayOfYear(JsValue thisObject, JsCallArguments arguments) =>
JsNumber.Create(ValidateZonedDateTime(thisObject).GetIsoDateTime().Date.DayOfYear());
- private JsNumber GetWeekOfYear(JsValue thisObject, JsCallArguments arguments)
+ private JsValue GetWeekOfYear(JsValue thisObject, JsCallArguments arguments)
{
var zdt = ValidateZonedDateTime(thisObject);
+ if (!string.Equals(zdt.Calendar, "iso8601", StringComparison.Ordinal))
+ {
+ return Undefined;
+ }
+
var date = zdt.GetIsoDateTime().Date;
return JsNumber.Create(date.WeekOfYear());
}
- private JsNumber GetYearOfWeek(JsValue thisObject, JsCallArguments arguments)
+ private JsValue GetYearOfWeek(JsValue thisObject, JsCallArguments arguments)
{
var zdt = ValidateZonedDateTime(thisObject);
+ if (!string.Equals(zdt.Calendar, "iso8601", StringComparison.Ordinal))
+ {
+ return Undefined;
+ }
+
var date = zdt.GetIsoDateTime().Date;
return JsNumber.Create(date.YearOfWeek());
}
@@ -243,11 +259,9 @@ private JsNumber GetHoursInDay(JsValue thisObject, JsCallArguments arguments)
Throw.RangeError(_realm, "Start of day is outside the valid range");
}
- // Get start of next day
+ // Get start of next day using GetStartOfDay per spec
var nextDay = AddDays(zdt.GetIsoDateTime().Date, 1);
- var nextDayInstants = provider.GetPossibleInstantsFor(zdt.TimeZone,
- nextDay.Year, nextDay.Month, nextDay.Day, 0, 0, 0, 0, 0, 0);
- var startOfNextDay = nextDayInstants.Length > 0 ? nextDayInstants[0] : startOfDay + TemporalHelpers.NanosecondsPerDay;
+ var startOfNextDay = TemporalHelpers.GetStartOfDay(_realm, provider, zdt.TimeZone, nextDay);
if (!InstantConstructor.IsValidEpochNanoseconds(startOfNextDay))
{
@@ -479,25 +493,24 @@ private JsZonedDateTime WithPlainTime(JsValue thisObject, JsCallArguments argume
// https://tc39.es/proposal-temporal/#sec-temporal.zoneddatetime.prototype.withplaintime
var zdt = ValidateZonedDateTime(thisObject);
var plainTimeLike = arguments.At(0);
+ var provider = _engine.Options.Temporal.TimeZoneProvider;
- IsoTime time;
+ BigInteger epochNs;
if (plainTimeLike.IsUndefined())
{
- // Step 6: If plainTimeLike is undefined, let plainTime be start of day
- time = new IsoTime(0, 0, 0, 0, 0, 0);
+ // Step 7: If plainTimeLike is undefined, let epochNs be GetStartOfDay
+ var current = zdt.GetIsoDateTime();
+ epochNs = TemporalHelpers.GetStartOfDay(_realm, provider, zdt.TimeZone, current.Date);
}
else
{
- // Step 8: Let plainTime be ? ToTemporalTime(plainTimeLike)
+ // Step 8-9: Let plainTime be ToTemporalTime, then GetEpochNanosecondsFor with compatible
var plainTime = _realm.Intrinsics.TemporalPlainTime.ToTemporalTime(plainTimeLike, "constrain");
- time = plainTime.IsoTime;
+ var current = zdt.GetIsoDateTime();
+ var newDateTime = new IsoDateTime(current.Date, plainTime.IsoTime);
+ epochNs = GetInstantFor(provider, zdt.TimeZone, newDateTime, "compatible");
}
- var current = zdt.GetIsoDateTime();
- var newDateTime = new IsoDateTime(current.Date, time);
- var provider = _engine.Options.Temporal.TimeZoneProvider;
- var epochNs = GetInstantFor(provider, zdt.TimeZone, newDateTime, "compatible");
-
// Validate result is within valid Temporal range
if (!InstantConstructor.IsValidEpochNanoseconds(epochNs))
{
@@ -674,25 +687,9 @@ private JsValue Round(JsValue thisObject, JsCallArguments arguments)
var dateStart = isoDateTime.Date;
var dateEnd = AddDays(dateStart, 1);
- // Get start of current day in this timezone
- var startInstants = provider.GetPossibleInstantsFor(timeZone,
- dateStart.Year, dateStart.Month, dateStart.Day, 0, 0, 0, 0, 0, 0);
- var startNs = startInstants.Length > 0 ? startInstants[0] : thisNs - (BigInteger) isoDateTime.Time.TotalNanoseconds();
-
- if (!InstantConstructor.IsValidEpochNanoseconds(startNs))
- {
- Throw.RangeError(_realm, "Start of day is outside the valid range");
- }
-
- // Get start of next day in this timezone
- var endInstants = provider.GetPossibleInstantsFor(timeZone,
- dateEnd.Year, dateEnd.Month, dateEnd.Day, 0, 0, 0, 0, 0, 0);
- var endNs = endInstants.Length > 0 ? endInstants[0] : startNs + TemporalHelpers.NanosecondsPerDay;
-
- if (!InstantConstructor.IsValidEpochNanoseconds(endNs))
- {
- Throw.RangeError(_realm, "Start of next day is outside the valid range");
- }
+ // Get start of current day and next day using GetStartOfDay per spec
+ var startNs = TemporalHelpers.GetStartOfDay(_realm, provider, timeZone, dateStart);
+ var endNs = TemporalHelpers.GetStartOfDay(_realm, provider, timeZone, dateEnd);
// Calculate day length (may not be exactly 24 hours due to DST)
var dayLengthNs = (long) (endNs - startNs);
@@ -863,7 +860,7 @@ private JsBoolean Equals(JsValue thisObject, JsCallArguments arguments)
var otherZdt = _constructor.ToTemporalZonedDateTime(other, Undefined);
var sameEpoch = zdt.EpochNanoseconds == otherZdt.EpochNanoseconds;
- var sameTimeZone = string.Equals(zdt.TimeZone, otherZdt.TimeZone, StringComparison.OrdinalIgnoreCase);
+ var sameTimeZone = TemporalHelpers.TimeZoneEquals(_engine, zdt.TimeZone, otherZdt.TimeZone);
var sameCalendar = string.Equals(zdt.Calendar, otherZdt.Calendar, StringComparison.Ordinal);
return sameEpoch && sameTimeZone && sameCalendar ? JsBoolean.True : JsBoolean.False;
@@ -1071,10 +1068,39 @@ private JsString ToJSON(JsValue thisObject, JsCallArguments arguments)
return new JsString(FormatZonedDateTime(zdt, "auto", "auto", "auto", null));
}
- private JsString ToLocaleString(JsValue thisObject, JsCallArguments arguments)
+ ///
+ /// https://tc39.es/proposal-temporal/#sup-temporal.zoneddatetime.prototype.tolocalestring
+ ///
+ private JsValue ToLocaleString(JsValue thisObject, JsCallArguments arguments)
{
var zdt = ValidateZonedDateTime(thisObject);
- return new JsString(FormatZonedDateTime(zdt, "auto", "auto", "auto", null));
+ var locales = arguments.At(0);
+ var options = arguments.At(1);
+
+ // Per spec: CreateDateTimeFormat with required=~any~, defaults=~all~, toLocaleStringTimeZone=ZDT.[[TimeZone]]
+ // Uses ZonedDateTime defaults which include timeZoneName: "short"
+ // This will throw TypeError if user passes a timeZone option
+ var dtf = _realm.Intrinsics.DateTimeFormat.CreateDateTimeFormat(
+ locales, options, required: Intl.DateTimeRequired.Any, defaults: Intl.DateTimeDefaults.ZonedDateTime,
+ toLocaleStringTimeZone: zdt.TimeZone);
+
+ // Calendar mismatch check per spec
+ var cal = zdt.Calendar;
+ if (!string.Equals(cal, "iso8601", StringComparison.Ordinal) &&
+ dtf.Calendar != null && !string.Equals(cal, dtf.Calendar, StringComparison.Ordinal))
+ {
+ Throw.RangeError(_realm, $"Calendar mismatch: ZonedDateTime uses '{cal}' but DateTimeFormat uses '{dtf.Calendar}'");
+ }
+
+ // Per spec: create instant from ZDT's epoch nanoseconds and format it
+ // The DTF was created with ZDT's timezone, so the instant will be formatted in that timezone
+ const long nsPerTick = 100;
+ var ticks = (long) (zdt.EpochNanoseconds / nsPerTick);
+ var unixEpochTicks = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc).Ticks;
+ var totalTicks = unixEpochTicks + ticks;
+ var dateTime = new DateTime(totalTicks, DateTimeKind.Utc);
+
+ return dtf.Format(dateTime);
}
private JsValue ValueOf(JsValue thisObject, JsCallArguments arguments)
@@ -1103,7 +1129,7 @@ private static string FormatZonedDateTime(JsZonedDateTime zdt, string showCalend
// Add offset
if (!string.Equals(showOffset, "never", StringComparison.Ordinal))
{
- var offsetStr = TemporalHelpers.FormatOffsetString(zdt.OffsetNanoseconds);
+ var offsetStr = TemporalHelpers.FormatOffsetRounded(zdt.OffsetNanoseconds);
result += offsetStr;
}
@@ -1126,29 +1152,10 @@ private static string FormatZonedDateTime(JsZonedDateTime zdt, string showCalend
return result;
}
- private static BigInteger GetStartOfDayInstant(JsZonedDateTime zdt, ITimeZoneProvider provider)
+ private BigInteger GetStartOfDayInstant(JsZonedDateTime zdt, ITimeZoneProvider provider)
{
var dt = zdt.GetIsoDateTime();
- var possibleInstants = provider.GetPossibleInstantsFor(zdt.TimeZone,
- dt.Year, dt.Month, dt.Day, 0, 0, 0, 0, 0, 0);
-
- if (possibleInstants.Length > 0)
- {
- return possibleInstants[0];
- }
-
- // Gap at midnight - find the instant after the gap
- var startDateTime = new IsoDateTime(dt.Date, new IsoTime(1, 0, 0, 0, 0, 0));
- var afterGapInstants = provider.GetPossibleInstantsFor(zdt.TimeZone,
- startDateTime.Year, startDateTime.Month, startDateTime.Day,
- 1, 0, 0, 0, 0, 0);
-
- if (afterGapInstants.Length > 0)
- {
- return afterGapInstants[0] - TemporalHelpers.NanosecondsPerHour;
- }
-
- return zdt.EpochNanoseconds - (BigInteger) dt.Time.TotalNanoseconds();
+ return TemporalHelpers.GetStartOfDay(_realm, provider, zdt.TimeZone, dt.Date);
}
private static IsoDate AddDays(IsoDate date, int days)
@@ -1292,7 +1299,7 @@ private JsDuration DifferenceZonedDateTime(JsZonedDateTime zonedDateTime, JsZone
}
// Step 12-13: For date units, timezones must match
- if (!string.Equals(zonedDateTime.TimeZone, other.TimeZone, StringComparison.Ordinal))
+ if (!TemporalHelpers.TimeZoneEquals(_engine, zonedDateTime.TimeZone, other.TimeZone))
{
Throw.RangeError(_realm, "Time zones must match for ZonedDateTime difference with date units");
}
@@ -1495,46 +1502,54 @@ private BigInteger GetEpochFromIsoDateTime(IsoDateTime dateTime, string timeZone
return epochNs;
}
- return GetInstantFor(provider, timeZone, dateTime, disambiguation);
- }
-
- private BigInteger GetInstantFor(ITimeZoneProvider provider, string timeZone, IsoDateTime dateTime, string disambiguation)
- {
- var possibleInstants = provider.GetPossibleInstantsFor(timeZone,
- dateTime.Year, dateTime.Month, dateTime.Day,
- dateTime.Hour, dateTime.Minute, dateTime.Second,
- dateTime.Millisecond, dateTime.Microsecond, dateTime.Nanosecond);
-
- if (possibleInstants.Length == 1)
+ if (offsetNs.HasValue && string.Equals(offsetOption, "reject", StringComparison.Ordinal))
{
- return possibleInstants[0];
- }
+ // In "reject" mode, the provided offset must exactly match the actual offset
+ var possibleInstants = provider.GetPossibleInstantsFor(
+ timeZone,
+ dateTime.Year, dateTime.Month, dateTime.Day,
+ dateTime.Hour, dateTime.Minute, dateTime.Second,
+ dateTime.Millisecond, dateTime.Microsecond, dateTime.Nanosecond);
- if (possibleInstants.Length == 0)
- {
- // Gap
- if (string.Equals(disambiguation, "reject", StringComparison.Ordinal))
+ foreach (var instant in possibleInstants)
{
- Throw.RangeError(_realm, "The specified date-time does not exist in the time zone (gap)");
+ var actualOffset = provider.GetOffsetNanosecondsFor(timeZone, instant);
+ if (actualOffset == offsetNs.Value)
+ {
+ return instant;
+ }
}
- var localNs = IsoDateTimeToNanoseconds(dateTime);
- var standardOffset = provider.GetOffsetNanosecondsFor(timeZone, BigInteger.Zero);
- return localNs - standardOffset;
+ Throw.RangeError(_realm, "Offset does not match any possible offset for this wall time");
}
- // Ambiguous
- if (string.Equals(disambiguation, "reject", StringComparison.Ordinal))
+ if (offsetNs.HasValue && string.Equals(offsetOption, "prefer", StringComparison.Ordinal))
{
- Throw.RangeError(_realm, "The specified date-time is ambiguous in the time zone (overlap)");
- }
+ // In "prefer" mode, use the provided offset if it matches, otherwise disambiguate
+ var possibleInstants = provider.GetPossibleInstantsFor(
+ timeZone,
+ dateTime.Year, dateTime.Month, dateTime.Day,
+ dateTime.Hour, dateTime.Minute, dateTime.Second,
+ dateTime.Millisecond, dateTime.Microsecond, dateTime.Nanosecond);
- if (string.Equals(disambiguation, "later", StringComparison.Ordinal))
- {
- return possibleInstants[possibleInstants.Length - 1];
+ foreach (var instant in possibleInstants)
+ {
+ var actualOffset = provider.GetOffsetNanosecondsFor(timeZone, instant);
+ if (actualOffset == offsetNs.Value)
+ {
+ return instant;
+ }
+ }
+
+ // No match - fall through to normal disambiguation
}
- return possibleInstants[0];
+ return GetInstantFor(provider, timeZone, dateTime, disambiguation);
+ }
+
+ private BigInteger GetInstantFor(ITimeZoneProvider provider, string timeZone, IsoDateTime dateTime, string disambiguation)
+ {
+ return TemporalHelpers.GetEpochNanosecondsFor(_realm, provider, timeZone, dateTime, disambiguation);
}
private static BigInteger IsoDateTimeToNanoseconds(IsoDateTime dateTime)
diff --git a/Jint/Runtime/EventLoop.cs b/Jint/Runtime/EventLoop.cs
index 2fddfffaf9..e2ea9f6e6a 100644
--- a/Jint/Runtime/EventLoop.cs
+++ b/Jint/Runtime/EventLoop.cs
@@ -1,6 +1,5 @@
using System.Collections.Concurrent;
using System.Threading;
-using System.Threading.Tasks;
namespace Jint.Runtime;