Build the calendar month grid in UTC (#10476)
* Build the calendar month grid in UTC The grid walked a cursor at local midnight and stepped it with setDate(getDate() + 1). Chile starts DST at 24:00 on the first Saturday of September, so local midnight of 6 September 2026 does not exist, and Qt's V4 engine resolves a missing local time backward -- onto 5 September 23:00, the same calendar date, which the next iteration reads again. September 2026 draws 30 31 1 2 3 4 5 and then 5 6 7 8 9 10 11, and every later date sits one weekday early. Cuba, Lebanon, the Azores and Egypt change over at 00:00 too and break the same way in their own transition month; where the gap lands on a leading padding cell instead, the whole six-week grid shifts by a day with no duplicate in it. Node resolves the identical gap forward, so the suite has never seen this: running the shipped monthGrid under Node across all 598 tzdata zones for 2020-2035 reports nothing, while the same code under the QML engine breaks 402 grids. A sequence of civil dates never needed a wall clock, so it walks UTC, where every day is 24 hours long, and the test pins that by building the grid with a Date whose local constructor and accessors throw. stepMonth reads a month back off the same kind of date. No zone puts a midnight gap on a first of the month, so it is not broken today, but it moves with the grid rather than waiting for a tzdata release to make it a second bug. Co-Authored-By: Codex XHigh <noreply@openai.com> * Name the calendar heading's month from local noon The heading built a local midnight on the first of the month, and that midnight does not always exist: Cuba started DST at 00:00 on 1 April 2012, so in Havana the QML engine resolves it back onto 31 March and the heading reads March 2012 above an April grid. Noon exists on every first of the month there has been, and keeping it local is what makes Qt.formatDate print the right month in every zone. The Apia case comes from #13186: Samoa skipped 30 December 2011 entirely, so unlike a one-hour midnight gap, Node's V8 cannot resolve its way past it, and the test fails on the old grid under Node instead of only in the shell. Co-Authored-By: Codex Medium <noreply@openai.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Codex XHigh <noreply@openai.com> Co-authored-by: Omarchybot <317366263+omarchybot@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
3 files changed
+72
-10
No files matched your search
@@ -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") {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in new issue
Block a user