Compare commits

...
3 Commits
Author SHA1 Message Date
tomas.kracmarandClaude Sonnet 5 e45a7dbe81 Release 1.10.0
Mirror "Maybe" (tentative RSVP) events instead of dropping them.

Previously, with "Mirror accepted events only" on, events you'd replied
tentative to were skipped entirely. They're now mirrored with a "Maybe: "
marker in the title (after the normal prefix) and written with tentative
availability so Calendar shows them hatched. The marker is applied for any
tentative RSVP regardless of the accepted-only setting; an accepted<->
tentative flip re-syncs the title. Not carried on the merge path
(mergeGapMin > 0), which already discards per-event titles.

Block gains a `tentative` flag (in ==/hash); accepted-only gate lets
.tentative through alongside .accepted.

All unit tests pass.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-31 15:30:55 +02:00
tomas.kracmarandClaude Sonnet 5 9e3dd93939 Release 1.9.2
Fix Sync Now toolbar button showing icon-only, no text label. Confirmed
via screenshot that 1.9.1's sidebar/toolbar fixes worked; this was the
remaining clarity issue -- forced .labelStyle(.titleAndIcon) so the label
always shows.

All unit tests pass.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 17:48:42 +02:00
tomas.kracmarandClaude Sonnet 5 622a85f4fd Release 1.9.1
Fix sidebar not clickable: List(SidebarSection.allCases, selection:) ties
selection to Identifiable's id (String) while the binding was typed
SidebarSection? -- rebuilt with the standard List(selection:) { ForEach {
.tag(section) } } pattern instead, plus .listStyle(.sidebar).

Fix "Write to calendars" hard to find: still present as the Dry Run/Write
toolbar segmented control, but likely lost to toolbar overflow alongside a
status readout, Sync Now, and the overflow menu all in one
ToolbarItemGroup. Moved status out to .navigationSubtitle (native, never
overflows), toolbar now just Dry Run/Write, Sync Now/Cancel, overflow menu.

