Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import PackageDescription

let package = Package(
name: "NotchBar",
defaultLocalization: "en",
platforms: [
.macOS(.v14),
],
Expand All @@ -15,7 +16,8 @@ let package = Package(
targets: [
.executableTarget(
name: "NotchBar",
path: "Sources"
path: "Sources",
resources: [.process("Resources")]
),
.testTarget(
name: "NotchBarTests",
Expand Down
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ Minimalist macOS app that uses the physical notch to show progress on the curren
## What it does

- Pick one or more calendars (checkboxes in Settings).
- If a tracked calendar is deleted or unshared, NotchBar won't auto-pick a replacement — reselect one in Settings.
- English and French, following the system language.
- The notch stays solid black at rest — no information leaks until you hover.
- On hover, the panel expands and shows one of six contextual states, computed across the events of every tracked calendar:

Expand All @@ -25,7 +27,8 @@ Sources/
├── NotchPanel/ # NSPanel windows, hover tracking, SwiftUI rendering
├── Calendar/ # EventKit access + event snapshot model
├── Settings/ # UserDefaults-backed preferences + settings UI
└── Utilities/ScreenHelper.swift # Physical notch geometry
├── Utilities/ # ScreenHelper (notch geometry) + Localized helper
└── Resources/ # en.lproj/ + fr.lproj/ Localizable.strings, processed natively by SwiftPM
Tests/
└── NotchBarTests/ # XCTest target (@testable import NotchBar)
Supporting/
Expand Down
58 changes: 31 additions & 27 deletions Sources/Calendar/EventProgressModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,36 +23,40 @@ struct EventProgressSnapshot: Equatable {
let state: State
var joinURL: URL?

static let noCalendar = EventProgressSnapshot(
title: "",
progress: 0,
startTimeLabel: "",
endTimeLabel: "",
elapsedLabel: "",
remainingLabel: "",
statusLabel: "",
secondaryMessage: "Pick a calendar in Settings",
tint: Color.secondary.opacity(0.35),
state: .noCalendar
)

static let emptyToday = EventProgressSnapshot(
title: "",
progress: 0,
startTimeLabel: "",
endTimeLabel: "",
elapsedLabel: "",
remainingLabel: "",
statusLabel: "",
secondaryMessage: "No event today",
tint: Color.secondary.opacity(0.35),
state: .emptyToday
)
static func noCalendar(locale: Locale = .current) -> EventProgressSnapshot {
EventProgressSnapshot(
title: "",
progress: 0,
startTimeLabel: "",
endTimeLabel: "",
elapsedLabel: "",
remainingLabel: "",
statusLabel: "",
secondaryMessage: Localized.string("Pick a calendar in Settings", locale: locale),
tint: Color.secondary.opacity(0.35),
state: .noCalendar
)
}

static func emptyToday(locale: Locale = .current) -> EventProgressSnapshot {
EventProgressSnapshot(
title: "",
progress: 0,
startTimeLabel: "",
endTimeLabel: "",
elapsedLabel: "",
remainingLabel: "",
statusLabel: "",
secondaryMessage: Localized.string("No event today", locale: locale),
tint: Color.secondary.opacity(0.35),
state: .emptyToday
)
}
}

@MainActor
final class EventProgressModel: ObservableObject {
@Published private(set) var snapshot: EventProgressSnapshot = .noCalendar
@Published private(set) var snapshot: EventProgressSnapshot = .noCalendar()
@Published private(set) var isHoverVisible = false

private var timerTask: Task<Void, Never>?
Expand Down Expand Up @@ -87,7 +91,7 @@ final class EventProgressModel: ObservableObject {

func refreshSnapshot() {
guard let calendarManager else {
updateSnapshot(.noCalendar)
updateSnapshot(.noCalendar())
return
}

Expand Down
73 changes: 48 additions & 25 deletions Sources/Calendar/SnapshotBuilder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,37 +6,42 @@ enum SnapshotBuilder {
events: [CalendarEvent],
selectedCalendarIDs: Set<String>,
now: Date,
calendar: Calendar
calendar: Calendar,
locale: Locale = .current
) -> EventProgressSnapshot {
guard !selectedCalendarIDs.isEmpty else { return .noCalendar }
guard !selectedCalendarIDs.isEmpty else { return .noCalendar(locale: locale) }

let relevant = events
.filter { selectedCalendarIDs.contains($0.calendarIdentifier) }
.sorted { $0.startDate < $1.startDate }

if let current = relevant.first(where: { $0.startDate <= now && $0.endDate > now }) {
return inProgressSnapshot(for: current, now: now)
return inProgressSnapshot(for: current, now: now, locale: locale)
}

guard let next = relevant.first(where: { $0.startDate > now }) else {
return .emptyToday
return .emptyToday(locale: locale)
}

let secondsUntilStart = next.startDate.timeIntervalSince(now)

if secondsUntilStart <= 5 * 60 {
return startingSoonSnapshot(for: next, now: now)
return startingSoonSnapshot(for: next, now: now, locale: locale)
}

let endOfToday = calendar.startOfDay(for: now.addingTimeInterval(86400))
if next.startDate < endOfToday {
return upcomingTodaySnapshot(for: next, now: now)
return upcomingTodaySnapshot(for: next, now: now, locale: locale)
}

return upcomingLaterSnapshot(for: next, now: now)
return upcomingLaterSnapshot(for: next, now: now, locale: locale)
}

private static func upcomingLaterSnapshot(for event: CalendarEvent, now: Date) -> EventProgressSnapshot {
private static func upcomingLaterSnapshot(
for event: CalendarEvent,
now: Date,
locale: Locale
) -> EventProgressSnapshot {
let interval = max(event.startDate.timeIntervalSince(now), 0)
let totalSeconds = Int(interval)
let days = totalSeconds / 86400
Expand All @@ -45,49 +50,62 @@ enum SnapshotBuilder {
let seconds = totalSeconds % 60

let countdown = String(format: "%02d:%02d:%02d:%02d", days, hours, minutes, seconds)
let title = event.title.nilIfEmpty
?? Localized.string("Upcoming Meeting", locale: locale)

return EventProgressSnapshot(
title: event.title.nilIfEmpty ?? "Upcoming Meeting",
title: title,
progress: 0,
startTimeLabel: formattedTime(event.startDate),
endTimeLabel: formattedTime(event.endDate),
elapsedLabel: "",
remainingLabel: "",
statusLabel: "Upcoming",
secondaryMessage: "Next event in: \(countdown)",
statusLabel: Localized.string("Upcoming", locale: locale),
secondaryMessage: Localized.string("Next event in: \(countdown)", locale: locale),
tint: event.color,
state: .upcomingLater
)
}

private static func inProgressSnapshot(for event: CalendarEvent, now: Date) -> EventProgressSnapshot {
private static func inProgressSnapshot(
for event: CalendarEvent,
now: Date,
locale: Locale
) -> EventProgressSnapshot {
let total = event.endDate.timeIntervalSince(event.startDate)
let elapsed = now.timeIntervalSince(event.startDate)
let progress = min(max(elapsed / max(total, 1), 0), 1)
let elapsedSeconds = max(Int(elapsed), 0)
let remainingSeconds = max(Int(event.endDate.timeIntervalSince(now)), 0)
let title = event.title.nilIfEmpty
?? Localized.string("Current Meeting", locale: locale)

return EventProgressSnapshot(
title: event.title.nilIfEmpty ?? "Current Meeting",
title: title,
progress: progress,
startTimeLabel: formattedTime(event.startDate),
endTimeLabel: formattedTime(event.endDate),
elapsedLabel: formatDuration(seconds: elapsedSeconds),
remainingLabel: formatDuration(seconds: remainingSeconds),
statusLabel: "In progress",
statusLabel: Localized.string("In progress", locale: locale),
secondaryMessage: nil,
tint: event.color,
state: .inProgress,
joinURL: event.joinURL
)
}

private static func startingSoonSnapshot(for event: CalendarEvent, now: Date) -> EventProgressSnapshot {
let title = event.title.nilIfEmpty ?? "Upcoming Meeting"
private static func startingSoonSnapshot(
for event: CalendarEvent,
now: Date,
locale: Locale
) -> EventProgressSnapshot {
let title = event.title.nilIfEmpty
?? Localized.string("Upcoming Meeting", locale: locale)
let minutes = max(Int(event.startDate.timeIntervalSince(now) / 60), 0)
let message = minutes == 0
? "Starts now — \(title)"
: "Starts in \(minutes)m — \(title)"
? Localized.string("Starts now — \(title)", locale: locale)
: Localized.string("Starts in \(minutes)m — \(title)", locale: locale)

return EventProgressSnapshot(
title: title,
Expand All @@ -96,24 +114,29 @@ enum SnapshotBuilder {
endTimeLabel: formattedTime(event.endDate),
elapsedLabel: "",
remainingLabel: "",
statusLabel: "Starts soon",
statusLabel: Localized.string("Starts soon", locale: locale),
secondaryMessage: message,
tint: event.color,
state: .startingSoon,
joinURL: event.joinURL
)
}

private static func upcomingTodaySnapshot(for event: CalendarEvent, now: Date) -> EventProgressSnapshot {
let title = event.title.nilIfEmpty ?? "Upcoming Meeting"
private static func upcomingTodaySnapshot(
for event: CalendarEvent,
now: Date,
locale: Locale
) -> EventProgressSnapshot {
let title = event.title.nilIfEmpty
?? Localized.string("Upcoming Meeting", locale: locale)
let interval = max(event.startDate.timeIntervalSince(now), 0)
let totalMinutes = Int(interval / 60)
let hours = totalMinutes / 60
let minutes = totalMinutes % 60

let countdown = hours == 0
? "\(minutes)min"
: "\(hours)h \(minutes)min"
? Localized.string("\(minutes)min", locale: locale)
: Localized.string("\(hours)h \(minutes)min", locale: locale)

return EventProgressSnapshot(
title: title,
Expand All @@ -122,8 +145,8 @@ enum SnapshotBuilder {
endTimeLabel: formattedTime(event.endDate),
elapsedLabel: "",
remainingLabel: "",
statusLabel: "Upcoming today",
secondaryMessage: "Next: \(title) in \(countdown)",
statusLabel: Localized.string("Upcoming today", locale: locale),
secondaryMessage: Localized.string("Next: \(title) in \(countdown)", locale: locale),
tint: event.color,
state: .upcomingToday
)
Expand Down
8 changes: 6 additions & 2 deletions Sources/NotchBarApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,18 @@ struct NotchBarApp: App {
}
)
Divider()
Button("Refresh Events") {
Button {
Task {
await appDelegate.calendarManager.refreshEvents(using: appDelegate.preferences)
appDelegate.progressModel.refreshSnapshot()
}
} label: {
Text("Refresh Events", bundle: .module)
}
Button("Quit") {
Button {
NSApp.terminate(nil)
} label: {
Text("Quit", bundle: .module)
}
}
.menuBarExtraStyle(.window)
Expand Down
10 changes: 5 additions & 5 deletions Sources/NotchPanel/NotchPanelView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -95,11 +95,11 @@ private struct InProgressContent: View {
.frame(height: 13)

HStack(spacing: 12) {
MetricLabel(title: "Elapsed", value: snapshot.elapsedLabel)
MetricLabel(titleKey: "Elapsed", value: snapshot.elapsedLabel)

Spacer(minLength: 0)

MetricLabel(title: "Remaining", value: snapshot.remainingLabel)
MetricLabel(titleKey: "Remaining", value: snapshot.remainingLabel)
}
}
}
Expand Down Expand Up @@ -194,12 +194,12 @@ private struct TimeRangeView: View {
}

private struct MetricLabel: View {
let title: String
let titleKey: LocalizedStringKey
let value: String

var body: some View {
HStack(spacing: 7) {
Text(title)
Text(titleKey, bundle: .module)
.foregroundStyle(.white.opacity(0.42))
Text(value.isEmpty ? "--:--:--" : value)
.monospacedDigit()
Expand Down Expand Up @@ -306,7 +306,7 @@ private struct JoinButton: View {
HStack(spacing: 6) {
Image(systemName: "video.fill")
.font(.system(size: 12, weight: .bold))
Text("Join")
Text("Join", bundle: .module)
.font(.system(size: 13, weight: .semibold, design: .rounded))
}
.foregroundStyle(.white)
Expand Down
24 changes: 24 additions & 0 deletions Sources/Resources/en.lproj/Localizable.strings
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
"%lldh %lldmin" = "%lldh %lldmin";
"%lldmin" = "%lldmin";
"Calendar access is disabled. Enable it in System Settings > Privacy & Security > Calendars." = "Calendar access is disabled. Enable it in System Settings > Privacy & Security > Calendars.";
"Current Meeting" = "Current Meeting";
"Elapsed" = "Elapsed";
"In progress" = "In progress";
"Join" = "Join";
"Launch at login" = "Launch at login";
"Next event in: %@" = "Next event in: %@";
"Next: %@ in %@" = "Next: %@ in %@";
"No event today" = "No event today";
"NotchBar has write-only calendar access and cannot read your events. Switch it to Full Access in System Settings > Privacy & Security > Calendars." = "NotchBar has write-only calendar access and cannot read your events. Switch it to Full Access in System Settings > Privacy & Security > Calendars.";
"Pick a calendar in Settings" = "Pick a calendar in Settings";
"Quit" = "Quit";
"Quit NotchBar" = "Quit NotchBar";
"Refresh Events" = "Refresh Events";
"Remaining" = "Remaining";
"Starts in %lldm — %@" = "Starts in %lldm — %@";
"Starts now — %@" = "Starts now — %@";
"Starts soon" = "Starts soon";
"Tracked Calendars" = "Tracked Calendars";
"Upcoming" = "Upcoming";
"Upcoming Meeting" = "Upcoming Meeting";
"Upcoming today" = "Upcoming today";
24 changes: 24 additions & 0 deletions Sources/Resources/fr.lproj/Localizable.strings
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
"%lldh %lldmin" = "%lld h %lld min";
"%lldmin" = "%lld min";
"Calendar access is disabled. Enable it in System Settings > Privacy & Security > Calendars." = "L'accès au calendrier est désactivé. Activez-le dans Réglages Système > Confidentialité et sécurité > Calendriers.";
"Current Meeting" = "Réunion en cours";
"Elapsed" = "Écoulé";
"In progress" = "En cours";
"Join" = "Rejoindre";
"Launch at login" = "Ouvrir à la connexion";
"Next event in: %@" = "Prochain événement dans : %@";
"Next: %@ in %@" = "Prochain : %@ dans %@";
"No event today" = "Aucun événement aujourd'hui";
"NotchBar has write-only calendar access and cannot read your events. Switch it to Full Access in System Settings > Privacy & Security > Calendars." = "NotchBar a un accès en écriture seule et ne peut pas lire vos événements. Passez à l'accès complet dans Réglages Système > Confidentialité et sécurité > Calendriers.";
"Pick a calendar in Settings" = "Choisissez un calendrier dans les Réglages";
"Quit" = "Quitter";
"Quit NotchBar" = "Quitter NotchBar";
"Refresh Events" = "Actualiser les événements";
"Remaining" = "Restant";
"Starts in %lldm — %@" = "Commence dans %lld min — %@";
"Starts now — %@" = "Commence maintenant — %@";
"Starts soon" = "Commence bientôt";
"Tracked Calendars" = "Calendriers suivis";
"Upcoming" = "À venir";
"Upcoming Meeting" = "Réunion à venir";
"Upcoming today" = "À venir aujourd'hui";
Loading