`ZonedDate.fromLocalDate(new Date('2025-10-26T00:00:00.000Z'), TIME_ZONE_ID_EUROPE_BERLIN)`returns hour **1**, although the correct local hour is **2** (CEST, UTC+2). Berlin switches back at **2025-10-26T01:00:00Z**, not at midnight UTC. The two consecutive UTC instants `00:00Z` and `01:00Z` must both display `02:00`, with different offsets.
I tested the behavior with time zones 'Europe/Berlin' and 'Australia/Hobart'.
I tried to attach a Markdown file here, but upload failed for whatever reason. You wiil find the detailed description in the Markdown below:
# ZonedDate.fromLocalDate returns the wrong Berlin hour before the autumn DST transition
## Affected package and environment
- `@progress/kendo-date-math`: **1.5.17**.
- Reproduced with Node.js **24.18.0** on Linux.
- Reproduced with host timezones **UTC** and **Europe/Berlin**.
- The required Berlin timezone data is loaded explicitly in the reproduction.
- Angular is not required to reproduce this issue.
## Summary
`ZonedDate.fromLocalDate(new Date('2025-10-26T00:00:00.000Z'), TIME_ZONE_ID_EUROPE_BERLIN)`
returns hour **1**, although the correct local hour is **2** (CEST, UTC+2).
Berlin switches back at **2025-10-26T01:00:00Z**, not at midnight UTC.
The two consecutive UTC instants `00:00Z` and `01:00Z` must both display
`02:00`, with different offsets.
## Minimal reproduction
In an empty directory, install the affected version:
```sh
npm init -y
npm install @progress/kendo-date-math@1.5.17
```
Run the following as `repro.cjs` using `node repro.cjs`:
```javascript
const assert = require('node:assert/strict');
const { ZonedDate } = require('@progress/kendo-date-math');
require('@progress/kendo-date-math/tz/Europe/Berlin');
const inputs = [
'2025-10-25T22:00:00.000Z',
'2025-10-25T23:00:00.000Z',
'2025-10-26T00:00:00.000Z',
'2025-10-26T01:00:00.000Z',
'2025-10-26T02:00:00.000Z',
];
const hours = inputs.map((iso) =>
ZonedDate.fromLocalDate(new Date(iso), 'Europe/Berlin').getHours());
console.log('Actual Berlin hours:', hours);
assert.deepEqual(hours, [0, 1, 2, 2, 3]);
```
Actual output: `[0, 1, 1, 2, 3]`, followed by an assertion failure.
Expected output: `[0, 1, 2, 2, 3]`.
On Linux, both `TZ=UTC node repro.cjs` and
`TZ=Europe/Berlin node repro.cjs` reproduce the failure.
| Input UTC instant | Expected Berlin time | Actual hour |
| --- | --- | --- |
| 2025-10-25 22:00 | 2025-10-26 00:00 CEST | 0 |
| 2025-10-25 23:00 | 2025-10-26 01:00 CEST | 1 |
| 2025-10-26 00:00 | 2025-10-26 02:00 CEST | **1** |
| 2025-10-26 01:00 | 2025-10-26 02:00 CET | 2 |
| 2025-10-26 02:00 | 2025-10-26 03:00 CET | 3 |
## Diagnosis
In the installed `dist/npm/tz/zoned-date.js`, `fromLocalDate()` calls
`convertTimezoneUTC(date, 'Etc/UTC', timezone)`.
For `2025-10-26T00:00:00Z`, that function:
1. Calculates a base shift of +120 minutes.
2. Creates an intermediate date of `2025-10-26T02:00:00Z`.
3. Looks up the target offset again at that intermediate date.
4. Finds a different offset and applies a -60-minute correction.
The intermediate date encodes wall-clock fields; it is not the original
instant. Looking up its offset as another instant causes the wrong hour.
Simply deleting the second lookup is not a verified general fix. Additional
probes show that Kendo's internal `offset()` lookup itself disagrees with
`Intl` at some transition instants: for Berlin it returns -60 at
`2025-03-30T01:00:00Z` (expected -120), and -120 at
`2025-10-26T01:00:00Z` (expected -60). Existing corrections compensate for
some of these cases. The UTC-instant-to-wall-clock operation should not
depend on resolving an ambiguous wall-clock time back into an instant.
## Proposed upstream changes
The following is a source-level proposal, not a released Telerik patch.
It uses the runtime's IANA timezone implementation through `Intl`.
Please adapt it to the package's source layout and supported-browser policy.
### 1. Preserve a known instant in the constructor
Add an optional third parameter to the existing constructor. Insert this
branch before its existing `timezone === ''` branch; leave the existing
floating-date and wall-clock-input branches unchanged:
```diff
-constructor(utcDate: Date, timezone: string) {
+constructor(utcDate: Date, timezone: string, instant?: Date) {
this._utcDate = cloneDate(utcDate);
this.timezone = timezone;
- if (timezone === '') {
+ if (instant && timezone !== '') {
+ this.timezoneOffset = (instant.getTime() - utcDate.getTime()) / MS_PER_MINUTE;
+ this._localDate = cloneDate(instant);
+ } else if (timezone === '') {
```
This also preserves the distinct instants behind the two occurrences of
`02:00`, instead of attempting to infer the instant from ambiguous fields.
`MS_PER_MINUTE` and `cloneDate` already exist in the implementation.
### 2. Replace the explicit-timezone path in fromLocalDate
Retain the existing `timezone === ''` path. Replace the remainder of
`fromLocalDate()` with the following code:
```typescript
const formatter: Intl.DateTimeFormat = new Intl.DateTimeFormat('en-GB', {
timeZone: timezone,
calendar: 'gregory',
numberingSystem: 'latn',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hourCycle: 'h23',
});
const parts: Array<Intl.DateTimeFormatPart> = formatter.formatToParts(date);
const getPart: (type: Intl.DateTimeFormatPartTypes) => number =
(type: Intl.DateTimeFormatPartTypes): number =>
Number(parts.find((part: Intl.DateTimeFormatPart): boolean => part.type === type)?.value);
const utcDate: Date = new Date(0);
utcDate.setUTCFullYear(getPart('year'), getPart('month') - 1, getPart('day'));
utcDate.setUTCHours(getPart('hour'), getPart('minute'), getPart('second'), date.getUTCMilliseconds());
return new ZonedDate(utcDate, timezone, date);
```
Here, `utcDate` is deliberately a carrier of wall-clock fields in its UTC
components, matching Kendo's existing internal representation. It is not
the original UTC instant. No browser-local `Date` setters are used.
The use of `hourCycle: 'h23'` prevents midnight from being encoded as hour 24.
Milliseconds are preserved directly from the original instant.
## Regression coverage and review scope
Please verify:
- The five expected hours in the reproduction, under UTC and Berlin hosts.
- Full calendar fields and milliseconds around both spring and autumn transitions.
- `toLocalDate().getTime()` and `getTime()` still equal the input instant.
- `getTimezoneOffset()` distinguishes the two occurrences of Berlin `02:00`:
-120 for `00:00Z`, and -60 for `01:00Z`.
- Zones with negative offsets and southern-hemisphere DST.
- Existing behavior when the timezone argument is omitted or empty.
The constructor/factory proposal addresses conversion from a known instant.
It is not a complete redesign of ambiguous/nonexistent wall-clock input.
Please also audit `clone()` and `toTimezone()`: the current implementation
reconstructs values from wall-clock fields and can lose repeated-hour identity.
Other arithmetic and `fromUTCDate()` paths require the vendor's full regression
suite before release. Formatter caching may be appropriate after correctness
and browser-support requirements are established.
### Validation performed
The reproduction above fails on the installed package with exactly the
documented hours and passes with the proposed changes applied in memory.
The proposal also passed 11,580 conversion checks: five target zones
(`Europe/Berlin`, `Europe/Athens`, `America/New_York`, `Australia/Hobart`,
`Etc/UTC`), two host timezones (UTC and Berlin), and 15-minute samples within
24 hours either side of six 2025 spring/autumn transition instants.
These checks compared full calendar fields against `Intl`, verified
milliseconds and instant round trips, and checked offset consistency.
Separate checks verified the two Berlin repeated-hour offsets and unchanged
floating-date conversion for the sampled instant. Installed dependency files
were not modified. This is focused validation, not Telerik's full test suite.
## Application workaround
Our application bypasses `ZonedDate.fromLocalDate()` for its display-date
conversion and obtains calendar fields through `Intl.formatToParts()` instead.
All active timestamp tests pass with UTC and Berlin host timezones.
The original epoch value remains authoritative. A browser-local `Date` used
as a display carrier cannot itself retain an arbitrary timezone or distinguish
both occurrences of a repeated hour, and can normalize nonexistent host-local
times during spring-forward transitions.