All unit tests pass.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 17:44:44 +02:00
6 changed files with 69 additions and 32 deletions
+4 -4
View File
@@ -410,7 +410,7 @@
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "-";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 28;
CURRENT_PROJECT_VERSION = 31;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = BusyMirror/Info.plist;
@@ -421,7 +421,7 @@
"$(inherited)",
"@executable_path/../Frameworks",
);
MARKETING_VERSION = 1.9.0;
MARKETING_VERSION = 1.10.0;
PRODUCT_BUNDLE_IDENTIFIER = com.cqrenet.BusyMirror;
PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES;
@@ -440,7 +440,7 @@
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "-";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 28;
CURRENT_PROJECT_VERSION = 31;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = BusyMirror/Info.plist;
@@ -451,7 +451,7 @@
"$(inherited)",
"@executable_path/../Frameworks",
);
MARKETING_VERSION = 1.9.0;
MARKETING_VERSION = 1.10.0;
PRODUCT_BUNDLE_IDENTIFIER = com.cqrenet.BusyMirror;
PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES;
+5 -1
View File
@@ -8,6 +8,8 @@ struct Block: Hashable {
let notes: String? // source notes (for optional copy)
let occurrence: Date? // occurrenceDate for recurring instances
let alarmOffsets: [TimeInterval]? // relative alarm offsets copied from source event
var tentative: Bool = false // source "Maybe" RSVP; mirrored with a marker
// ponytail: lost on the merge path (mergeGapMin > 0) along with the title; only tracked in per-event mode
/// Convenience factory for time-only blocks (used internally for occupancy tracking).
static func span(start: Date, end: Date) -> Block {
@@ -22,7 +24,8 @@ struct Block: Hashable {
lhs.srcStableID == rhs.srcStableID &&
lhs.label == rhs.label &&
lhs.notes == rhs.notes &&
lhs.occurrence == rhs.occurrence
lhs.occurrence == rhs.occurrence &&
lhs.tentative == rhs.tentative
}
func hash(into hasher: inout Hasher) {
@@ -32,6 +35,7 @@ struct Block: Hashable {
hasher.combine(label)
hasher.combine(notes)
hasher.combine(occurrence)
hasher.combine(tentative)
}
}
+24 -21
View File
@@ -568,22 +568,25 @@ struct ContentView: View {
@ViewBuilder
private var sidebarView: some View {
List(SidebarSection.allCases, selection: $selectedSection) { section in
Label {
HStack {
Text(section.title)
if section == .routes && !routes.isEmpty {
Spacer()
Text("\(routes.count)")
.font(.caption)
.foregroundStyle(.secondary)
List(selection: $selectedSection) {
ForEach(SidebarSection.allCases) { section in
Label {
HStack {
Text(section.title)
if section == .routes && !routes.isEmpty {
Spacer()
Text("\(routes.count)")
.font(.caption)
.foregroundStyle(.secondary)
}
}
} icon: {
Image(systemName: section.icon)
}
} icon: {
Image(systemName: section.icon)
.tag(section)
}
.tag(section as SidebarSection?)
}
.listStyle(.sidebar)
.navigationSplitViewColumnWidth(min: 180, ideal: 200)
}
@@ -727,6 +730,12 @@ struct ContentView: View {
}
}
private var statusSubtitle: String {
if let progressText { return progressText }
if isRunning { return "Running…" }
return hasAccess ? "\(calendars.count) calendars" : "No calendar access"
}
@ToolbarContentBuilder
private var toolbarContent: some ToolbarContent {
ToolbarItemGroup {
@@ -737,15 +746,7 @@ struct ContentView: View {
.pickerStyle(.segmented)
.frame(width: 150)
.disabled(isRunning)
HStack(spacing: 6) {
Circle()
.fill(isRunning ? Color.orange : (appController.lastRunFailed ? Color.red : Color.secondary))
.frame(width: 7, height: 7)
Text(progressText ?? (isRunning ? "Running…" : (hasAccess ? "\(calendars.count) calendars" : "No access")))
.font(.caption)
.foregroundStyle(.secondary)
}
.help("Dry Run previews changes without writing. Write actually creates/updates/deletes events.")
if isRunning {
Button("Cancel") { cancelMirror() }
@@ -754,6 +755,7 @@ struct ContentView: View {
startMirrorNow()
} label: {
Label("Sync Now", systemImage: "arrow.triangle.2.circlepath")
.labelStyle(.titleAndIcon)
}
.disabled(!canRunMirrorNow)
}
@@ -813,6 +815,7 @@ struct ContentView: View {
}
}
.navigationTitle((selectedSection ?? .routes).title)
.navigationSubtitle(statusSubtitle)
.toolbar { toolbarContent }
}
.confirmationDialog(
+10 -6
View File
@@ -145,10 +145,12 @@ final class MirrorEngine {
var skippedStatus = 0
for ev in srcEvents {
if Task.isCancelled { break }
let myStatus = ev.attendees?.first(where: { $0.isCurrentUser })?.participantStatus
let isTentative = (myStatus == .tentative)
if config.mirrorAcceptedOnly, ev.hasAttendees {
let attendees = ev.attendees ?? []
if let me = attendees.first(where: { $0.isCurrentUser }) {
if me.participantStatus != .accepted {
if let me = ev.attendees?.first(where: { $0.isCurrentUser }) {
// "Maybe" replies still get mirrored, just marked (see displayTitle below)
if me.participantStatus != .accepted && me.participantStatus != .tentative {
skippedStatus += 1
continue
}
@@ -178,7 +180,7 @@ final class MirrorEngine {
guard let s = ev.startDate, let e = ev.endDate, e > s else { continue }
guard ev.calendar.calendarIdentifier == srcCal.calendarIdentifier else { continue }
let srcID = stableSourceIdentifier(for: ev)
srcBlocks.append(Block(start: s, end: e, srcStableID: srcID, label: ev.title, notes: ev.notes, occurrence: ev.occurrenceDate, alarmOffsets: alarmOffsets(for: ev)))
srcBlocks.append(Block(start: s, end: e, srcStableID: srcID, label: ev.title, notes: ev.notes, occurrence: ev.occurrenceDate, alarmOffsets: alarmOffsets(for: ev), tentative: isTentative))
}
if skippedMirrors > 0 {
log("- SKIP mirrored-on-source: \(skippedMirrors) instance(s)")
@@ -379,7 +381,8 @@ final class MirrorEngine {
let baseSourceTitle = stripPrefix(blk.label, prefix: config.titlePrefix)
let effectiveTitle = config.hideDetails ? config.placeholderTitle : (baseSourceTitle.isEmpty ? config.placeholderTitle : baseSourceTitle)
let titleSuffix = config.hideDetails ? "" : (baseSourceTitle.isEmpty ? "" : " — \(baseSourceTitle)")
let displayTitle = (config.titlePrefix.isEmpty ? "" : config.titlePrefix) + effectiveTitle
let maybeMark = blk.tentative ? "Maybe: " : ""
let displayTitle = (config.titlePrefix.isEmpty ? "" : config.titlePrefix) + maybeMark + effectiveTitle
let notes = desiredNotes(for: blk)
let desiredURL = buildMirrorURL(
targetCalID: tgt.calendarIdentifier,
@@ -409,6 +412,7 @@ final class MirrorEngine {
return
}
existing.title = displayTitle
existing.availability = blk.tentative ? .tentative : .busy
existing.startDate = blk.start
existing.endDate = blk.end
existing.isAllDay = false
@@ -467,7 +471,7 @@ final class MirrorEngine {
newEv.notes = notes
newEv.url = desiredURL
newEv.alarms = desiredAlarms(for: blk)
newEv.availability = .busy
newEv.availability = blk.tentative ? .tentative : .busy
do {
try store.save(newEv, span: .thisEvent, commit: true)
created += 1
+8
View File
@@ -165,6 +165,14 @@ final class BlockMathTests: XCTestCase {
XCTAssertEqual(result.count, 2)
}
func testTentativeAffectsEqualityAndHash() {
let base = Block(start: d, end: d.addingTimeInterval(600), srcStableID: "a", label: "x", notes: nil, occurrence: d, alarmOffsets: nil)
var maybe = base
maybe.tentative = true
XCTAssertNotEqual(base, maybe)
XCTAssertNotEqual(base.hashValue, maybe.hashValue)
}
func testUniqueBlocksByIDDifferentOccurrence() {
let b1 = Block(start: d, end: d.addingTimeInterval(600), srcStableID: "a", label: nil, notes: nil, occurrence: d, alarmOffsets: nil)
let b2 = Block(start: d, end: d.addingTimeInterval(600), srcStableID: "a", label: nil, notes: nil, occurrence: d.addingTimeInterval(3600), alarmOffsets: nil)
+18
View File
@@ -2,6 +2,24 @@
All notable changes to BusyMirror will be documented in this file.
## [1.10.0] - 2026-08-31
### Added
- **"Maybe" events are now mirrored.** Previously, when "Mirror accepted events only" was on, events you'd RSVP'd tentative to were dropped entirely. They're now mirrored with a `Maybe: ` marker in the title (after the normal prefix, e.g. `🪞 Maybe: Standup`) and written with tentative availability so Calendar shows them hatched. Applies regardless of the accepted-only setting — any tentative RSVP gets the marker. A flip between accepted and tentative re-syncs the title. Not carried on the merge path (`mergeGapMin > 0`), which already discards per-event titles. ([MirrorEngine.swift](BusyMirror/MirrorEngine.swift), [BlockMath.swift](BusyMirror/BlockMath.swift))
## [1.9.2] - 2026-08-27
### Fixed
- **"Sync Now" toolbar button showed only its icon**, no text — confirmed via screenshot: the Dry Run/Write toggle and toolbar layout were actually fine (1.9.1's fix worked, sidebar clicks too), but the circular-arrows icon next to it was unlabeled, easy to miss as "Sync Now" entirely. Forced `.labelStyle(.titleAndIcon)` so the text always shows regardless of toolbar width. ([ContentView.swift](BusyMirror/ContentView.swift))
## [1.9.1] - 2026-08-27
### Fixed
- **Sidebar not clickable.** `sidebarView` used `List(SidebarSection.allCases, selection:)` (the data-driven initializer), which ties selection to `Identifiable`'s `id` — a `String` for `SidebarSection` — while the binding was typed `SidebarSection?`. Rebuilt with the standard `List(selection:) { ForEach(...) { ... .tag(section) } }` pattern, which binds selection directly to the (`Hashable`) section value with no ambiguity. Also added `.listStyle(.sidebar)`, the standard modifier for a `NavigationSplitView` sidebar (vibrancy, selection color) that was missing.
- **"Write to calendars" hard to find.** It was still there as the Dry Run/Write segmented control in the toolbar, but likely lost to toolbar overflow alongside a status readout, Sync Now, and the overflow menu all crammed into one `ToolbarItemGroup`. Moved the calendar-count/status text out of the toolbar entirely into `.navigationSubtitle` (always visible, native, never subject to overflow), leaving the toolbar to just Dry Run/Write, Sync Now/Cancel, and the overflow menu.
Not independently confirmed by clicking through the UI (no GUI automation available in this environment) — the `List(selection:)` fix follows SwiftUI's standard, unambiguous sidebar-selection pattern.
## [1.9.0] - 2026-08-27
### Added