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
12 changes: 10 additions & 2 deletions SessionFlow.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,8 @@
E5E5F6A7B8C9D0E1F2A3B4C5 /* SharedAwarenessComponents.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5A6B7C8D9E0F1A2B3C4D5E6 /* SharedAwarenessComponents.swift */; };
A1B1C1D1E1F10001A2B2C2D1 /* ShortcutService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B1C1D1E1F10002A2B2C2D1 /* ShortcutService.swift */; };
A1B1C1D1E1F10003A2B2C2D1 /* SessionFlowIntents.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B1C1D1E1F10004A2B2C2D1 /* SessionFlowIntents.swift */; };
EC01A2B3C4D5E6F7A8B9C0D1 /* RecentEventsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC01A2B3C4D5E6F7A8B9C0D2 /* RecentEventsStore.swift */; };
EC02A2B3C4D5E6F7A8B9C0D1 /* EventCreationPopover.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC02A2B3C4D5E6F7A8B9C0D2 /* EventCreationPopover.swift */; };
/* End PBXBuildFile section */

/* Begin PBXFileReference section */
Expand Down Expand Up @@ -142,6 +144,8 @@
A1B1C1D1E1F10002A2B2C2D1 /* ShortcutService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShortcutService.swift; sourceTree = "<group>"; };
A1B1C1D1E1F10004A2B2C2D1 /* SessionFlowIntents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionFlowIntents.swift; sourceTree = "<group>"; };
FF01CHANGELOG001 /* CHANGELOG.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; name = CHANGELOG.md; path = CHANGELOG.md; sourceTree = SOURCE_ROOT; };
EC01A2B3C4D5E6F7A8B9C0D2 /* RecentEventsStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecentEventsStore.swift; sourceTree = "<group>"; };
EC02A2B3C4D5E6F7A8B9C0D2 /* EventCreationPopover.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventCreationPopover.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */

