* 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>
318 lines
12 KiB
JavaScript
318 lines
12 KiB
JavaScript
// Pure date and format math for the clock widget and its calendar panel.
|
|
// Everything here is locale- and Qt-free so it can be unit tested under node
|
|
// (test/shell.d/clock-test.sh); the QML owns month/weekday naming through
|
|
// Qt.locale().
|
|
|
|
var MS_PER_DAY = 86400000
|
|
|
|
// Weekday indices match both JS Date.getDay() and QML's Locale.Sunday…
|
|
// Locale.Saturday, so a locale's firstDayOfWeek can be passed straight in.
|
|
var WEEKDAY_NAMES = ["sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"]
|
|
|
|
// ---- Bar label formats. Right-clicking the clock walks these in order and
|
|
// writes the result back to shell.json, so the label the bar shows and
|
|
// the format the config stores are always the same thing.
|
|
//
|
|
// The locale-shaped time presets are each followed by their 12-hour twin, so
|
|
// the walk from a 24-hour label to the same label in AM/PM is a single right
|
|
// click rather than a lap of the ring. The ISO preset is deliberately left
|
|
// without one: ISO 8601 writes time on a 24-hour clock, so an AM/PM variant
|
|
// would contradict the only thing that format is for.
|
|
var CLOCK_FORMATS = [
|
|
"dddd HH:mm",
|
|
"dddd h:mm AP",
|
|
"dddd HH:mm:ss",
|
|
"dddd h:mm:ss AP",
|
|
"HH:mm",
|
|
"h:mm AP",
|
|
"ddd d MMM HH:mm",
|
|
"ddd d MMM h:mm AP",
|
|
"d MMMM 'W'ww yyyy",
|
|
"yyyy-MM-dd HH:mm"
|
|
]
|
|
|
|
// Vertical bars have room for a few stacked lines and nothing else, so the
|
|
// ring stays short. AM/PM costs a fourth line, which is why only the plain
|
|
// time carries it here.
|
|
var VERTICAL_CLOCK_FORMATS = [
|
|
"HH\n—\nmm",
|
|
"h\n—\nmm\nAP",
|
|
"dd\nMMM\n'W'ww\n''yy",
|
|
"HH\nmm"
|
|
]
|
|
|
|
// Whether a format prints seconds, so the widget can tick once a second only
|
|
// for the formats that show them. Quoted literals go first: the s in a 'Sat'
|
|
// is text rather than a token, and an opening quote with no closing one runs
|
|
// to the end of the format the way Qt reads it.
|
|
function clockNeedsSeconds(format) {
|
|
var text = String(format === undefined || format === null ? "" : format)
|
|
return /s/.test(text.replace(/'[^']*'?/g, ""))
|
|
}
|
|
|
|
function clockFormats(vertical) {
|
|
return vertical ? VERTICAL_CLOCK_FORMATS.slice() : CLOCK_FORMATS.slice()
|
|
}
|
|
|
|
// The presets in a fixed order, plus the configured alternate and current
|
|
// format when they are something else. The order must not depend on which
|
|
// entry is current: cycling writes the result back to shell.json, and a ring
|
|
// that reshuffled itself around the current value would bounce between two
|
|
// entries instead of walking.
|
|
function clockFormatRing(configured, configuredAlt, presets) {
|
|
var ring = []
|
|
var candidates = (presets || []).concat([configuredAlt, configured])
|
|
for (var i = 0; i < candidates.length; i++) {
|
|
var format = String(candidates[i] === undefined || candidates[i] === null ? "" : candidates[i])
|
|
if (format === "" || ring.indexOf(format) !== -1) continue
|
|
ring.push(format)
|
|
}
|
|
return ring.length > 0 ? ring : ["HH:mm"]
|
|
}
|
|
|
|
// Next entry after `current`. An unknown current format (a hand-written one
|
|
// that is not in the ring) starts the walk at the top.
|
|
function nextClockFormat(ring, current) {
|
|
if (!ring || ring.length === 0) return ""
|
|
var index = ring.indexOf(String(current === undefined || current === null ? "" : current))
|
|
return ring[(index + 1) % ring.length]
|
|
}
|
|
|
|
// Two-digit ISO week, substituted into a format's 'ww' token before Qt
|
|
// formats it -- Qt has no ISO week specifier of its own.
|
|
function isoWeekLiteral(year, month, day) {
|
|
return pad2(isoWeek(year, month, day))
|
|
}
|
|
|
|
function pad2(value) {
|
|
var n = Number(value)
|
|
return (n < 10 ? "0" : "") + n
|
|
}
|
|
|
|
// Stable "yyyy-MM-dd" identity for a day, so a grid cell can be compared
|
|
// against today without dragging Date objects through bindings.
|
|
function dateKey(year, month, day) {
|
|
return year + "-" + pad2(Number(month) + 1) + "-" + pad2(day)
|
|
}
|
|
|
|
function keyForDate(date) {
|
|
return dateKey(date.getFullYear(), date.getMonth(), date.getDate())
|
|
}
|
|
|
|
function coerceWeekStart(value) {
|
|
if (value === undefined || value === null) return null
|
|
if (typeof value === "number")
|
|
return isFinite(value) ? ((Math.round(value) % 7) + 7) % 7 : null
|
|
|
|
var text = String(value).replace(/^\s+|\s+$/g, "").toLowerCase()
|
|
if (text === "") return null
|
|
|
|
for (var i = 0; i < WEEKDAY_NAMES.length; i++)
|
|
if (WEEKDAY_NAMES[i] === text || WEEKDAY_NAMES[i].substr(0, 3) === text) return i
|
|
|
|
var parsed = parseInt(text, 10)
|
|
return isFinite(parsed) ? ((parsed % 7) + 7) % 7 : null
|
|
}
|
|
|
|
// Configured week start, falling back to the locale's own first day when
|
|
// the setting is missing or nonsense.
|
|
function normalizedWeekStart(value, fallback) {
|
|
var configured = coerceWeekStart(value)
|
|
if (configured !== null) return configured
|
|
var fallbackStart = coerceWeekStart(fallback)
|
|
return fallbackStart === null ? 1 : fallbackStart
|
|
}
|
|
|
|
function weekStartSettingName(index) {
|
|
return WEEKDAY_NAMES[normalizedWeekStart(index, 1)]
|
|
}
|
|
|
|
// The toggle flips between the two conventions people actually switch
|
|
// between. A calendar configured to any other start (Saturday, say) is
|
|
// shown as-is and lands on Monday the first time it is toggled.
|
|
function toggledWeekStart(index) {
|
|
return normalizedWeekStart(index, 1) === 1 ? 0 : 1
|
|
}
|
|
|
|
function weekdayOrder(weekStart) {
|
|
var start = normalizedWeekStart(weekStart, 1)
|
|
var out = []
|
|
for (var i = 0; i < 7; i++) out.push((start + i) % 7)
|
|
return out
|
|
}
|
|
|
|
// ISO-8601 week number: the week owning the Thursday of that date's
|
|
// Monday-based week. Mirrors the clock widget's 'ww' format token.
|
|
function isoWeek(year, month, day) {
|
|
var date = new Date(Date.UTC(year, month, day))
|
|
var weekday = date.getUTCDay() || 7
|
|
date.setUTCDate(date.getUTCDate() + 4 - weekday)
|
|
var yearStart = new Date(Date.UTC(date.getUTCFullYear(), 0, 1))
|
|
return Math.ceil(((date.getTime() - yearStart.getTime()) / MS_PER_DAY + 1) / 7)
|
|
}
|
|
|
|
function dayOfYear(year, month, day) {
|
|
return Math.round((Date.UTC(year, month, day) - Date.UTC(year, 0, 1)) / MS_PER_DAY) + 1
|
|
}
|
|
|
|
function daysInYear(year) {
|
|
return dayOfYear(year, 11, 31)
|
|
}
|
|
|
|
// Share of the year already behind you: whole days completed over days in
|
|
// the year, so January 1 reads 0% and December 31 reads 100%.
|
|
function yearProgress(year, month, day) {
|
|
var total = daysInYear(year)
|
|
if (total <= 0) return 0
|
|
return Math.max(0, Math.min(1, (dayOfYear(year, month, day) - 1) / total))
|
|
}
|
|
|
|
function yearProgressPercent(year, month, day) {
|
|
return Math.round(yearProgress(year, month, day) * 100)
|
|
}
|
|
|
|
// Memento mori. The default span is a round number rather than anything from
|
|
// an actuarial table: the point of the bar is the reminder, not the
|
|
// arithmetic, and whoever wants a different number can say so.
|
|
var DEFAULT_LIFE_EXPECTANCY = 90
|
|
|
|
// A birth year rather than an age, so the bar keeps counting on its own
|
|
// instead of going stale the moment it is entered. 0 means "not set", which
|
|
// is also what a blank, malformed, future, or implausibly distant year means.
|
|
function parseBirthYear(value, currentYear) {
|
|
var now = Math.round(Number(currentYear))
|
|
if (!isFinite(now)) return 0
|
|
var text = String(value === undefined || value === null ? "" : value).replace(/^\s+|\s+$/g, "")
|
|
if (!/^\d{4}$/.test(text)) return 0
|
|
var year = parseInt(text, 10)
|
|
if (!isFinite(year) || year > now || year < now - 120) return 0
|
|
return year
|
|
}
|
|
|
|
// Whole years, the way people say their age: born in 1979 makes you 47 for
|
|
// all of 2026, whichever side of your birthday today falls.
|
|
function ageFromBirthYear(birthYear, currentYear) {
|
|
var born = parseBirthYear(birthYear, currentYear)
|
|
if (born <= 0) return 0
|
|
return Math.round(Number(currentYear)) - born
|
|
}
|
|
|
|
// 0 means "not set", which is also what a blank, negative, fractional, or
|
|
// absurd entry means — the life bar simply stays hidden.
|
|
function parseAge(value) {
|
|
var text = String(value === undefined || value === null ? "" : value).replace(/^\s+|\s+$/g, "")
|
|
if (!/^\d+$/.test(text)) return 0
|
|
var years = parseInt(text, 10)
|
|
if (!isFinite(years) || years <= 0 || years > 120) return 0
|
|
return years
|
|
}
|
|
|
|
// Unset or nonsense falls back to the default rather than to zero, so the
|
|
// bar always has something to measure against.
|
|
function parseLifeExpectancy(value) {
|
|
var text = String(value === undefined || value === null ? "" : value).replace(/^\s+|\s+$/g, "")
|
|
if (!/^\d+$/.test(text)) return DEFAULT_LIFE_EXPECTANCY
|
|
var years = parseInt(text, 10)
|
|
if (!isFinite(years) || years <= 0 || years > 150) return DEFAULT_LIFE_EXPECTANCY
|
|
return years
|
|
}
|
|
|
|
function lifeProgress(age, expectancy) {
|
|
var years = parseAge(age)
|
|
var span = parseLifeExpectancy(expectancy)
|
|
if (years <= 0 || span <= 0) return 0
|
|
return Math.max(0, Math.min(1, years / span))
|
|
}
|
|
|
|
function lifeProgressPercent(age, expectancy) {
|
|
return Math.round(lifeProgress(age, expectancy) * 100)
|
|
}
|
|
|
|
// 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(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 = []
|
|
|
|
for (var w = 0; w < 6; w++) {
|
|
var days = []
|
|
var thursday = null
|
|
for (var d = 0; d < 7; d++) {
|
|
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({
|
|
key: key,
|
|
year: cellYear,
|
|
month: cellMonth,
|
|
day: cellDay,
|
|
weekday: weekday,
|
|
inMonth: cellMonth === month && cellYear === year,
|
|
weekend: weekday === 0 || weekday === 6,
|
|
today: key === today
|
|
})
|
|
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
|
|
// stays stable for the other starts, where a row straddles two ISO
|
|
// weeks but shares all of Monday through Thursday with one of them.
|
|
var anchor = thursday || days[0]
|
|
weeks.push({
|
|
week: isoWeek(anchor.year, anchor.month, anchor.day),
|
|
days: days
|
|
})
|
|
}
|
|
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(Date.UTC(year, Number(month) + Number(delta), 1))
|
|
return { year: target.getUTCFullYear(), month: target.getUTCMonth() }
|
|
}
|
|
|
|
if (typeof module !== "undefined") {
|
|
module.exports = {
|
|
dateKey: dateKey,
|
|
keyForDate: keyForDate,
|
|
normalizedWeekStart: normalizedWeekStart,
|
|
weekStartSettingName: weekStartSettingName,
|
|
toggledWeekStart: toggledWeekStart,
|
|
weekdayOrder: weekdayOrder,
|
|
isoWeek: isoWeek,
|
|
dayOfYear: dayOfYear,
|
|
daysInYear: daysInYear,
|
|
yearProgress: yearProgress,
|
|
yearProgressPercent: yearProgressPercent,
|
|
parseAge: parseAge,
|
|
parseBirthYear: parseBirthYear,
|
|
ageFromBirthYear: ageFromBirthYear,
|
|
parseLifeExpectancy: parseLifeExpectancy,
|
|
lifeProgress: lifeProgress,
|
|
lifeProgressPercent: lifeProgressPercent,
|
|
monthGrid: monthGrid,
|
|
stepMonth: stepMonth,
|
|
clockFormats: clockFormats,
|
|
clockNeedsSeconds: clockNeedsSeconds,
|
|
clockFormatRing: clockFormatRing,
|
|
nextClockFormat: nextClockFormat,
|
|
isoWeekLiteral: isoWeekLiteral
|
|
}
|
|
}
|