diff --git a/apps/calendar-macos/CompassKit/Sources/CompassKit/Life/LifeDateFormat.swift b/apps/calendar-macos/CompassKit/Sources/CompassKit/Life/LifeDateFormat.swift new file mode 100644 index 0000000000..4c18cd19d4 --- /dev/null +++ b/apps/calendar-macos/CompassKit/Sources/CompassKit/Life/LifeDateFormat.swift @@ -0,0 +1,34 @@ +import Foundation + +/// Web `DatePicker dateFormat="MMM d, yyyy"` for the Life date of birth. The +/// stored value stays `yyyy-MM-dd`; this only changes what people see and type. +public enum LifeDateFormat { + private static let months = [ + "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", + ] + + /// `2026-06-10` becomes `Jun 10, 2026`; anything else is returned unchanged. + public static func display(_ stored: String) -> String { + guard let date = LifeMath.parseLifeDate(stored) else { return stored } + let calendar = EffectiveTimeZone.calendar + let year = calendar.component(.year, from: date) + let month = calendar.component(.month, from: date) + let day = calendar.component(.day, from: date) + return "\(months[month - 1]) \(day), \(year)" + } + + /// Accepts `yyyy-MM-dd` or `MMM d, yyyy` (comma optional) and returns `yyyy-MM-dd`. + public static func storedValue(from typed: String) -> String? { + let trimmed = typed.trimmingCharacters(in: .whitespacesAndNewlines) + if LifeMath.parseLifeDate(trimmed) != nil { return trimmed } + let parts = trimmed.replacingOccurrences(of: ",", with: " ") + .split(separator: " ", omittingEmptySubsequences: true).map(String.init) + guard parts.count == 3, + let month = months.firstIndex(where: { $0.caseInsensitiveCompare(String(parts[0].prefix(3))) == .orderedSame }), + parts[0].count >= 3, + let day = Int(parts[1]), let year = Int(parts[2]) + else { return nil } + let value = String(format: "%04d-%02d-%02d", year, month + 1, day) + return LifeMath.parseLifeDate(value) == nil ? nil : value + } +} diff --git a/apps/calendar-macos/CompassKit/Sources/CompassKit/Life/LifeQuotes.swift b/apps/calendar-macos/CompassKit/Sources/CompassKit/Life/LifeQuotes.swift new file mode 100644 index 0000000000..9e0039d78d --- /dev/null +++ b/apps/calendar-macos/CompassKit/Sources/CompassKit/Life/LifeQuotes.swift @@ -0,0 +1,66 @@ +import Foundation + +/// Mirrors `LIFE_QUOTES` in `apps/calendar-web/src/views/Life/life-quotes.ts`. +public enum LifeQuotes { + public static let all: [String] = [ + "“Teach us to number our days, that we may gain a heart of wisdom.” (Psalm 90:12)", + "“It is not that we have a short time to live, but that we waste a lot of it.” (Seneca)", + "“How we spend our days is, of course, how we spend our lives.” (Annie Dillard)", + "“How many more times will you watch the full moon rise? Perhaps twenty. And yet it all seems limitless.” (Paul Bowles)", + "“Tell me, what is it you plan to do with your one wild and precious life?” (Mary Oliver)", + "“You could leave life right now. Let that determine what you do and say and think.” (Marcus Aurelius)", + "“Dost thou love life? Then do not squander time, for that is the stuff life is made of.” (Benjamin Franklin)", + "“As if you could kill time without injuring eternity.” (Henry David Thoreau)", + "“Time is the coin of your life. It is the only coin you have, and only you can determine how it will be spent.” (Carl Sandburg)", + "“Life is long, if you know how to use it.” (Seneca)", + "“Your time is limited, so don’t waste it living someone else’s life.” (Steve Jobs)", + "“The average human lifespan is absurdly, terrifyingly, insultingly short.” (Oliver Burkeman)", + "“Forever is composed of nows.” (Emily Dickinson)", + "“Time is the substance I am made of. Time is a river which sweeps me along, but I am the river.” (Jorge Luis Borges)", + "“Begin at once to live, and count each separate day as a separate life.” (Seneca)", + "“While we are postponing, life speeds by.” (Seneca)", + "“Nothing is ours except time.” (Seneca)", + "“Do not act as if you were going to live ten thousand years. Death hangs over you. While you live, while it is in your power, be good.” (Marcus Aurelius)", + "“Think of yourself as dead. You have lived your life. Now take what’s left and live it properly.” (Marcus Aurelius)", + "“It is not death that a man should fear, but he should fear never beginning to live.” (Marcus Aurelius)", + "“Seize the day, put very little trust in tomorrow.” (Horace)", + "“The Moving Finger writes; and, having writ, moves on.” (Omar Khayyam)", + "“To every thing there is a season, and a time to every purpose under the heaven.” (Ecclesiastes 3:1)", + "“Life can only be understood backwards; but it must be lived forwards.” (Soren Kierkegaard)", + "“The unexamined life is not worth living.” (Socrates)", + "“We are always getting ready to live but never living.” (Ralph Waldo Emerson)", + "“Lost time is never found again.” (Benjamin Franklin)", + "“Time is what we want most, but what we use worst.” (William Penn)", + "“The days are long, but the years are short.” (Gretchen Rubin)", + "“Spend the afternoon. You can’t take it with you.” (Annie Dillard)", + "“The trouble is, you think you have time.” (Jack Kornfield)", + "“It passes on just like this, not ceasing day or night.” (Confucius, watching a river)", + "“The months and days are the travelers of eternity. The years that come and go are also voyagers.” (Matsuo Basho)", + "“If not now, when?” (Hillel the Elder)", + "“We are such stuff as dreams are made on, and our little life is rounded with a sleep.” (William Shakespeare)", + "“As a well-spent day brings happy sleep, so a life well spent brings happy death.” (Leonardo da Vinci)", + "“The value of life lies not in the length of days, but in the use we make of them.” (Michel de Montaigne)", + "“Time and tide wait for no man.” (English proverb)", + "“The best time to plant a tree was twenty years ago. The second best time is now.” (Proverb)", + "“A man who dares to waste one hour of time has not discovered the value of life.” (Charles Darwin)", + "“We are like butterflies who flutter for a day and think it is forever.” (Carl Sagan)", + "“Nobody sees a flower really; it is so small. We haven’t time, and to see takes time, like to have a friend takes time.” (Georgia O’Keeffe)", + "“How wonderful it is that nobody need wait a single moment before starting to improve the world.” (Anne Frank)", + "“The purpose of life is to live it, to taste experience to the utmost, to reach out eagerly and without fear for newer and richer experience.” (Eleanor Roosevelt)", + "“I took a deep breath and listened to the old brag of my heart. I am, I am, I am.” (Sylvia Plath)", + "“The time is always right to do what is right.” (Martin Luther King Jr.)", + "“Don’t count the days; make the days count.” (Muhammad Ali)", + "“I urge you to please notice when you are happy, and exclaim or murmur or think at some point, 'If this isn’t nice, I don’t know what is.'” (Kurt Vonnegut)", + "“It is good to have an end to journey toward; but it is the journey that matters, in the end.” (Ursula K. Le Guin)", + "“Life moves pretty fast. If you don’t stop and look around once in a while, you could miss it.” (Ferris Bueller’s Day Off)", + ] + + public static func random(using generator: inout G) -> String { + all.randomElement(using: &generator) ?? all[0] + } + + public static func random() -> String { + var generator = SystemRandomNumberGenerator() + return random(using: &generator) + } +} diff --git a/apps/calendar-macos/CompassKitTests/LifeQuotesAndDateFormatTests.swift b/apps/calendar-macos/CompassKitTests/LifeQuotesAndDateFormatTests.swift new file mode 100644 index 0000000000..7c889c8b59 --- /dev/null +++ b/apps/calendar-macos/CompassKitTests/LifeQuotesAndDateFormatTests.swift @@ -0,0 +1,36 @@ +import CompassKit +import XCTest + +final class LifeQuotesAndDateFormatTests: XCTestCase { + private var savedZone = "UTC" + + override func setUp() { + savedZone = EffectiveTimeZone.identifier + EffectiveTimeZone.identifier = "America/Denver" + } + + override func tearDown() { + EffectiveTimeZone.identifier = savedZone + } + + func testQuoteListMatchesWebAndEveryQuoteIsAttributed() { + XCTAssertEqual(LifeQuotes.all.count, 50) + XCTAssertTrue(LifeQuotes.all.contains("“Lost time is never found again.” (Benjamin Franklin)")) + XCTAssertTrue(LifeQuotes.all.allSatisfy { $0.hasPrefix("“") && $0.hasSuffix(")") }) + XCTAssertTrue(LifeQuotes.all.contains(LifeQuotes.random())) + } + + func testDisplayUsesWebsMonthDayYear() { + XCTAssertEqual(LifeDateFormat.display("2026-06-10"), "Jun 10, 2026") + XCTAssertEqual(LifeDateFormat.display("1999-12-01"), "Dec 1, 1999") + XCTAssertEqual(LifeDateFormat.display(""), "") + } + + func testTypedDatesAcceptBothFormats() { + XCTAssertEqual(LifeDateFormat.storedValue(from: "Jun 10, 2026"), "2026-06-10") + XCTAssertEqual(LifeDateFormat.storedValue(from: "jun 10 2026"), "2026-06-10") + XCTAssertEqual(LifeDateFormat.storedValue(from: "1990-02-03"), "1990-02-03") + XCTAssertNil(LifeDateFormat.storedValue(from: "Feb 31, 2026")) + XCTAssertNil(LifeDateFormat.storedValue(from: "someday")) + } +} diff --git a/apps/calendar-macos/CompassUI/Sources/CompassUI/Life/LifeSidebarView.swift b/apps/calendar-macos/CompassUI/Sources/CompassUI/Life/LifeSidebarView.swift index 4ef827fb29..75e9999817 100644 --- a/apps/calendar-macos/CompassUI/Sources/CompassUI/Life/LifeSidebarView.swift +++ b/apps/calendar-macos/CompassUI/Sources/CompassUI/Life/LifeSidebarView.swift @@ -8,6 +8,8 @@ struct LifeSidebarView: View { @State private var birthDate = "" @State private var lifespan = "" @State private var invalidBirthDate = false + /// Web `LifeQuote`: one random quote per visit to the sidebar. + @State private var quote = LifeQuotes.random() @FocusState private var focusedField: Field? private enum Field { case birthDate, lifespan } @@ -38,7 +40,7 @@ struct LifeSidebarView: View { .accessibilityLabel("Date of birth") .focused($focusedField, equals: .birthDate) .onSubmit { commitBirthDate() } - .help("Enter a date as yyyy-mm-dd") + .help("Enter a date like Jun 10, 2026") .padding(.vertical, 8).frame(width: 176) .background(theme.surfaceColor).foregroundStyle(theme.textColor) .clipShape(RoundedRectangle(cornerRadius: 4)) @@ -69,7 +71,7 @@ struct LifeSidebarView: View { } .textFieldStyle(.plain) .multilineTextAlignment(.center) - Text("“Lost time is never found again.” (Benjamin Franklin)").italic() + Text(quote).italic() } .font(.rubik(14)) .foregroundStyle(theme.textMutedColor) @@ -97,12 +99,15 @@ struct LifeSidebarView: View { } private func refreshInputs() { - birthDate = model.lifeStore.preferences.birthDate + birthDate = LifeDateFormat.display(model.lifeStore.preferences.birthDate) lifespan = String(model.lifeStore.preferences.lifespan) } private func commitBirthDate() { - invalidBirthDate = !model.lifeStore.updateBirthDate(birthDate) + let typed = birthDate.trimmingCharacters(in: .whitespacesAndNewlines) + // An empty field clears the date; otherwise accept "Jun 10, 2026" or "2026-06-10". + invalidBirthDate = !model.lifeStore.updateBirthDate( + typed.isEmpty ? "" : (LifeDateFormat.storedValue(from: typed) ?? typed)) refreshInputs() }