/* Begin PBXFrameworksBuildPhase section */
Expand Down Expand Up @@ -237,6 +241,7 @@
E3F4A5B6C7D8E9F0A1B2C3D4 /* MiniPlayerWindowController.swift */,
E4F5A6B7C8D9E0F1A2B3C4D5 /* MiniPlayerView.swift */,
F5A6B7C8D9E0F1A2B3C4D5E6 /* SharedAwarenessComponents.swift */,
EC02A2B3C4D5E6F7A8B9C0D2 /* EventCreationPopover.swift */,
);
path = Views;
sourceTree = "<group>";
Expand All @@ -256,6 +261,7 @@
E1F2A3B4C5D6E7F8A9B0C1D2 /* DockProgressController.swift */,
E2F3A4B5C6D7E8F9A0B1C2D3 /* MenuBarController.swift */,
A1B1C1D1E1F10002A2B2C2D1 /* ShortcutService.swift */,
EC01A2B3C4D5E6F7A8B9C0D2 /* RecentEventsStore.swift */,
);
path = Services;
sourceTree = "<group>";
Expand Down Expand Up @@ -425,6 +431,8 @@
E5E5F6A7B8C9D0E1F2A3B4C5 /* SharedAwarenessComponents.swift in Sources */,
A1B1C1D1E1F10001A2B2C2D1 /* ShortcutService.swift in Sources */,
A1B1C1D1E1F10003A2B2C2D1 /* SessionFlowIntents.swift in Sources */,
EC01A2B3C4D5E6F7A8B9C0D1 /* RecentEventsStore.swift in Sources */,
EC02A2B3C4D5E6F7A8B9C0D1 /* EventCreationPopover.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
Expand Down Expand Up @@ -563,7 +571,7 @@
CODE_SIGN_ENTITLEMENTS = SessionFlow/SessionFlow.entitlements;
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 734;
CURRENT_PROJECT_VERSION = 754;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_ASSET_PATHS = "";
ENABLE_APP_SANDBOX = NO;
Expand Down Expand Up @@ -600,7 +608,7 @@
CODE_SIGN_ENTITLEMENTS = SessionFlow/SessionFlow.entitlements;
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 734;
CURRENT_PROJECT_VERSION = 754;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_ASSET_PATHS = "";
ENABLE_APP_SANDBOX = NO;
Expand Down
7 changes: 7 additions & 0 deletions SessionFlow/Services/CalendarService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -477,6 +477,13 @@ class CalendarService: ObservableObject {
}
}

/// Returns the current duration in minutes for an event, or nil if the event no longer exists.
func eventDurationMinutes(identifier: String) -> Int? {
guard let event = eventStore.event(withIdentifier: identifier) else { return nil }
guard let start = event.startDate, let end = event.endDate else { return nil }
return Int(end.timeIntervalSince(start) / 60)
}

func createSessions(_ sessions: [ScheduledSession]) -> (success: Int, failed: Int, eventIds: [String]) {
var successCount = 0
var failCount = 0
Expand Down
32 changes: 32 additions & 0 deletions SessionFlow/Services/EventUndoManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ class EventUndoManager: ObservableObject {
case time(EventTimeChange)
case delete(EventDeleteSnapshot)
case schedule(ScheduleSnapshot)
case create(EventDeleteSnapshot) // undo = delete, redo = restore
}

struct EventTimeChange: Equatable {
Expand Down Expand Up @@ -102,6 +103,15 @@ class EventUndoManager: ObservableObject {
updateState()
}

func recordCreate(_ snapshot: EventDeleteSnapshot) {
undoStack.append(.create(snapshot))
if undoStack.count > maxStackSize {
undoStack.removeFirst()
}
redoStack.removeAll()
updateState()
}

func recordSchedule(_ snapshot: ScheduleSnapshot) {
undoStack.append(.schedule(snapshot))
if undoStack.count > maxStackSize {
Expand All @@ -121,6 +131,8 @@ class EventUndoManager: ObservableObject {
break // Caller will push redo after restore (needs new eventId)
case .schedule:
redoStack.append(change)
case .create:
break // Caller will push redo after delete (needs new eventId after re-create)
}
updateState()
switch change {
Expand Down Expand Up @@ -149,6 +161,8 @@ class EventUndoManager: ObservableObject {
return .delete(snap)
case .schedule(let snap):
return .schedule(snap)
case .create(let snap):
return .create(snap)
}
}

Expand All @@ -167,6 +181,12 @@ class EventUndoManager: ObservableObject {
updateState()
}

/// Call after undoing a create (deleting event). Pushes redo entry so re-creation is possible.
func pushRedoForUndoneCreate(_ snapshot: EventDeleteSnapshot) {
redoStack.append(.create(snapshot))
updateState()
}

/// After undoing a schedule, caller provides new event IDs from re-creating sessions.
func pushRedoForScheduleUndo(_ snapshot: ScheduleSnapshot) {
// Replace the redo entry with updated event IDs
Expand All @@ -191,6 +211,18 @@ class EventUndoManager: ObservableObject {
return .delete(snap)
case .schedule(let snap):
return .schedule(snap)
case .create(let snap):
return .create(snap)
}
}

/// After redoing a create, update the undo stack's top `.create` entry with the new event ID.
func updateTopUndoCreateId(_ snapshot: EventDeleteSnapshot) {
if let idx = undoStack.lastIndex(where: {
if case .create = $0 { return true }
return false
}) {
undoStack[idx] = .create(snapshot)
}
}

Expand Down
124 changes: 124 additions & 0 deletions SessionFlow/Services/RecentEventsStore.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import Foundation
import SwiftUI

/// Persists recently created event templates for quick re-creation on the timeline.
class RecentEventsStore: ObservableObject {
private static let defaultsKey = "SessionFlow.RecentEventTemplates"
private static let maxEntries = 20

struct EventTemplate: Codable, Identifiable, Equatable {
let id: UUID
let title: String
let durationMinutes: Int // fallback if original event is gone
let calendarName: String
let calendarIdentifier: String?
let eventId: String? // reference to the original calendar event
let lastUsed: Date

init(title: String, durationMinutes: Int, calendarName: String, calendarIdentifier: String?, eventId: String? = nil, lastUsed: Date = Date()) {
self.id = UUID()
self.title = title
self.durationMinutes = durationMinutes
self.calendarName = calendarName
self.calendarIdentifier = calendarIdentifier
self.eventId = eventId
self.lastUsed = lastUsed
}
}

@Published private(set) var templates: [EventTemplate] = []

init() {
load()
}

/// Records a created event. Updates existing template if title matches, otherwise adds new.
func record(title: String, durationMinutes: Int, calendarName: String, calendarIdentifier: String?, eventId: String? = nil) {
let trimmed = title.trimmingCharacters(in: .whitespaces)
guard !trimmed.isEmpty else { return }

// Remove existing entry with same title (case-insensitive)
templates.removeAll { $0.title.localizedCaseInsensitiveCompare(trimmed) == .orderedSame }

let template = EventTemplate(
title: trimmed,
durationMinutes: durationMinutes,
calendarName: calendarName,
calendarIdentifier: calendarIdentifier,
eventId: eventId
)
templates.insert(template, at: 0)

// Trim to max
if templates.count > Self.maxEntries {
templates = Array(templates.prefix(Self.maxEntries))
}
save()
}

/// Returns templates matching a query, sorted by relevance (prefix match first, then contains).
func search(_ query: String) -> [EventTemplate] {
let q = query.trimmingCharacters(in: .whitespaces)
guard !q.isEmpty else { return templates }

let lowered = q.lowercased()

// Partition: prefix matches first, then substring matches
var prefixMatches: [EventTemplate] = []
var containsMatches: [EventTemplate] = []

for t in templates {
let titleLower = t.title.lowercased()
if titleLower.hasPrefix(lowered) {
prefixMatches.append(t)
} else if titleLower.contains(lowered) {
containsMatches.append(t)
}
}

// Also do fuzzy: match if all query chars appear in order
var fuzzyMatches: [EventTemplate] = []
let queryChars = Array(lowered)
for t in templates {
if prefixMatches.contains(where: { $0.id == t.id }) || containsMatches.contains(where: { $0.id == t.id }) {
continue
}
let titleLower = t.title.lowercased()
var qi = 0
for ch in titleLower {
if qi < queryChars.count && ch == queryChars[qi] {
qi += 1
}
}
if qi == queryChars.count {
fuzzyMatches.append(t)
}
}

return prefixMatches + containsMatches + fuzzyMatches
}

/// Returns the live duration for a template by looking up its original event.
/// Falls back to the stored durationMinutes if the event no longer exists.
func resolveDuration(for template: EventTemplate, using calendarService: CalendarService) -> Int {
if let eventId = template.eventId,
let liveDuration = calendarService.eventDurationMinutes(identifier: eventId) {
return liveDuration
}
return template.durationMinutes
}

private func load() {
guard let data = UserDefaults.standard.data(forKey: Self.defaultsKey),
let decoded = try? JSONDecoder().decode([EventTemplate].self, from: data) else {
return
}
templates = decoded
}

private func save() {
if let data = try? JSONEncoder().encode(templates) {
UserDefaults.standard.set(data, forKey: Self.defaultsKey)
}
}
}
2 changes: 2 additions & 0 deletions SessionFlow/SessionFlowApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ struct SessionFlowApp: App {
@StateObject private var updateService = UpdateService()
@StateObject private var sessionAwarenessService = SessionAwarenessService()
@StateObject private var sessionAudioService = SessionAudioService()
@StateObject private var recentEventsStore = RecentEventsStore()
@StateObject private var menuBarController = MenuBarController()
@StateObject private var miniPlayerController = MiniPlayerWindowController()
@State private var didInitializeServices = false
Expand All @@ -47,6 +48,7 @@ struct SessionFlowApp: App {
.environmentObject(sessionAwarenessService)
.environmentObject(sessionAwarenessService.timeState)
.environmentObject(sessionAudioService)
.environmentObject(recentEventsStore)
.frame(minWidth: 1000, minHeight: 700)
.focusEffectDisabled()
.onAppear {
Expand Down
Loading
Loading