Count the other progress bar too

Double-tapping the year bar asks for a birth year and a life expectancy,
and a second bar appears below measuring one against the other. Tab moves
between the two, Enter commits the pair, Escape drops them. Hovering the
bar names the thing, and double-tapping it puts it away again.

A birth year rather than an age, so the bar keeps counting on its own
instead of going stale the moment it is entered. Expectancy defaults to
ninety and falls back to it when what is entered makes no sense, so the
bar always has something to measure against; a birth year that makes no
sense leaves the bar hidden instead, which is also where it starts.
Putting the bar away keeps the expectancy, so setting a birth year again
brings your own number back rather than the default.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
David Heinemeier Hansson
2026-07-26 20:03:18 -07:00
co-authored by Claude Opus 5
parent cc4771fd91
commit 9e3da4e27f
3 changed files with 331 additions and 14 deletions
+63
View File
@@ -149,6 +149,63 @@ 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.
@@ -212,6 +269,12 @@ if (typeof module !== "undefined") {
daysInYear: daysInYear,
yearProgress: yearProgress,
yearProgressPercent: yearProgressPercent,
parseAge: parseAge,
parseBirthYear: parseBirthYear,
ageFromBirthYear: ageFromBirthYear,
parseLifeExpectancy: parseLifeExpectancy,
lifeProgress: lifeProgress,
lifeProgressPercent: lifeProgressPercent,
monthGrid: monthGrid,
stepMonth: stepMonth,
clockFormats: clockFormats,
+210 -13
View File
@@ -48,6 +48,17 @@ Panel {
readonly property real yearDone: Model.yearProgress(today.getFullYear(), today.getMonth(), today.getDate())
readonly property int yearDonePercent: Model.yearProgressPercent(today.getFullYear(), today.getMonth(), today.getDate())
// Memento mori, for anyone who goes looking: double-tapping the year bar
// asks for a birth year and a life expectancy, and a second bar tracks one
// against the other. A birth year rather than an age, so it keeps counting
// on its own. Without one the bar stays hidden.
readonly property int birthYear: Model.parseBirthYear(setting("birthYear", 0), today.getFullYear())
readonly property int age: Model.ageFromBirthYear(birthYear, today.getFullYear())
readonly property int lifeExpectancy: Model.parseLifeExpectancy(setting("lifeExpectancy", 0))
readonly property real lifeDone: Model.lifeProgress(age, lifeExpectancy)
readonly property int lifeDonePercent: Model.lifeProgressPercent(age, lifeExpectancy)
property bool editingLife: false
// Unset falls through to the locale's own first day, so a fresh install
// starts out matching the rest of the desktop rather than a hardcoded
// convention. Clicking the grid's "W" heading writes the choice back to
@@ -84,6 +95,9 @@ Panel {
function close() {
setCenterHoverRevealSuppressed(false)
// Dismissing the panel mid-edit would otherwise leave the inputs up,
// waiting behind a closed popup for the next time it opens.
if (root.editingLife) root.cancelEditingLife()
root.controller.hide()
}
@@ -125,27 +139,76 @@ Panel {
moveMonth(delta * 12)
}
function setWeekStart(day) {
var next = Model.normalizedWeekStart(day, root.weekStart)
if (next === root.weekStart) return
// Applied locally first so the panel redraws on the click itself; the
// shell.json write comes back through the bar as the same value. With no
// writable entry (the widget is not in the layout) it stays a session-only
// preference rather than doing nothing. The host widget builds its own
// entry when the label format is cycled, so it has to be kept in step or
// it would write this key straight back out from a stale copy.
function persistSettings(values) {
var entry = { id: root.moduleName }
for (var key in root.settings) if (key !== "id") entry[key] = root.settings[key]
entry.weekStartDay = Model.weekStartSettingName(next)
for (var existing in root.settings) if (existing !== "id") entry[existing] = root.settings[existing]
for (var key in values) entry[key] = values[key]
// Applied locally first so the grid reflows on the click itself; the
// shell.json write comes back through the bar as the same value. With
// no writable entry (the widget is not in the layout) it stays a
// session-only preference rather than doing nothing.
root.settings = entry
// The host widget builds its own entry when the label format is cycled.
// Until shell.json round-trips it would be working from a copy without
// this key, and would write the week start straight back out.
if (root.hostWidget && "settings" in root.hostWidget) root.hostWidget.settings = entry
if (root.bar && root.bar.shell && typeof root.bar.shell.updateEntryInline === "function")
root.bar.shell.updateEntryInline(root.moduleName, entry)
}
function setWeekStart(day) {
var next = Model.normalizedWeekStart(day, root.weekStart)
if (next === root.weekStart) return
persistSettings({ weekStartDay: Model.weekStartSettingName(next) })
}
function startEditingLife() {
root.editingLife = true
Qt.callLater(function() {
bornField.text = root.birthYear > 0 ? String(root.birthYear) : ""
expectancyField.text = String(root.lifeExpectancy)
bornField.selectAll()
bornField.forceActiveFocus()
})
}
function cancelEditingLife() {
root.editingLife = false
Qt.callLater(function() { if (keyCatcher) keyCatcher.forceActiveFocus() })
}
// Shared by both fields: Tab hops to the other one, Enter commits the pair,
// Escape drops the lot.
function handleLifeKey(event, other) {
if (event.key === Qt.Key_Escape) {
root.cancelEditingLife()
event.accepted = true
} else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) {
root.commitLife()
event.accepted = true
} else if (event.key === Qt.Key_Tab || event.key === Qt.Key_Backtab) {
other.selectAll()
other.forceActiveFocus()
event.accepted = true
}
}
// Double-tapping the life bar puts it away again. The expectancy stays in
// the config so setting a birth year again brings your own number back
// rather than the default.
function clearLife() {
if (root.birthYear <= 0) return
persistSettings({ birthYear: 0 })
}
function commitLife() {
var born = Model.parseBirthYear(bornField.text, today.getFullYear())
var span = Model.parseLifeExpectancy(expectancyField.text)
if (born !== root.birthYear || span !== root.lifeExpectancy)
persistSettings({ birthYear: born, lifeExpectancy: span })
cancelEditingLife()
}
function toggleWeekStart() {
setWeekStart(Model.toggledWeekStart(root.weekStart))
}
@@ -181,6 +244,7 @@ Panel {
PanelKeyCatcher {
id: keyCatcher
anchors.fill: parent
blocked: root.editingLife
onMoveRequested: function(dx, dy) {
if (dx !== 0) root.moveMonth(dx)
if (dy !== 0) root.moveYear(dy)
@@ -288,8 +352,65 @@ Panel {
width: gridColumn.width
height: Math.max(yearLabel.implicitHeight, Style.space(10))
TapHandler {
enabled: !root.editingLife
onDoubleTapped: root.startEditingLife()
}
Row {
visible: root.editingLife
anchors.horizontalCenter: parent.horizontalCenter
anchors.verticalCenter: parent.verticalCenter
spacing: Style.space(10)
Text {
anchors.verticalCenter: parent.verticalCenter
text: "BORN"
color: Qt.darker(root.contentForeground, 1.5)
font.family: root.contentFontFamily
font.pixelSize: Style.font.bodySmall
font.letterSpacing: 1
}
TextField {
id: bornField
width: Style.space(70)
anchors.verticalCenter: parent.verticalCenter
placeholderText: "year"
foreground: root.contentForeground
font.family: root.contentFontFamily
inputMethodHints: Qt.ImhDigitsOnly
Keys.onPressed: function(event) { root.handleLifeKey(event, expectancyField) }
}
Text {
anchors.verticalCenter: parent.verticalCenter
anchors.verticalCenterOffset: 0
leftPadding: Style.space(6)
text: "LIVE TO"
color: Qt.darker(root.contentForeground, 1.5)
font.family: root.contentFontFamily
font.pixelSize: Style.font.bodySmall
font.letterSpacing: 1
}
TextField {
id: expectancyField
width: Style.space(60)
anchors.verticalCenter: parent.verticalCenter
placeholderText: "90"
foreground: root.contentForeground
font.family: root.contentFontFamily
inputMethodHints: Qt.ImhDigitsOnly
Keys.onPressed: function(event) { root.handleLifeKey(event, bornField) }
}
}
Text {
id: yearLabel
visible: !root.editingLife
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
text: root.today.getFullYear()
@@ -301,6 +422,7 @@ Panel {
Text {
id: yearPercent
visible: !root.editingLife
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
text: root.yearDonePercent + "%"
@@ -311,6 +433,7 @@ Panel {
Rectangle {
id: yearTrack
visible: !root.editingLife
anchors.left: yearLabel.right
anchors.right: yearPercent.left
anchors.leftMargin: Style.space(12)
@@ -332,6 +455,80 @@ Panel {
}
}
// ---- Memento mori. Only here once someone has gone looking and
// given an age; the same rail as the year above it, measured
// against a nominal lifetime.
Item {
visible: root.birthYear > 0
width: parent.width
height: visible ? lifeBlock.height : 0
Item {
id: lifeBlock
anchors.horizontalCenter: parent.horizontalCenter
width: gridColumn.width
height: Math.max(lifeLabel.implicitHeight, Style.space(10))
Text {
id: lifeLabel
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
text: "LIFE"
color: Qt.darker(root.contentForeground, 1.5)
font.family: root.contentFontFamily
font.pixelSize: Style.font.bodySmall
font.letterSpacing: 1
}
Text {
id: lifePercent
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
text: root.lifeDonePercent + "%"
color: root.contentForeground
font.family: root.contentFontFamily
font.pixelSize: Style.font.bodySmall
}
Rectangle {
anchors.left: lifeLabel.right
anchors.right: lifePercent.left
anchors.leftMargin: Style.space(12)
anchors.rightMargin: Style.space(12)
anchors.verticalCenter: parent.verticalCenter
height: Style.space(6)
radius: Style.cornerRadius > 0 ? height / 2 : 0
color: Qt.rgba(root.contentForeground.r, root.contentForeground.g, root.contentForeground.b, 0.12)
Rectangle {
width: Math.round(parent.width * root.lifeDone)
height: parent.height
radius: parent.radius
color: Style.selectedStateColor(root.contentForeground, Color.accent)
Behavior on width { NumberAnimation { duration: 160; easing.type: Easing.OutCubic } }
}
}
TapHandler {
onDoubleTapped: root.clearLife()
}
MouseArea {
id: lifeMouse
anchors.fill: parent
hoverEnabled: true
acceptedButtons: Qt.NoButton
PanelToolTip {
visible: lifeMouse.containsMouse
text: "Memento Mori"
fontFamily: root.contentFontFamily
}
}
}
}
// ---- Month grid: week numbers down a gutter on the left, then
// the seven day columns. Always six rows, so the popup is
// exactly as tall in February as it is in August.
+58 -1
View File
@@ -40,6 +40,45 @@ assertEqual(calendar.yearProgressPercent(2026, 0, 1), 0, 'calendar starts the ye
assertEqual(calendar.yearProgressPercent(2026, 6, 26), 56, 'calendar reports the share of the year behind you')
assertEqual(calendar.yearProgressPercent(2026, 11, 31), 100, 'calendar finishes the year at a hundred percent')
assertEqual(calendar.yearProgressPercent(2024, 11, 31), 100, 'calendar finishes a leap year too')
// ---- memento mori
// A birth year rather than an age, so the bar keeps counting on its own.
assertEqual(calendar.parseBirthYear('1979', 2026), 1979, 'calendar reads a birth year')
assertEqual(calendar.parseBirthYear(1979, 2026), 1979, 'calendar reads a numeric birth year')
assertEqual(calendar.parseBirthYear(' 1979 ', 2026), 1979, 'calendar trims a birth year')
assertEqual(calendar.parseBirthYear('2026', 2026), 2026, 'calendar accepts the current year')
assertEqual(calendar.parseBirthYear('2027', 2026), 0, 'calendar rejects a birth year in the future')
assertEqual(calendar.parseBirthYear('1905', 2026), 0, 'calendar rejects an implausibly distant birth year')
assertEqual(calendar.parseBirthYear('79', 2026), 0, 'calendar rejects a two-digit year')
assertEqual(calendar.parseBirthYear('', 2026), 0, 'calendar treats a blank birth year as unset')
assertEqual(calendar.parseBirthYear('abc', 2026), 0, 'calendar treats a non-numeric birth year as unset')
assertEqual(calendar.ageFromBirthYear('1979', 2026), 47, 'calendar derives an age from a birth year')
assertEqual(calendar.ageFromBirthYear('1979', 2027), 48, 'calendar keeps counting as the years pass')
assertEqual(calendar.ageFromBirthYear('2026', 2026), 0, 'calendar makes someone born this year zero')
assertEqual(calendar.ageFromBirthYear('', 2026), 0, 'calendar derives no age without a birth year')
assertEqual(calendar.parseAge('47'), 47, 'calendar reads an age')
assertEqual(calendar.parseAge(47), 47, 'calendar reads a numeric age')
assertEqual(calendar.parseAge(' 47 '), 47, 'calendar trims an age')
assertEqual(calendar.parseAge(''), 0, 'calendar treats a blank age as unset')
assertEqual(calendar.parseAge('0'), 0, 'calendar treats zero as unset')
assertEqual(calendar.parseAge('-3'), 0, 'calendar treats a negative age as unset')
assertEqual(calendar.parseAge('121'), 0, 'calendar treats an implausible age as unset')
assertEqual(calendar.parseAge('abc'), 0, 'calendar treats a non-numeric age as unset')
assertEqual(calendar.parseAge('4.5'), 0, 'calendar treats a fractional age as unset')
assertEqual(calendar.parseLifeExpectancy('65'), 65, 'calendar reads a life expectancy')
assertEqual(calendar.parseLifeExpectancy(''), 90, 'calendar defaults the life expectancy to ninety')
assertEqual(calendar.parseLifeExpectancy(0), 90, 'calendar defaults an unset life expectancy')
assertEqual(calendar.parseLifeExpectancy('abc'), 90, 'calendar defaults a non-numeric life expectancy')
assertEqual(calendar.parseLifeExpectancy('200'), 90, 'calendar defaults an implausible life expectancy')
assertEqual(calendar.lifeProgressPercent(45, 90), 50, 'calendar measures a life against the expectancy given')
assertEqual(calendar.lifeProgressPercent(45, 65), 69, 'calendar honours a shorter expectancy')
assertEqual(calendar.lifeProgressPercent(45, ''), 50, 'calendar falls back to ninety when no expectancy is set')
assertEqual(calendar.lifeProgressPercent(90, 90), 100, 'calendar fills the life bar at the expectancy')
assertEqual(calendar.lifeProgressPercent(80, 65), 100, 'calendar never overfills the life bar')
assertEqual(calendar.lifeProgressPercent(0, 90), 0, 'calendar leaves the life bar empty when no age is set')
assertEqual(calendar.daysInYear(2024), 366, 'calendar knows leap years are longer')
assertEqual(calendar.daysInYear(2026), 365, 'calendar knows common years')
@@ -108,7 +147,7 @@ assert(/ipcTarget: "omarchy\.clock"/.test(panelSource), 'calendar panel register
assert(/manageIpc: false/.test(panelSource), 'calendar panel leaves the IPC target to the bar widget')
assert(/anchorItem: root\.anchorItem/.test(panelSource), 'calendar panel anchors to the host widget button')
assert(/function toggleWeekStart\(\)/.test(panelSource), 'calendar panel exposes a week start toggle')
assert(/setting\("weekStartDay", null\)/.test(panelSource) && /entry\.weekStartDay =/.test(panelSource), 'calendar reads and writes the week start as weekStartDay')
assert(/setting\("weekStartDay", null\)/.test(panelSource) && /persistSettings\(\{ weekStartDay:/.test(panelSource), 'calendar reads and writes the week start as weekStartDay')
assert(/updateEntryInline/.test(panelSource), 'calendar panel persists the week start to shell.json')
assert(/function moveMonth\(delta\)/.test(panelSource), 'calendar panel steps between months')
assert(!/property bool onToday/.test(panelSource) && !/root\.onToday/.test(panelSource), 'calendar panel avoids the on-prefixed property name QML reads as a signal handler')
@@ -123,6 +162,24 @@ assert(!/clampMonth/.test(panelSource), 'calendar steps freely into future month
assert(/Qt\.formatDate\(root\.today, "MMMM d"\)/.test(panelSource), 'calendar hero spells out today')
assert(/id: yearLabel/.test(panelSource) && /root\.yearDone/.test(panelSource), 'calendar panel shows the year progress bar')
// The memento mori bar is opt-in: double-tapping the year bar asks for an age,
// and nothing shows until one has been given.
assert(/onDoubleTapped: root\.startEditingLife\(\)/.test(panelSource), 'calendar asks for an age when the year bar is double-tapped')
assert(/persistSettings\(\{ birthYear: born, lifeExpectancy: span \}\)/.test(panelSource), 'calendar saves birth year and expectancy together, so neither lands on a stale copy')
assert(/readonly property int birthYear: Model\.parseBirthYear\(setting\("birthYear", 0\)/.test(panelSource), 'calendar reads the saved birth year back')
assert(/readonly property int age: Model\.ageFromBirthYear\(birthYear/.test(panelSource), 'calendar derives the age from the stored birth year')
assert(/readonly property int lifeExpectancy: Model\.parseLifeExpectancy\(setting\("lifeExpectancy", 0\)\)/.test(panelSource), 'calendar reads the saved expectancy back')
assert(/id: expectancyField/.test(panelSource) && /id: bornField/.test(panelSource), 'calendar offers both inputs')
assert(/visible: root\.editingLife\s*\n\s*anchors\.horizontalCenter: parent\.horizontalCenter/.test(panelSource), 'calendar centers the inputs over the bar they replace')
assert(/visible: root\.birthYear > 0/.test(panelSource), 'calendar hides the life bar until a birth year is known')
assert(/text: "LIFE"/.test(panelSource) && /root\.lifeDone/.test(panelSource), 'calendar shows the life bar')
assert(/text: "Memento Mori"/.test(panelSource), 'calendar names the life bar on hover')
assert(/onDoubleTapped: root\.clearLife\(\)/.test(panelSource), 'calendar puts the life bar away when it is double-tapped')
assert(/persistSettings\(\{ birthYear: 0 \}\)/.test(panelSource), 'calendar clears the birth year to hide the life bar')
assertEqual(calendar.parseBirthYear(0, 2026), 0, 'a cleared birth year reads back as unset')
assert(/blocked: root\.editingLife/.test(panelSource), 'calendar lets the inputs have the keyboard while they are up')
assert(/if \(root\.editingLife\) root\.cancelEditingLife\(\)/.test(panelSource), 'calendar drops a half-finished edit when the panel closes')
assert(/source: Qt\.resolvedUrl\("Panel\.qml"\)/.test(widgetSource), 'clock widget hosts the calendar panel')
assert(/readonly property bool opened:/.test(widgetSource), 'clock widget exposes the panel open state to shell routing')
assert(/Qt\.RightButton\) root\.cycleFormat\(\)/.test(widgetSource), 'clock right click cycles the label format')