diff --git a/shell/plugins/panels/clock/Model.js b/shell/plugins/panels/clock/Model.js index 58d767f1..873db953 100644 --- a/shell/plugins/panels/clock/Model.js +++ b/shell/plugins/panels/clock/Model.js @@ -231,10 +231,17 @@ function lifeProgressPercent(age, expectancy) { // Always six rows of seven days. A fixed grid keeps the popup exactly the // same height in every month, so stepping through the year never makes the // panel jump under the pointer. +// +// Walked in UTC, where every day is 24 hours long. A local-midnight cursor has +// to be resolved by the engine in zones that start DST at 00:00 -- Chile every +// September, Cuba every March -- and V4 resolves the missing midnight backward +// onto the previous day while V8 resolves it forward, so the local walk repeats +// a date in the shell and not under the tests. A grid of civil dates never +// needed a wall clock in the first place. function monthGrid(year, month, weekStart, todayKey) { var start = normalizedWeekStart(weekStart, 1) - var leading = (new Date(year, month, 1).getDay() - start + 7) % 7 - var cursor = new Date(year, month, 1 - leading) + var leading = (new Date(Date.UTC(year, month, 1)).getUTCDay() - start + 7) % 7 + var cursor = new Date(Date.UTC(year, month, 1 - leading)) var today = String(todayKey || "") var weeks = [] @@ -242,10 +249,10 @@ function monthGrid(year, month, weekStart, todayKey) { var days = [] var thursday = null for (var d = 0; d < 7; d++) { - var cellYear = cursor.getFullYear() - var cellMonth = cursor.getMonth() - var cellDay = cursor.getDate() - var weekday = cursor.getDay() + var cellYear = cursor.getUTCFullYear() + var cellMonth = cursor.getUTCMonth() + var cellDay = cursor.getUTCDate() + var weekday = cursor.getUTCDay() var key = dateKey(cellYear, cellMonth, cellDay) if (weekday === 4) thursday = { year: cellYear, month: cellMonth, day: cellDay } days.push({ @@ -258,7 +265,7 @@ function monthGrid(year, month, weekStart, todayKey) { weekend: weekday === 0 || weekday === 6, today: key === today }) - cursor.setDate(cursor.getDate() + 1) + cursor.setUTCDate(cursor.getUTCDate() + 1) } // Number every row by the ISO week owning its Thursday. That is the // definition itself for Monday-start weeks, and the only answer that @@ -273,9 +280,11 @@ function monthGrid(year, month, weekStart, todayKey) { return weeks } +// UTC for the same reason as the grid: the month is read back off the date, so +// a first of the month that has no local midnight would step into the wrong one. function stepMonth(year, month, delta) { - var target = new Date(year, Number(month) + Number(delta), 1) - return { year: target.getFullYear(), month: target.getMonth() } + var target = new Date(Date.UTC(year, Number(month) + Number(delta), 1)) + return { year: target.getUTCFullYear(), month: target.getUTCMonth() } } if (typeof module !== "undefined") { diff --git a/shell/plugins/panels/clock/Panel.qml b/shell/plugins/panels/clock/Panel.qml index 4f4414bb..042c2ef8 100644 --- a/shell/plugins/panels/clock/Panel.qml +++ b/shell/plugins/panels/clock/Panel.qml @@ -41,7 +41,9 @@ Panel { property int viewYear: today.getFullYear() property int viewMonth: today.getMonth() - readonly property date viewDate: new Date(viewYear, viewMonth, 1) + // Local noon, so a first of the month with no midnight (Havana, April 2012) + // still names its own month; UTC would name the previous one west of Greenwich. + readonly property date viewDate: new Date(viewYear, viewMonth, 1, 12) readonly property bool viewingCurrentMonth: viewYear === today.getFullYear() && viewMonth === today.getMonth() // Pinned to today, not to the month being browsed — stepping through the diff --git a/test/shell.d/clock-test.sh b/test/shell.d/clock-test.sh index d2bde8a1..5b11f112 100755 --- a/test/shell.d/clock-test.sh +++ b/test/shell.d/clock-test.sh @@ -110,6 +110,57 @@ assertDeepEqual(julySunday.map(week => week.week), [27, 28, 29, 30, 31, 32], 'ca const januarySunday = calendar.monthGrid(2021, 0, 0, '') assertEqual(januarySunday[0].week, 53, 'calendar carries the previous ISO year into a straddling first row') +// Zones that start DST at 00:00 have a calendar day with no local midnight, and +// the engines disagree about which side of the gap it falls on: V4 resolves it +// backward onto the previous date, V8 forward. Node therefore cannot see the +// duplicated day the shell draws, so pin the property that makes the question +// moot -- the grid is walked in UTC and never asks for a wall clock. +const RealDate = Date +class LocalTimeIsForbidden extends RealDate { + constructor(...args) { + if (args.length > 1) throw new Error('built a Date from local components') + super(...args) + } + getFullYear() { throw new Error('read the local year') } + getMonth() { throw new Error('read the local month') } + getDate() { throw new Error('read the local day') } + getDay() { throw new Error('read the local weekday') } + setDate() { throw new Error('stepped the local day') } +} + +function withoutLocalTime(build) { + global.Date = LocalTimeIsForbidden + try { + return { value: build(), localTimeUse: '' } + } catch (error) { + return { value: null, localTimeUse: error.message } + } finally { + global.Date = RealDate + } +} + +const september = withoutLocalTime(() => calendar.monthGrid(2026, 8, 0, '')) +assertEqual(september.localTimeUse, '', 'calendar builds the month grid without reading local time') +assertDeepEqual( + september.value.slice(0, 2).flatMap(week => week.days).map(day => day.day), + [30, 31, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12], + 'calendar runs September 2026 straight through the Chilean DST midnight' +) + +const stepped = withoutLocalTime(() => calendar.stepMonth(2026, 8, 1)) +assertEqual(stepped.localTimeUse, '', 'calendar steps months without reading local time') +assertDeepEqual(stepped.value, { year: 2026, month: 9 }, 'calendar steps from September to October') + +// Samoa skipped 30 December 2011 outright, a gap even V8 cannot paper over. +const previousTZ = process.env.TZ +process.env.TZ = 'Pacific/Apia' +const apia = calendar.monthGrid(2011, 11, 1, '') +if (previousTZ === undefined) delete process.env.TZ +else process.env.TZ = previousTZ +assertDeepEqual(apia[4].days.map(day => day.day), [26, 27, 28, 29, 30, 31, 1], 'calendar keeps a day the local zone skipped') + +assert(/viewDate: new Date\(viewYear, viewMonth, 1, 12\)/.test(panelSource), 'calendar names the heading month from a noon that always exists') + // ---- stepping assertDeepEqual(calendar.stepMonth(2026, 0, 1), { year: 2026, month: 1 }, 'calendar steps to the next month') assertDeepEqual(calendar.stepMonth(2026, 0, -1), { year: 2025, month: 11 }, 'calendar steps back across the new year')