From c7dd2beca30e0ddf58422b527311ca2f1e7f0397 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Mon, 6 Jul 2026 16:20:14 -0700 Subject: [PATCH] datewizard: live H:MM:SS countdown, machine-accent tint, tip badge, name tooltip - countdownText() -> live H:MM:SS to the next appointment (1s tick; falls back to whole days past 100h). Was coarse 3d/5h/12m. - wand badge + presence dot tint with NSColor.controlAccentColor (this machine's system accent) instead of the ROYGBIV day palette. - badge font 9.5->7.5pt and top-aligned (raised toward the wand's tip). - plumb the appointment title through WizardController->setNextEvent so the hover tooltip leads with the event name. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Sources/DateWizard/AppDelegate.swift | 2 +- .../Sources/DateWizard/MenuBarDays.swift | 26 +++++++++++-------- .../Sources/DateWizard/WizardController.swift | 4 +-- .../Sources/DateWizard/WizardRoster.swift | 18 +++++++------ 4 files changed, 28 insertions(+), 22 deletions(-) diff --git a/date-wizard/Sources/DateWizard/AppDelegate.swift b/date-wizard/Sources/DateWizard/AppDelegate.swift index 895aebd04f..357037f4c2 100644 --- a/date-wizard/Sources/DateWizard/AppDelegate.swift +++ b/date-wizard/Sources/DateWizard/AppDelegate.swift @@ -38,7 +38,7 @@ final class DateWizardAppDelegate: NSObject, NSApplicationDelegate { // Keep the menu bar strip's lit dot in sync with the wizard's focus. controller.onFocusedDayChanged = { [weak menu] date in menu?.setFocusedDay(date) } // Feed the next appointment to the wand's countdown badge. - controller.onNextEventChanged = { [weak menu] date in menu?.setNextEvent(date) } + controller.onNextEventChanged = { [weak menu] date, title in menu?.setNextEvent(date, title: title) } // Launch flags: open straight into a single-day view. Applied BEFORE // start() so the initial load fetches the day (not the week first). diff --git a/date-wizard/Sources/DateWizard/MenuBarDays.swift b/date-wizard/Sources/DateWizard/MenuBarDays.swift index dacdf7576a..2e5766f487 100644 --- a/date-wizard/Sources/DateWizard/MenuBarDays.swift +++ b/date-wizard/Sources/DateWizard/MenuBarDays.swift @@ -31,6 +31,7 @@ final class MenuBarDays { private var hoveredIndex: Int? private var focusedIndex: Int? // the day showing in the wizard private var nextEventDate: Date? // start of the next appointment (badge) + private var nextEventTitle: String? // name of that appointment (tooltip) private var countdownTimer: Timer? // ticks the badge text down private var midnightTimer: Timer? // Menu-bar-fit rung: 2 = full countdown pill, 1 = presence dot, 0 = bare @@ -93,29 +94,31 @@ final class MenuBarDays { /// The start of the next appointment (nil = nothing ahead). Drives the /// countdown badge on the wand. Called by the wizard whenever it reloads /// the upcoming set. - func setNextEvent(_ date: Date?) { + func setNextEvent(_ date: Date?, title: String? = nil) { let had = (nextEventDate != nil) + nextEventTitle = title if date != nextEventDate { nextEventDate = date; refresh() } + else { button?.toolTip = nextEventToolTip() } // same time, refreshed name // When an event appears/disappears, reshape the fit ladder so the broker // knows whether there's a badge to trade for space. if (date != nil) != had { fit?.updateRungs(fitRungs(hasNext: date != nil)) } } - /// Compact time-until-next-appointment: "3d", "5h", "12m", or "now". + /// Live H:MM:SS countdown to the next appointment ("now" at zero). Far-off + /// events (100h+) collapse to whole days, where ticking seconds are noise. private func countdownText() -> String? { guard let d = nextEventDate else { return nil } - let secs = d.timeIntervalSinceNow - if secs < 60 { return "now" } - if secs < 3600 { return "\(Int(secs / 60))m" } - if secs < 86400 { return "\(Int(secs / 3600))h" } - return "\(Int(secs / 86400))d" + let secs = Int(d.timeIntervalSinceNow) + if secs <= 0 { return "now" } + if secs >= 100 * 3600 { return "\(secs / 86400)d" } + return String(format: "%d:%02d:%02d", secs / 3600, (secs % 3600) / 60, secs % 60) } - // Re-render the badge as the countdown ticks. 30s keeps the minute readout - // within half a minute of accurate without burning cycles. + // Re-render the badge every second so the H:MM:SS readout ticks live. It's a + // tiny image redraw, and the wand sheds the badge under menu-bar pressure. private func scheduleCountdownTick() { countdownTimer?.invalidate() - let timer = Timer(timeInterval: 30, repeats: true) { [weak self] _ in self?.refresh() } + let timer = Timer(timeInterval: 1, repeats: true) { [weak self] _ in self?.refresh() } RunLoop.main.add(timer, forMode: .common) countdownTimer = timer } @@ -170,7 +173,8 @@ final class MenuBarDays { if cal.isDateInToday(d) { dayWord = "" } else if cal.isDateInTomorrow(d) { dayWord = "tomorrow " } else { let df = DateFormatter(); df.dateFormat = "EEE "; dayWord = df.string(from: d) } - return "Next: \(dayWord)\(fmt.string(from: d)) · \(base)" + let name = (nextEventTitle?.isEmpty == false) ? nextEventTitle! : "appointment" + return "\(name) — \(dayWord)\(fmt.string(from: d))\n\(base)" } // ── input ───────────────────────────────────────────────────────── diff --git a/date-wizard/Sources/DateWizard/WizardController.swift b/date-wizard/Sources/DateWizard/WizardController.swift index 1aec568ea3..a142511b7e 100644 --- a/date-wizard/Sources/DateWizard/WizardController.swift +++ b/date-wizard/Sources/DateWizard/WizardController.swift @@ -39,7 +39,7 @@ final class WizardController: NSWindowController, NSWindowDelegate, WeekViewDele // Broadcast the start of the next upcoming appointment (nil = none ahead) so // the menu bar can paint a countdown badge on the wand. Wired by AppDelegate. - var onNextEventChanged: ((Date?) -> Void)? + var onNextEventChanged: ((Date?, String?) -> Void)? // ── UI ─────────────────────────────────────────────────────────── private var backdrop: BackdropView! @@ -496,7 +496,7 @@ final class WizardController: NSWindowController, NSWindowDelegate, WeekViewDele // Next appointment = soonest timed event that hasn't started yet. Skip // all-day items so the badge always counts down to a real clock time. let next = upcoming.first { !$0.allDay && $0.start > now } - onNextEventChanged?(next?.start) + onNextEventChanged?(next?.start, next?.title) } private func loadDayGrid() { diff --git a/date-wizard/Sources/DateWizard/WizardRoster.swift b/date-wizard/Sources/DateWizard/WizardRoster.swift index 5f0ee7d1d9..dd2d89eace 100644 --- a/date-wizard/Sources/DateWizard/WizardRoster.swift +++ b/date-wizard/Sources/DateWizard/WizardRoster.swift @@ -57,10 +57,11 @@ func wizardGuy(scale s: CGFloat) -> NSImage { // bar thickness; wizardGuy is the fallback if the symbol is ever missing. // // With a `badge` string (e.g. "2h", "45m", "3d") the wand carries the countdown -// to the next appointment as plain text sitting to the LEFT of the glyph — no -// pill, no outline, and it never overlaps the wand. The text is tinted to -// today's ROYGBIV day color (DayPalette) so the whole app reads as one -// instrument. A colored badge can't ride a template image (macOS recolors those +// to the next appointment as small H:MM:SS text sitting to the LEFT of the glyph, +// raised toward the wand's tip — no pill, no outline, and it never overlaps the +// wand. The text is tinted to THIS MACHINE's system accent color +// (NSColor.controlAccentColor) so each box's wand reads as its own. A colored +// badge can't ride a template image (macOS recolors those // wholesale), so in that case we return a *non-template* image and tint the wand // (dark ? white : black) ourselves. // @@ -81,7 +82,7 @@ func wandGlyph(pointSize: CGFloat = 15, badge: String? = nil, dot: Bool = false, } let tinted = tintedImage(symbol, color: dark ? .white : .black) - let accent = DayPalette.color(for: Date()) // today's ROYGBIV hue + let accent = NSColor.controlAccentColor // this machine's system accent let gap: CGFloat = 2 // clear space between badge and wand // Presence-dot rung: a small day-colored dot, left of the wand (no overlap). @@ -101,15 +102,16 @@ func wandGlyph(pointSize: CGFloat = 15, badge: String? = nil, dot: Bool = false, } let badge = badge! - // Countdown rung: plain day-colored text, left of the wand, no pill/outline. - let font = NSFont.systemFont(ofSize: 9.5, weight: .bold) + // Countdown rung: small accent-colored text, left of the wand and raised to + // its tip (top-aligned), no pill/outline. + let font = NSFont.systemFont(ofSize: 7.5, weight: .bold) let textAttrs: [NSAttributedString.Key: Any] = [.font: font, .foregroundColor: accent] let textSize = (badge as NSString).size(withAttributes: textAttrs) let size = NSSize(width: textSize.width + gap + tinted.size.width, height: max(tinted.size.height, textSize.height)) let out = NSImage(size: size) out.lockFocus() - (badge as NSString).draw(at: NSPoint(x: 0, y: (size.height - textSize.height) / 2), + (badge as NSString).draw(at: NSPoint(x: 0, y: size.height - textSize.height), withAttributes: textAttrs) tinted.draw(at: NSPoint(x: textSize.width + gap, y: (size.height - tinted.size.height) / 2), from: .zero, operation: .sourceOver, fraction: 1) -- 2.51.2