* Add a clock format with live seconds Right-clicking the clock now reaches "Thursday 09:39:23" and its AM/PM twin, and the widget's SystemClock ticks once a second only while a format that prints seconds is showing — every other format keeps the minute precision it had, so nobody pays for a repaint a second to read a label that changes once a minute. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Read an unterminated literal in a clock format as text Qt reads an opening quote with no closing one as a literal running to the end of the format, so "HH:mm 'sec" prints "09:39 sec" and never a second count — but the seconds test stripped only balanced quotes, saw the s, and put the widget on a per-second tick for a label that changes once a minute. The wiring assertions went the other way: each passed while the feature was broken, so hard-coding showsSeconds to false, dropping the label's onDateChanged, or commenting the precision line out and leaving the text behind all shipped green. Comments now come out of the source before it is matched, and both halves of the tick are asserted. Co-Authored-By: Codex XHigh <noreply@openai.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: Codex XHigh <noreply@openai.com>
309 lines
11 KiB
JavaScript
309 lines
11 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.
|
|
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 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.getFullYear()
|
|
var cellMonth = cursor.getMonth()
|
|
var cellDay = cursor.getDate()
|
|
var weekday = cursor.getDay()
|
|
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.setDate(cursor.getDate() + 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
|
|
}
|
|
|
|
function stepMonth(year, month, delta) {
|
|
var target = new Date(year, Number(month) + Number(delta), 1)
|
|
return { year: target.getFullYear(), month: target.getMonth() }
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|