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>
This commit is contained in:
@@ -410,7 +410,7 @@
|
|||||||
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "-";
|
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "-";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
COMBINE_HIDPI_IMAGES = YES;
|
COMBINE_HIDPI_IMAGES = YES;
|
||||||
CURRENT_PROJECT_VERSION = 30;
|
CURRENT_PROJECT_VERSION = 31;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = BusyMirror/Info.plist;
|
INFOPLIST_FILE = BusyMirror/Info.plist;
|
||||||
@@ -421,7 +421,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/../Frameworks",
|
"@executable_path/../Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.9.2;
|
MARKETING_VERSION = 1.10.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.cqrenet.BusyMirror;
|
PRODUCT_BUNDLE_IDENTIFIER = com.cqrenet.BusyMirror;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
REGISTER_APP_GROUPS = YES;
|
REGISTER_APP_GROUPS = YES;
|
||||||
@@ -440,7 +440,7 @@
|
|||||||
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "-";
|
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "-";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
COMBINE_HIDPI_IMAGES = YES;
|
COMBINE_HIDPI_IMAGES = YES;
|
||||||
CURRENT_PROJECT_VERSION = 30;
|
CURRENT_PROJECT_VERSION = 31;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = BusyMirror/Info.plist;
|
INFOPLIST_FILE = BusyMirror/Info.plist;
|
||||||
@@ -451,7 +451,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/../Frameworks",
|
"@executable_path/../Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.9.2;
|
MARKETING_VERSION = 1.10.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.cqrenet.BusyMirror;
|
PRODUCT_BUNDLE_IDENTIFIER = com.cqrenet.BusyMirror;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
REGISTER_APP_GROUPS = YES;
|
REGISTER_APP_GROUPS = YES;
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ struct Block: Hashable {
|
|||||||
let notes: String? // source notes (for optional copy)
|
let notes: String? // source notes (for optional copy)
|
||||||
let occurrence: Date? // occurrenceDate for recurring instances
|
let occurrence: Date? // occurrenceDate for recurring instances
|
||||||
let alarmOffsets: [TimeInterval]? // relative alarm offsets copied from source event
|
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).
|
/// Convenience factory for time-only blocks (used internally for occupancy tracking).
|
||||||
static func span(start: Date, end: Date) -> Block {
|
static func span(start: Date, end: Date) -> Block {
|
||||||
@@ -22,7 +24,8 @@ struct Block: Hashable {
|
|||||||
lhs.srcStableID == rhs.srcStableID &&
|
lhs.srcStableID == rhs.srcStableID &&
|
||||||
lhs.label == rhs.label &&
|
lhs.label == rhs.label &&
|
||||||
lhs.notes == rhs.notes &&
|
lhs.notes == rhs.notes &&
|
||||||
lhs.occurrence == rhs.occurrence
|
lhs.occurrence == rhs.occurrence &&
|
||||||
|
lhs.tentative == rhs.tentative
|
||||||
}
|
}
|
||||||
|
|
||||||
func hash(into hasher: inout Hasher) {
|
func hash(into hasher: inout Hasher) {
|
||||||
@@ -32,6 +35,7 @@ struct Block: Hashable {
|
|||||||
hasher.combine(label)
|
hasher.combine(label)
|
||||||
hasher.combine(notes)
|
hasher.combine(notes)
|
||||||
hasher.combine(occurrence)
|
hasher.combine(occurrence)
|
||||||
|
hasher.combine(tentative)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -145,10 +145,12 @@ final class MirrorEngine {
|
|||||||
var skippedStatus = 0
|
var skippedStatus = 0
|
||||||
for ev in srcEvents {
|
for ev in srcEvents {
|
||||||
if Task.isCancelled { break }
|
if Task.isCancelled { break }
|
||||||
|
let myStatus = ev.attendees?.first(where: { $0.isCurrentUser })?.participantStatus
|
||||||
|
let isTentative = (myStatus == .tentative)
|
||||||
if config.mirrorAcceptedOnly, ev.hasAttendees {
|
if config.mirrorAcceptedOnly, ev.hasAttendees {
|
||||||
let attendees = ev.attendees ?? []
|
if let me = ev.attendees?.first(where: { $0.isCurrentUser }) {
|
||||||
if let me = attendees.first(where: { $0.isCurrentUser }) {
|
// "Maybe" replies still get mirrored, just marked (see displayTitle below)
|
||||||
if me.participantStatus != .accepted {
|
if me.participantStatus != .accepted && me.participantStatus != .tentative {
|
||||||
skippedStatus += 1
|
skippedStatus += 1
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -178,7 +180,7 @@ final class MirrorEngine {
|
|||||||
guard let s = ev.startDate, let e = ev.endDate, e > s else { continue }
|
guard let s = ev.startDate, let e = ev.endDate, e > s else { continue }
|
||||||
guard ev.calendar.calendarIdentifier == srcCal.calendarIdentifier else { continue }
|
guard ev.calendar.calendarIdentifier == srcCal.calendarIdentifier else { continue }
|
||||||
let srcID = stableSourceIdentifier(for: ev)
|
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 {
|
if skippedMirrors > 0 {
|
||||||
log("- SKIP mirrored-on-source: \(skippedMirrors) instance(s)")
|
log("- SKIP mirrored-on-source: \(skippedMirrors) instance(s)")
|
||||||
@@ -379,7 +381,8 @@ final class MirrorEngine {
|
|||||||
let baseSourceTitle = stripPrefix(blk.label, prefix: config.titlePrefix)
|
let baseSourceTitle = stripPrefix(blk.label, prefix: config.titlePrefix)
|
||||||
let effectiveTitle = config.hideDetails ? config.placeholderTitle : (baseSourceTitle.isEmpty ? config.placeholderTitle : baseSourceTitle)
|
let effectiveTitle = config.hideDetails ? config.placeholderTitle : (baseSourceTitle.isEmpty ? config.placeholderTitle : baseSourceTitle)
|
||||||
let titleSuffix = config.hideDetails ? "" : (baseSourceTitle.isEmpty ? "" : " — \(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 notes = desiredNotes(for: blk)
|
||||||
let desiredURL = buildMirrorURL(
|
let desiredURL = buildMirrorURL(
|
||||||
targetCalID: tgt.calendarIdentifier,
|
targetCalID: tgt.calendarIdentifier,
|
||||||
@@ -409,6 +412,7 @@ final class MirrorEngine {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
existing.title = displayTitle
|
existing.title = displayTitle
|
||||||
|
existing.availability = blk.tentative ? .tentative : .busy
|
||||||
existing.startDate = blk.start
|
existing.startDate = blk.start
|
||||||
existing.endDate = blk.end
|
existing.endDate = blk.end
|
||||||
existing.isAllDay = false
|
existing.isAllDay = false
|
||||||
@@ -467,7 +471,7 @@ final class MirrorEngine {
|
|||||||
newEv.notes = notes
|
newEv.notes = notes
|
||||||
newEv.url = desiredURL
|
newEv.url = desiredURL
|
||||||
newEv.alarms = desiredAlarms(for: blk)
|
newEv.alarms = desiredAlarms(for: blk)
|
||||||
newEv.availability = .busy
|
newEv.availability = blk.tentative ? .tentative : .busy
|
||||||
do {
|
do {
|
||||||
try store.save(newEv, span: .thisEvent, commit: true)
|
try store.save(newEv, span: .thisEvent, commit: true)
|
||||||
created += 1
|
created += 1
|
||||||
|
|||||||
@@ -165,6 +165,14 @@ final class BlockMathTests: XCTestCase {
|
|||||||
XCTAssertEqual(result.count, 2)
|
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() {
|
func testUniqueBlocksByIDDifferentOccurrence() {
|
||||||
let b1 = Block(start: d, end: d.addingTimeInterval(600), srcStableID: "a", label: nil, notes: nil, occurrence: d, alarmOffsets: nil)
|
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)
|
let b2 = Block(start: d, end: d.addingTimeInterval(600), srcStableID: "a", label: nil, notes: nil, occurrence: d.addingTimeInterval(3600), alarmOffsets: nil)
|
||||||
|
|||||||
@@ -2,6 +2,11 @@
|
|||||||
|
|
||||||
All notable changes to BusyMirror will be documented in this file.
|
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
|
## [1.9.2] - 2026-08-27
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
Reference in New Issue
Block a user