import SwiftUI private struct CalendarAgendaItem: Identifiable { let id: String let title: String let slot: String let owner: String let location: String let type: String let color: Color let pendingSync: Bool let sortDate: Date? let event: VelocityCalendarEventDTO? let task: VelocityTaskDTO? } private struct CalendarQuickMetric: Identifiable { let id: String let label: String let value: String let color: Color } private struct CalendarEventDraft { var title = "" var clientPersonId = "" var description = "" var location = "" var startDate: Date var endDate: Date var allDay = false var status = "tentative" var reminderMinutes = 15 init(startDate: Date = CalendarEventDraft.defaultStartDate()) { self.startDate = startDate self.endDate = startDate.addingTimeInterval(60 * 60) } var isValid: Bool { guard title.trimmedNonEmpty != nil else { return false } return allDay || endDate > startDate } static func defaultStartDate() -> Date { let calendar = Calendar.current let now = Date() let nextHour = calendar.dateInterval(of: .hour, for: now)?.end ?? now.addingTimeInterval(60 * 60) return nextHour } } struct CalendarView: View { @State private var store = AppStore.shared @State private var selectedDay = Self.currentWeekdayName() @State private var actionError: String? @State private var actionMessage: String? @State private var actionMessageDismissTask: Task? @State private var activeDashboardFocus: VelocityDashboardCalendarFocus? @State private var activeTaskMutationID: String? @State private var activeEventMutationID: String? @State private var undoCancelledTask: VelocityTaskDTO? @State private var undoCancelledEvent: VelocityCalendarEventDTO? @State private var isCreateEventPresented = false @State private var editingEvent: VelocityCalendarEventDTO? @State private var eventDraft = CalendarEventDraft() @State private var isCreatingEvent = false @State private var isSavingEvent = false @State private var createEventError: String? @State private var schedulingClientPersonID: String? @State private var targetedDropDay: String? private let visibleWeekdays = Calendar.current.weekdaySymbols var body: some View { ScrollView { VStack(alignment: .leading, spacing: 20) { header if let errorMessage = store.errorMessage { errorBanner(errorMessage) } if let actionError { errorBanner(actionError) } if let actionMessage { successBanner(actionMessage) } if let activeDashboardFocus { dashboardFocusBanner(activeDashboardFocus) } if store.isLoading && store.lastRefreshAt == nil { loadingPanel } else { metricsRow clientSchedulingStrip HStack(alignment: .top, spacing: 18) { scheduleRail agendaPanel } } } .padding(20) } .background(VelocityTheme.background) .scrollContentBackground(.hidden) .task { await store.refresh() consumeRequestedCalendarFocus() } .onChange(of: store.requestedCalendarFocus) { _, _ in consumeRequestedCalendarFocus() } .refreshable { await store.refresh() } .onDisappear { actionMessageDismissTask?.cancel() actionMessageDismissTask = nil } .sheet(isPresented: $isCreateEventPresented) { createEventSheet } .sheet(item: $editingEvent) { event in editEventSheet(event) } } private var header: some View { HStack(alignment: .top) { VStack(alignment: .leading, spacing: 4) { Text("Calendar") .font(.system(size: 28, weight: .bold)) .foregroundStyle(VelocityTheme.foreground) Text("Operator scheduling edge for confirmed calendar events and canonical CRM follow-up tasks.") .font(.system(size: 12)) .foregroundStyle(VelocityTheme.mutedFg) } Spacer() HStack(spacing: 10) { Button { presentCreateEvent() } label: { Label("Create Event", systemImage: "plus") .font(.system(size: 12, weight: .semibold)) .foregroundStyle(Color.white) .padding(.horizontal, 12) .padding(.vertical, 8) .background( Capsule() .fill(VelocityTheme.accent) .overlay(Capsule().stroke(VelocityTheme.accentDim, lineWidth: 1)) ) } .buttonStyle(.plain) Button { selectedDay = Self.currentWeekdayName() actionError = nil withAnimation(.interactiveSpring(response: 0.35, dampingFraction: 0.86)) { activeDashboardFocus = nil } } label: { Text("This week") .font(.system(size: 11, weight: .semibold)) .foregroundStyle(VelocityTheme.accent) .padding(.horizontal, 10) .padding(.vertical, 6) .background( Capsule() .fill(VelocityTheme.accent.opacity(0.12)) .overlay(Capsule().stroke(VelocityTheme.borderAccent, lineWidth: 1)) ) } .buttonStyle(.plain) } } } private var metricsRow: some View { HStack(spacing: 12) { ForEach(calendarMetrics) { metric in VStack(alignment: .leading, spacing: 8) { Text(metric.label.uppercased()) .font(.system(size: 10, weight: .semibold)) .tracking(1) .foregroundStyle(VelocityTheme.mutedFg) Text(metric.value) .font(.system(size: 20, weight: .bold)) .foregroundStyle(VelocityTheme.foreground) RoundedRectangle(cornerRadius: 4) .fill(metric.color) .frame(width: 48, height: 4) } .padding(16) .frame(maxWidth: .infinity, alignment: .leading) .glassCard(cornerRadius: 16) } } } private var scheduleRail: some View { VStack(alignment: .leading, spacing: 12) { Text("Week Grid") .font(.system(size: 13, weight: .semibold)) .foregroundStyle(VelocityTheme.foreground) ForEach(visibleWeekdays, id: \.self) { day in Button { selectedDay = day } label: { HStack { VStack(alignment: .leading, spacing: 3) { Text(day) .font(.system(size: 14, weight: .semibold)) .foregroundStyle(VelocityTheme.foreground) Text(daySubtitle(day)) .font(.system(size: 11)) .foregroundStyle(VelocityTheme.mutedFg) } Spacer() Circle() .fill(selectedDay == day ? VelocityTheme.accent : VelocityTheme.borderSubtle) .frame(width: 10, height: 10) } .padding(14) .background( RoundedRectangle(cornerRadius: 14) .fill(targetedDropDay == day ? VelocityTheme.accent.opacity(0.22) : (selectedDay == day ? VelocityTheme.accent.opacity(0.12) : VelocityTheme.surface)) .overlay( RoundedRectangle(cornerRadius: 14) .stroke(targetedDropDay == day || selectedDay == day ? VelocityTheme.borderAccent : VelocityTheme.borderSubtle, lineWidth: 1) ) ) } .buttonStyle(.plain) .dropDestination(for: String.self) { personIDs, _ in guard let personID = personIDs.first?.trimmedNonEmpty else { return false } Task { await scheduleSiteVisit(forPersonID: personID, on: day) } return true } isTargeted: { isTargeted in withAnimation(.interactiveSpring(response: 0.34, dampingFraction: 0.82)) { targetedDropDay = isTargeted ? day : nil } } } } .padding(18) .frame(maxWidth: 300, alignment: .topLeading) .glassCard(cornerRadius: 20) } private var clientSchedulingStrip: some View { Group { if !store.contacts.isEmpty { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 10) { ForEach(store.contacts.prefix(12)) { contact in HStack(spacing: 8) { ZStack { Circle() .fill(VelocityTheme.accent.opacity(0.16)) .frame(width: 32, height: 32) Text(initials(for: contact.fullName)) .font(.system(size: 10, weight: .bold)) .foregroundStyle(VelocityTheme.accent) } Text(contact.fullName) .font(.system(size: 12, weight: .semibold)) .lineLimit(1) } .foregroundStyle(VelocityTheme.foreground) .padding(.horizontal, 10) .padding(.vertical, 8) .background( Capsule() .fill(VelocityTheme.surface) .overlay(Capsule().stroke(VelocityTheme.borderSubtle, lineWidth: 1)) ) .opacity(schedulingClientPersonID == contact.personId ? 0.55 : 1) .scaleEffect(schedulingClientPersonID == contact.personId ? 0.97 : 1) .draggable(contact.personId) .animation(.interactiveSpring(response: 0.32, dampingFraction: 0.86), value: schedulingClientPersonID) } } .padding(.vertical, 2) } .transition(.opacity.combined(with: .move(edge: .top))) } } } private var agendaPanel: some View { VStack(alignment: .leading, spacing: 16) { HStack { VStack(alignment: .leading, spacing: 4) { Text(selectedDay) .font(.system(size: 22, weight: .bold)) .foregroundStyle(VelocityTheme.foreground) Text("Recommended schedule blended from confirmed calendar events and canonical CRM reminders.") .font(.system(size: 12)) .foregroundStyle(VelocityTheme.mutedFg) } Spacer() } if agendaItems.isEmpty { Text("No live calendar events or canonical CRM tasks are scheduled yet for this operator.") .font(.system(size: 13)) .foregroundStyle(VelocityTheme.mutedFg) } else if filteredAgendaItems.isEmpty { Text("No live calendar events or canonical CRM tasks are scheduled for \(selectedDay).") .font(.system(size: 13)) .foregroundStyle(VelocityTheme.mutedFg) } ForEach(filteredAgendaItems) { item in HStack(alignment: .top, spacing: 14) { VStack(spacing: 6) { Circle() .fill(item.color) .frame(width: 12, height: 12) Rectangle() .fill(item.color.opacity(0.22)) .frame(width: 2, height: 44) } VStack(alignment: .leading, spacing: 8) { HStack { Text(item.title) .font(.system(size: 14, weight: .semibold)) .foregroundStyle(VelocityTheme.foreground) Spacer() if item.pendingSync { Circle() .fill(VelocityTheme.warning) .frame(width: 8, height: 8) .shadow(color: VelocityTheme.warning.opacity(0.75), radius: 6) } Text(item.type) .font(.system(size: 10, weight: .semibold)) .foregroundStyle(item.color) } Text(item.slot) .font(.system(size: 12, weight: .medium)) .foregroundStyle(VelocityTheme.foreground) Text("Owner: \(item.owner) · \(item.location)") .font(.system(size: 11)) .foregroundStyle(VelocityTheme.mutedFg) } if let task = item.task { if activeTaskMutationID == task.reminderId { ProgressView() .progressViewStyle(CircularProgressViewStyle(tint: VelocityTheme.accent)) } else { taskActionsMenu(task) } } else if let event = item.event { if activeEventMutationID == event.calendarEventId { ProgressView() .progressViewStyle(CircularProgressViewStyle(tint: VelocityTheme.accent)) } else { eventActionsMenu(event) } } else { Spacer() } } .padding(16) .background( RoundedRectangle(cornerRadius: 16) .fill(VelocityTheme.surface) .overlay( RoundedRectangle(cornerRadius: 16) .stroke(VelocityTheme.borderSubtle, lineWidth: 1) ) ) } VStack(alignment: .leading, spacing: 8) { Text("Calendar synthesis") .font(.system(size: 13, weight: .semibold)) .foregroundStyle(VelocityTheme.foreground) Text(calendarSynthesis) .font(.system(size: 13)) .foregroundStyle(VelocityTheme.mutedFg) } .padding(16) .background( RoundedRectangle(cornerRadius: 16) .fill(Color(red: 0.09, green: 0.15, blue: 0.33).opacity(0.5)) .overlay( RoundedRectangle(cornerRadius: 16) .stroke(VelocityTheme.borderAccent, lineWidth: 1) ) ) } .padding(22) .frame(maxWidth: .infinity, alignment: .topLeading) .glassCard(cornerRadius: 20) } private var createEventSheet: some View { NavigationStack { ZStack { VelocityTheme.background.ignoresSafeArea() VStack(alignment: .leading, spacing: 18) { VStack(alignment: .leading, spacing: 4) { Text("Create Event") .font(.system(size: 24, weight: .bold)) .foregroundStyle(VelocityTheme.foreground) Text("Add a confirmed operator calendar slot with client context, reminders, and meeting details.") .font(.system(size: 12)) .foregroundStyle(VelocityTheme.mutedFg) } if let createEventError { errorBanner(createEventError) } ScrollViewReader { proxy in ScrollView { VStack(alignment: .leading, spacing: 14) { Color.clear .frame(height: 0) .id("create-event-form-top") formLabel("Title") eventTextField("Site visit with client", text: $eventDraft.title) formLabel("Client") Picker("Client", selection: $eventDraft.clientPersonId) { Text("No linked client").tag("") ForEach(store.contacts) { contact in Text(contact.fullName).tag(contact.personId) } } .pickerStyle(.menu) .tint(VelocityTheme.accent) .padding(12) .frame(maxWidth: .infinity, alignment: .leading) .background(fieldBackground) HStack(spacing: 12) { Toggle("All-day", isOn: $eventDraft.allDay) .toggleStyle(.switch) .tint(VelocityTheme.accent) .foregroundStyle(VelocityTheme.foreground) Spacer() Picker("Status", selection: $eventDraft.status) { Text("Normal Task").tag("tentative") Text("Confirmed Task").tag("confirmed") } .pickerStyle(.menu) .tint(VelocityTheme.accent) Picker("Reminder", selection: $eventDraft.reminderMinutes) { Text("None").tag(0) Text("5 min").tag(5) Text("15 min").tag(15) Text("30 min").tag(30) Text("1 hour").tag(60) Text("1 day").tag(1_440) } .pickerStyle(.menu) .tint(VelocityTheme.accent) } .padding(12) .background(fieldBackground) DatePicker( "Starts", selection: eventStartBinding, displayedComponents: eventDatePickerComponents ) .tint(VelocityTheme.accent) .foregroundStyle(VelocityTheme.foreground) .padding(12) .background(fieldBackground) if !eventDraft.allDay { DatePicker( "Ends", selection: $eventDraft.endDate, in: eventDraft.startDate.addingTimeInterval(60)..., displayedComponents: [.date, .hourAndMinute] ) .tint(VelocityTheme.accent) .foregroundStyle(VelocityTheme.foreground) .padding(12) .background(fieldBackground) } formLabel("Location") eventTextField("Project site, sales lounge, video call", text: $eventDraft.location) formLabel("Description") TextEditor(text: $eventDraft.description) .font(.system(size: 14)) .foregroundStyle(VelocityTheme.foreground) .scrollContentBackground(.hidden) .frame(minHeight: 110) .padding(10) .background(fieldBackground) } .padding(18) .glassCard(cornerRadius: 18) } .frame(height: 436) .onAppear { DispatchQueue.main.async { proxy.scrollTo("create-event-form-top", anchor: .top) } } } HStack(spacing: 12) { Button { isCreateEventPresented = false } label: { Text("Cancel") .font(.system(size: 14, weight: .semibold)) .foregroundStyle(VelocityTheme.foreground) .frame(maxWidth: .infinity) .padding(.vertical, 13) .background(fieldBackground) } .buttonStyle(.plain) Button { createEvent() } label: { HStack(spacing: 8) { if isCreatingEvent { ProgressView() .progressViewStyle(CircularProgressViewStyle(tint: .white)) } Text(isCreatingEvent ? "Creating..." : "Create Event") } .font(.system(size: 14, weight: .semibold)) .foregroundStyle(.white) .frame(maxWidth: .infinity) .padding(.vertical, 13) .background( RoundedRectangle(cornerRadius: 12) .fill(eventDraft.isValid && !isCreatingEvent ? VelocityTheme.accent : VelocityTheme.surface3) ) } .buttonStyle(.plain) .disabled(!eventDraft.isValid || isCreatingEvent) } } .padding(24) .frame(maxWidth: 620) .frame(maxWidth: .infinity) } .toolbar { ToolbarItem(placement: .confirmationAction) { Button { isCreateEventPresented = false } label: { Image(systemName: "xmark.circle.fill") .font(.system(size: 22, weight: .semibold)) .foregroundStyle(VelocityTheme.mutedFg) } .buttonStyle(.plain) } } } .presentationDetents([.height(690)]) .presentationDragIndicator(.visible) } private func editEventSheet(_ event: VelocityCalendarEventDTO) -> some View { NavigationStack { ZStack { VelocityTheme.background.ignoresSafeArea() VStack(alignment: .leading, spacing: 18) { VStack(alignment: .leading, spacing: 4) { Text("Edit Event") .font(.system(size: 24, weight: .bold)) .foregroundStyle(VelocityTheme.foreground) Text("Update the backend-owned calendar slot details.") .font(.system(size: 12)) .foregroundStyle(VelocityTheme.mutedFg) } if let createEventError { errorBanner(createEventError) } ScrollView { VStack(alignment: .leading, spacing: 14) { formLabel("Title") eventTextField("Site visit with client", text: $eventDraft.title) HStack(spacing: 12) { Picker("Status", selection: $eventDraft.status) { Text("Normal Task").tag("tentative") Text("Confirmed Task").tag("confirmed") Text("Done").tag("done") } .pickerStyle(.menu) .tint(VelocityTheme.accent) Picker("Reminder", selection: $eventDraft.reminderMinutes) { Text("None").tag(0) Text("5 min").tag(5) Text("15 min").tag(15) Text("30 min").tag(30) Text("1 hour").tag(60) Text("1 day").tag(1_440) } .pickerStyle(.menu) .tint(VelocityTheme.accent) } .padding(12) .background(fieldBackground) DatePicker( "Starts", selection: eventStartBinding, displayedComponents: eventDatePickerComponents ) .tint(VelocityTheme.accent) .foregroundStyle(VelocityTheme.foreground) .padding(12) .background(fieldBackground) if !eventDraft.allDay { DatePicker( "Ends", selection: $eventDraft.endDate, in: eventDraft.startDate.addingTimeInterval(60)..., displayedComponents: [.date, .hourAndMinute] ) .tint(VelocityTheme.accent) .foregroundStyle(VelocityTheme.foreground) .padding(12) .background(fieldBackground) } formLabel("Location") eventTextField("Project site, sales lounge, video call", text: $eventDraft.location) formLabel("Description") TextEditor(text: $eventDraft.description) .font(.system(size: 14)) .foregroundStyle(VelocityTheme.foreground) .scrollContentBackground(.hidden) .frame(minHeight: 110) .padding(10) .background(fieldBackground) } .padding(18) .glassCard(cornerRadius: 18) } .frame(height: 436) HStack(spacing: 12) { Button { editingEvent = nil } label: { Text("Cancel") .font(.system(size: 14, weight: .semibold)) .foregroundStyle(VelocityTheme.foreground) .frame(maxWidth: .infinity) .padding(.vertical, 13) .background(fieldBackground) } .buttonStyle(.plain) Button { saveEventEdits(event) } label: { HStack(spacing: 8) { if isSavingEvent { ProgressView() .progressViewStyle(CircularProgressViewStyle(tint: .white)) } Text(isSavingEvent ? "Saving..." : "Save Event") } .font(.system(size: 14, weight: .semibold)) .foregroundStyle(.white) .frame(maxWidth: .infinity) .padding(.vertical, 13) .background( RoundedRectangle(cornerRadius: 12) .fill(eventDraft.isValid && !isSavingEvent ? VelocityTheme.accent : VelocityTheme.surface3) ) } .buttonStyle(.plain) .disabled(!eventDraft.isValid || isSavingEvent) } } .padding(24) .frame(maxWidth: 620) .frame(maxWidth: .infinity) } } .presentationDetents([.height(690)]) .presentationDragIndicator(.visible) } private var filteredAgendaItems: [CalendarAgendaItem] { if let activeDashboardFocus { switch activeDashboardFocus { case .today: let weekday = Self.currentWeekdayName().lowercased() return agendaItems.filter { $0.slot.lowercased().contains(weekday) && !isInactiveAgendaItem($0) } case .pendingTasks: return agendaItems.filter { item in guard let task = item.task else { return false } return ["pending", "snoozed", "confirmed"].contains(task.status.lowercased()) } case .urgentTasks: return agendaItems.filter { item in guard let task = item.task else { return false } return ["urgent", "high"].contains(task.priority.lowercased()) && !isInactiveAgendaItem(item) } } } let weekday = selectedDay.lowercased() return agendaItems.filter { $0.slot.lowercased().contains(weekday) } } private var agendaItems: [CalendarAgendaItem] { let eventItems = store.calendarEvents.filter { $0.status.lowercased() != "cancelled" }.map { event in CalendarAgendaItem( id: event.calendarEventId, title: event.title, slot: formattedSlot(startAt: event.startAt), owner: event.createdBy.replacingOccurrences(of: "_", with: " ").capitalized, location: event.location ?? "No location", type: eventStatusLabel(event.status), color: color(for: event.status), pendingSync: store.pendingSyncCalendarEventIDs.contains(event.calendarEventId) || event.calendarEventId.hasPrefix("local-"), sortDate: event.startDate, event: event, task: nil ) } let taskItems = store.prioritizedTasks.filter { $0.status.lowercased() != "cancelled" }.map { task in CalendarAgendaItem( id: task.reminderId, title: task.title, slot: formattedTaskSlot(task), owner: task.ownerLabel, location: task.clientPhone ?? "Canonical CRM task", type: taskStatusLabel(task), color: taskColor(for: task), pendingSync: store.pendingSyncTaskIDs.contains(task.reminderId), sortDate: task.dueDate, event: nil, task: task ) } return (eventItems + taskItems).sorted { lhs, rhs in switch (lhs.sortDate, rhs.sortDate) { case let (left?, right?): return left < right case (_?, nil): return true case (nil, _?): return false default: return lhs.title.localizedCaseInsensitiveCompare(rhs.title) == .orderedAscending } } } private var calendarMetrics: [CalendarQuickMetric] { buildMetrics( events: store.calendarEvents, tasks: store.prioritizedTasks, pendingTaskCount: store.pendingTaskMetricCount, agendaItems: agendaItems ) } private var calendarSynthesis: String { if agendaItems.isEmpty { return "Velocity has not received any live calendar events or canonical CRM reminder tasks yet. Once reminders and confirmed follow-ups are written, they will appear here automatically." } return "Live mobile-edge calendar events are now blended with canonical CRM reminder tasks so the iPad operator schedule reflects both confirmed meetings and pending follow-up work." } private var loadingPanel: some View { VStack(alignment: .leading, spacing: 12) { ProgressView() .progressViewStyle(CircularProgressViewStyle(tint: VelocityTheme.accent)) Text("Loading live calendar events...") .font(.system(size: 14, weight: .semibold)) .foregroundStyle(VelocityTheme.foreground) Text("This surface reads confirmed mobile-edge calendar records and canonical CRM reminder tasks for the authenticated Velocity user.") .font(.system(size: 12)) .foregroundStyle(VelocityTheme.mutedFg) } .padding(20) .frame(maxWidth: .infinity, alignment: .leading) .glassCard(cornerRadius: 20) } private var eventStartBinding: Binding { Binding( get: { eventDraft.startDate }, set: { newValue in let previousDuration = max(eventDraft.endDate.timeIntervalSince(eventDraft.startDate), 60 * 60) eventDraft.startDate = newValue if eventDraft.endDate <= newValue { eventDraft.endDate = newValue.addingTimeInterval(previousDuration) } } ) } private var eventDatePickerComponents: DatePickerComponents { eventDraft.allDay ? [.date] : [.date, .hourAndMinute] } private var fieldBackground: some View { RoundedRectangle(cornerRadius: 12) .fill(VelocityTheme.surface2) .overlay( RoundedRectangle(cornerRadius: 12) .stroke(VelocityTheme.borderSubtle, lineWidth: 1) ) } private func formLabel(_ text: String) -> some View { Text(text.uppercased()) .font(.system(size: 10, weight: .semibold)) .tracking(1) .foregroundStyle(VelocityTheme.mutedFg) } private func eventTextField(_ prompt: String, text: Binding) -> some View { TextField(prompt, text: text) .font(.system(size: 14)) .foregroundStyle(VelocityTheme.foreground) .padding(12) .background(fieldBackground) } private func errorBanner(_ message: String) -> some View { Text(message) .font(.system(size: 13, weight: .medium)) .foregroundStyle(VelocityTheme.danger) .padding(14) .frame(maxWidth: .infinity, alignment: .leading) .background( RoundedRectangle(cornerRadius: 14) .fill(VelocityTheme.danger.opacity(0.10)) .overlay( RoundedRectangle(cornerRadius: 14) .stroke(VelocityTheme.danger.opacity(0.22), lineWidth: 1) ) ) } private func successBanner(_ message: String) -> some View { HStack(spacing: 12) { Text(message) .font(.system(size: 13, weight: .medium)) .foregroundStyle(VelocityTheme.success) Spacer() if undoCancelledTask != nil || undoCancelledEvent != nil { Button { undoCancellation() } label: { Label("Undo", systemImage: "arrow.uturn.backward") .font(.system(size: 12, weight: .semibold)) .foregroundStyle(VelocityTheme.success) .padding(.horizontal, 12) .padding(.vertical, 7) .background( Capsule() .fill(VelocityTheme.success.opacity(0.14)) .overlay(Capsule().stroke(VelocityTheme.success.opacity(0.28), lineWidth: 1)) ) } .buttonStyle(.plain) } } .padding(14) .frame(maxWidth: .infinity, alignment: .leading) .background( RoundedRectangle(cornerRadius: 14) .fill(VelocityTheme.success.opacity(0.10)) .overlay( RoundedRectangle(cornerRadius: 14) .stroke(VelocityTheme.success.opacity(0.22), lineWidth: 1) ) ) } private func dashboardFocusBanner(_ focus: VelocityDashboardCalendarFocus) -> some View { HStack(spacing: 10) { Label(calendarFocusLabel(focus), systemImage: "line.3.horizontal.decrease.circle") .font(.system(size: 12, weight: .semibold)) .foregroundStyle(VelocityTheme.warning) Text(calendarFocusDescription(focus)) .font(.system(size: 12, weight: .medium)) .foregroundStyle(VelocityTheme.mutedFg) Spacer() Button("Clear") { withAnimation(.interactiveSpring(response: 0.35, dampingFraction: 0.86)) { activeDashboardFocus = nil } } .font(.system(size: 12, weight: .semibold)) .foregroundStyle(VelocityTheme.accent) } .padding(14) .background( RoundedRectangle(cornerRadius: 14) .fill(VelocityTheme.warning.opacity(0.10)) .overlay( RoundedRectangle(cornerRadius: 14) .stroke(VelocityTheme.warning.opacity(0.22), lineWidth: 1) ) ) } private func buildMetrics( events: [VelocityCalendarEventDTO], tasks: [VelocityTaskDTO], pendingTaskCount: Int, agendaItems: [CalendarAgendaItem] ) -> [CalendarQuickMetric] { let currentWeekday = Self.currentWeekdayName().lowercased() let today = agendaItems.filter { $0.slot.lowercased().contains(currentWeekday) && !isInactiveAgendaItem($0) }.count let confirmed = events.filter { $0.status.lowercased() == "confirmed" }.count + tasks.filter { $0.status.lowercased() == "confirmed" }.count return [ CalendarQuickMetric(id: "today", label: "Today", value: "\(today) items", color: VelocityTheme.accent), CalendarQuickMetric(id: "priority", label: "Confirmed", value: "\(confirmed)", color: VelocityTheme.success), CalendarQuickMetric(id: "pending", label: "Pending tasks", value: "\(pendingTaskCount)", color: VelocityTheme.warning), ] } private func daySubtitle(_ day: String) -> String { let count = agendaItems.filter { $0.slot.lowercased().contains(day.lowercased()) && !isInactiveAgendaItem($0) }.count return count == 1 ? "1 scheduled item" : "\(count) scheduled items" } private func isInactiveAgendaItem(_ item: CalendarAgendaItem) -> Bool { if let task = item.task { return ["done", "cancelled"].contains(task.status.lowercased()) } if let event = item.event { return ["done", "cancelled"].contains(event.status.lowercased()) } return false } private func formattedSlot(startAt: String) -> String { guard let date = ISO8601DateFormatter().date(from: startAt) else { return startAt } let dayFormatter = DateFormatter() dayFormatter.dateFormat = "EEEE" let timeFormatter = DateFormatter() timeFormatter.dateFormat = "h:mm a" return "\(dayFormatter.string(from: date)) · \(timeFormatter.string(from: date))" } private func formattedTaskSlot(_ task: VelocityTaskDTO) -> String { guard let dueDate = task.dueDate else { return "Unscheduled" } let dayFormatter = DateFormatter() dayFormatter.dateFormat = "EEEE" let timeFormatter = DateFormatter() timeFormatter.dateFormat = "h:mm a" return "\(dayFormatter.string(from: dueDate)) · \(timeFormatter.string(from: dueDate))" } private func isToday(_ startAt: String) -> Bool { guard let date = ISO8601DateFormatter().date(from: startAt) else { return false } return Calendar.current.isDateInToday(date) } private func isToday(_ date: Date?) -> Bool { guard let date else { return false } return Calendar.current.isDateInToday(date) } private func color(for status: String) -> Color { switch status.lowercased() { case "confirmed": return VelocityTheme.success case "done": return VelocityTheme.mutedFg case "tentative": return VelocityTheme.accent case "cancelled": return VelocityTheme.danger default: return VelocityTheme.mutedFg } } private func eventStatusLabel(_ status: String) -> String { switch status.lowercased() { case "tentative": return "Normal Task" case "confirmed": return "Confirmed Task" case "done": return "Done" case "cancelled": return "Cancelled" default: return status.capitalized } } private func taskStatusLabel(_ task: VelocityTaskDTO) -> String { switch task.status.lowercased() { case "confirmed": return "Confirmed Task" case "done": return "Done" case "snoozed": return "Snoozed" case "cancelled": return "Cancelled" default: return "Normal Task" } } private func taskColor(for task: VelocityTaskDTO) -> Color { switch task.status.lowercased() { case "confirmed": return VelocityTheme.success case "done": return VelocityTheme.mutedFg case "cancelled": return VelocityTheme.danger default: break } return VelocityTheme.accent } private func taskActionsMenu(_ task: VelocityTaskDTO) -> some View { Menu { let status = task.status.lowercased() if status == "done" { Button(role: .destructive) { mutateTask( task, status: "cancelled", dueAt: nil, notes: "Removed after completion from the iPad calendar.", successMessage: "Task removed.", supportsUndo: false ) } label: { Label("Remove Task", systemImage: "trash") } } else if status == "confirmed" { Button { mutateTask(task, status: "pending", dueAt: task.dueAt, notes: "Set normal from the iPad calendar.") } label: { Label("Set Normal", systemImage: "circle") } } else if status != "done" && status != "cancelled" { Button { mutateTask(task, status: "confirmed", dueAt: task.dueAt, notes: "Marked confirmed from the iPad calendar.") } label: { Label("Mark Confirmed", systemImage: "checkmark.seal") } } if status != "done" && status != "cancelled" { Button { mutateTask(task, status: "done", dueAt: task.dueAt, notes: "Marked done from the iPad calendar.") } label: { Label("Mark Done", systemImage: "checkmark.circle") } } if !["done", "cancelled"].contains(status) { Button { let snoozeDate = task.nextSnoozeDate(adding: 2 * 60 * 60) mutateTask( task, status: "snoozed", dueAt: iso8601Timestamp(snoozeDate), notes: "Snoozed by 2 hours from the iPad calendar." ) } label: { Label("Snooze 2h", systemImage: "clock.badge") } Button { let snoozeDate = task.nextSnoozeDate(adding: 24 * 60 * 60) mutateTask( task, status: "snoozed", dueAt: iso8601Timestamp(snoozeDate), notes: "Snoozed by 1 day from the iPad calendar." ) } label: { Label("Snooze 1 Day", systemImage: "moon") } } if status != "done" && status != "cancelled" { Button(role: .destructive) { mutateTask(task, status: "cancelled", dueAt: nil, notes: "Cancelled from the iPad calendar.") } label: { Label("Cancel Task", systemImage: "xmark.circle") } } } label: { menuIcon("ellipsis.circle") } } private func eventActionsMenu(_ event: VelocityCalendarEventDTO) -> some View { Menu { let status = event.status.lowercased() if status != "cancelled" { Button { presentEditEvent(event) } label: { Label("Edit Event", systemImage: "square.and.pencil") } } if status == "done" { Button(role: .destructive) { cancelEvent(event, message: "Task removed.", supportsUndo: false) } label: { Label("Remove Task", systemImage: "trash") } } else if status == "confirmed" { Button { mutateEvent(event, status: "tentative", message: "Task set to normal.") } label: { Label("Set Normal", systemImage: "circle") } } else if status != "done" && status != "cancelled" { Button { mutateEvent(event, status: "confirmed", message: "Task marked confirmed.") } label: { Label("Mark Confirmed", systemImage: "checkmark.seal") } } if status != "done" && status != "cancelled" { Button { mutateEvent(event, status: "done", message: "Task marked done.") } label: { Label("Mark Done", systemImage: "checkmark.circle") } } if !["done", "cancelled"].contains(status) { Button { shiftEvent(event, adding: 2 * 60 * 60, message: "Task snoozed by 2 hours.") } label: { Label("Snooze 2h", systemImage: "clock.badge") } Button { shiftEvent(event, adding: 24 * 60 * 60, message: "Task snoozed by 1 day.") } label: { Label("Snooze 1 Day", systemImage: "moon") } } if status != "done" && status != "cancelled" { Button(role: .destructive) { cancelEvent(event) } label: { Label("Cancel Task", systemImage: "xmark.circle") } } } label: { menuIcon("ellipsis.circle") } } private func presentCreateEvent() { eventDraft = CalendarEventDraft(startDate: defaultEventStartDate(for: selectedDay)) createEventError = nil actionError = nil clearActionMessage() isCreateEventPresented = true } private func presentEditEvent(_ event: VelocityCalendarEventDTO) { let startDate = event.startDate ?? CalendarEventDraft.defaultStartDate() var draft = CalendarEventDraft(startDate: startDate) draft.title = event.title draft.description = event.description ?? "" draft.location = event.location ?? "" draft.endDate = event.endDate ?? startDate.addingTimeInterval(60 * 60) draft.allDay = event.allDay draft.status = event.status.lowercased() draft.reminderMinutes = event.reminderMinutes.first ?? 0 eventDraft = draft createEventError = nil actionError = nil clearActionMessage() editingEvent = event } @MainActor private func scheduleSiteVisit(forPersonID personID: String, on weekday: String) async { guard schedulingClientPersonID == nil else { return } guard let contact = store.contacts.first(where: { $0.personId == personID }) else { actionError = "Unable to schedule: this client is not present in the canonical CRM payload." return } guard let leadId = contact.leadId?.trimmedNonEmpty else { actionError = "Unable to schedule \(contact.fullName): no canonical lead is attached." return } let startDate = defaultEventStartDate(for: weekday) let endDate = startDate.addingTimeInterval(60 * 60) var metadata = [ "created_from": "ipad_calendar_drag_drop", "surface": "velocity_ipad", "person_id": contact.personId, "client_name": contact.fullName, ] if let phone = contact.primaryPhone?.trimmedNonEmpty { metadata["client_phone"] = phone } schedulingClientPersonID = personID actionError = nil clearActionMessage() do { _ = try await store.createCalendarEvent( leadId: leadId, title: "Site visit with \(contact.fullName)", description: contact.primaryInterest.flatMap { "Property focus: \($0)".trimmedNonEmpty }, startAt: iso8601Timestamp(startDate), endAt: iso8601Timestamp(endDate), allDay: false, status: "confirmed", reminderMinutes: [60, 15], location: contact.primaryInterest?.trimmedNonEmpty ?? "Project site", metadata: metadata ) withAnimation(.interactiveSpring(response: 0.42, dampingFraction: 0.86)) { selectedDay = weekday schedulingClientPersonID = nil targetedDropDay = nil } showActionMessage("Site visit scheduled for \(contact.fullName).") } catch { schedulingClientPersonID = nil targetedDropDay = nil actionError = calendarActionErrorMessage(error) } } private func createEvent() { guard eventDraft.isValid else { createEventError = "Add an event title and make sure the end time is after the start time." return } let selectedContact = store.contacts.first { $0.personId == eventDraft.clientPersonId } let calendar = Calendar.current let startDate = eventDraft.allDay ? calendar.startOfDay(for: eventDraft.startDate) : eventDraft.startDate let endDate = eventDraft.allDay ? (calendar.date(byAdding: .day, value: 1, to: startDate) ?? startDate.addingTimeInterval(24 * 60 * 60)) : eventDraft.endDate let reminderMinutes = eventDraft.reminderMinutes > 0 ? [eventDraft.reminderMinutes] : [] var metadata = [ "created_from": "ipad_calendar", "surface": "velocity_ipad", ] if let selectedContact { metadata["person_id"] = selectedContact.personId metadata["client_name"] = selectedContact.fullName if let phone = selectedContact.primaryPhone?.trimmedNonEmpty { metadata["client_phone"] = phone } } createEventError = nil isCreatingEvent = true Task { do { _ = try await store.createCalendarEvent( leadId: selectedContact?.leadId, title: eventDraft.title.trimmedNonEmpty ?? "Calendar event", description: eventDraft.description.trimmedNonEmpty, startAt: iso8601Timestamp(startDate), endAt: iso8601Timestamp(endDate), allDay: eventDraft.allDay, status: eventDraft.status, reminderMinutes: reminderMinutes, location: eventDraft.location.trimmedNonEmpty, metadata: metadata ) await MainActor.run { selectedDay = weekdayName(for: startDate) isCreatingEvent = false isCreateEventPresented = false showActionMessage("Event created.") actionError = nil } } catch { await MainActor.run { isCreatingEvent = false createEventError = calendarActionErrorMessage(error) } } } } private func saveEventEdits(_ event: VelocityCalendarEventDTO) { guard eventDraft.isValid else { createEventError = "Add an event title and make sure the end time is after the start time." return } let calendar = Calendar.current let startDate = eventDraft.allDay ? calendar.startOfDay(for: eventDraft.startDate) : eventDraft.startDate let endDate = eventDraft.allDay ? (calendar.date(byAdding: .day, value: 1, to: startDate) ?? startDate.addingTimeInterval(24 * 60 * 60)) : eventDraft.endDate let reminderMinutes = eventDraft.reminderMinutes > 0 ? [eventDraft.reminderMinutes] : [] createEventError = nil isSavingEvent = true Task { do { _ = try await store.updateCalendarEvent( event, title: eventDraft.title.trimmedNonEmpty ?? event.title, description: eventDraft.description, status: eventDraft.status, startAt: iso8601Timestamp(startDate), endAt: iso8601Timestamp(endDate), reminderMinutes: reminderMinutes, location: eventDraft.location ) await MainActor.run { selectedDay = weekdayName(for: startDate) isSavingEvent = false editingEvent = nil showActionMessage("Event updated.") actionError = nil } } catch { await MainActor.run { isSavingEvent = false createEventError = calendarActionErrorMessage(error) } } } } private func mutateEvent( _ event: VelocityCalendarEventDTO, status: String, message: String ) { actionError = nil clearActionMessage() activeEventMutationID = event.calendarEventId Task { do { _ = try await store.updateCalendarEvent(event, status: status) await MainActor.run { activeEventMutationID = nil showActionMessage(message) } } catch { await MainActor.run { activeEventMutationID = nil actionError = calendarActionErrorMessage(error) } } } } private func shiftEvent( _ event: VelocityCalendarEventDTO, adding interval: TimeInterval, message: String ) { guard let startDate = event.startDate, let endDate = event.endDate else { actionError = "This event cannot be rescheduled because its date was not readable." return } actionError = nil clearActionMessage() activeEventMutationID = event.calendarEventId Task { do { _ = try await store.updateCalendarEvent( event, startAt: iso8601Timestamp(startDate.addingTimeInterval(interval)), endAt: iso8601Timestamp(endDate.addingTimeInterval(interval)) ) await MainActor.run { activeEventMutationID = nil showActionMessage(message) } } catch { await MainActor.run { activeEventMutationID = nil actionError = calendarActionErrorMessage(error) } } } } private func cancelEvent( _ event: VelocityCalendarEventDTO, message: String = "Event cancelled.", supportsUndo: Bool = true ) { actionError = nil clearActionMessage() activeEventMutationID = event.calendarEventId Task { do { try await store.cancelCalendarEvent(event) await MainActor.run { activeEventMutationID = nil if supportsUndo { undoCancelledEvent = event } showActionMessage(message) } } catch { await MainActor.run { activeEventMutationID = nil actionError = calendarActionErrorMessage(error) } } } } private func mutateTask( _ task: VelocityTaskDTO, status: String, dueAt: String?, notes: String, successMessage: String? = nil, supportsUndo: Bool = true ) { actionError = nil clearActionMessage() activeTaskMutationID = task.reminderId Task { do { _ = try await store.updateTaskStatus( reminderId: task.reminderId, status: status, dueAt: dueAt, notes: notes ) await MainActor.run { activeTaskMutationID = nil if status.lowercased() == "cancelled" && supportsUndo { undoCancelledTask = task } showActionMessage(successMessage ?? taskActionMessage(status: status)) } } catch { if let apiError = error as? VelocityAPIError, apiError.statusCode == 404 { await store.refresh(silent: true) } await MainActor.run { activeTaskMutationID = nil actionError = calendarActionErrorMessage(error) } } } } private func calendarActionErrorMessage(_ error: Error) -> String { if let apiError = error as? VelocityAPIError, apiError.statusCode == 404 { return "The backend returned Not Found for this calendar request. I refreshed the live schedule so stale items are cleared." } return error.localizedDescription } private func taskActionMessage(status: String) -> String { switch status { case "confirmed": return "Task marked confirmed." case "pending": return "Task set to normal." case "done": return "Task marked done." case "snoozed": return "Task snoozed." case "cancelled": return "Task cancelled." default: return "Task updated." } } private func showActionMessage(_ message: String) { actionMessageDismissTask?.cancel() actionMessage = message actionMessageDismissTask = Task { try? await Task.sleep(nanoseconds: 10_000_000_000) guard !Task.isCancelled else { return } await MainActor.run { actionMessage = nil clearCancellationUndo() actionMessageDismissTask = nil } } } private func consumeRequestedCalendarFocus() { guard let focus = store.requestedCalendarFocus else { return } store.requestedCalendarFocus = nil withAnimation(.interactiveSpring(response: 0.35, dampingFraction: 0.86)) { activeDashboardFocus = focus if focus == .today { selectedDay = Self.currentWeekdayName() } } } private func calendarFocusLabel(_ focus: VelocityDashboardCalendarFocus) -> String { switch focus { case .today: return "Today" case .pendingTasks: return "Pending tasks" case .urgentTasks: return "Urgent tasks" } } private func calendarFocusDescription(_ focus: VelocityDashboardCalendarFocus) -> String { switch focus { case .today: return "Showing today’s confirmed events and CRM reminders." case .pendingTasks: return "Showing actionable CRM reminders across the week." case .urgentTasks: return "Showing high-priority and urgent CRM reminders." } } private func clearActionMessage(clearUndo: Bool = true) { actionMessageDismissTask?.cancel() actionMessageDismissTask = nil actionMessage = nil if clearUndo { clearCancellationUndo() } } private func undoCancellation() { if let task = undoCancelledTask { let restoreStatus = task.status.lowercased() == "cancelled" ? "pending" : task.status mutateTask( task, status: restoreStatus, dueAt: task.dueAt, notes: "Cancellation undone from the iPad calendar." ) return } if let event = undoCancelledEvent { let restoreStatus = event.status.lowercased() == "cancelled" ? "tentative" : event.status mutateEvent(event, status: restoreStatus, message: "Event restored.") } } private func clearCancellationUndo() { undoCancelledTask = nil undoCancelledEvent = nil } private func iso8601Timestamp(_ date: Date) -> String { ISO8601DateFormatter().string(from: date) } private func defaultEventStartDate(for weekdayName: String) -> Date { let calendar = Calendar.current let now = Date() let todayIndex = calendar.component(.weekday, from: now) let targetIndex = (visibleWeekdays.firstIndex(of: weekdayName) ?? (todayIndex - 1)) + 1 let dayDelta = targetIndex - todayIndex let targetDate = calendar.date(byAdding: .day, value: dayDelta, to: now) ?? now let startOfTargetDay = calendar.startOfDay(for: targetDate) let nextHourToday = CalendarEventDraft.defaultStartDate() if calendar.isDateInToday(startOfTargetDay) { return nextHourToday } var components = DateComponents() components.hour = 10 components.minute = 0 let tenAM = calendar.date(byAdding: components, to: startOfTargetDay) ?? startOfTargetDay.addingTimeInterval(10 * 60 * 60) return tenAM > now ? tenAM : calendar.date(byAdding: .day, value: 7, to: tenAM) ?? tenAM } private func weekdayName(for date: Date) -> String { let formatter = DateFormatter() formatter.dateFormat = "EEEE" return formatter.string(from: date) } private func initials(for name: String) -> String { let pieces = name .split(separator: " ") .prefix(2) .compactMap { $0.first } let initials = String(pieces).uppercased() return initials.isEmpty ? "CL" : initials } private func menuIcon(_ systemName: String) -> some View { Image(systemName: systemName) .font(.system(size: 17, weight: .semibold)) .foregroundStyle(VelocityTheme.mutedFg) .frame(width: 44, height: 44) .contentShape(Rectangle()) } private static func currentWeekdayName() -> String { let formatter = DateFormatter() formatter.dateFormat = "EEEE" return formatter.string(from: Date()) } } private extension String { var trimmedNonEmpty: String? { let value = trimmingCharacters(in: .whitespacesAndNewlines) return value.isEmpty ? nil : value } } #Preview { CalendarView() }