Files
Project_Velocity/iOS/velocity-ipad/velocity/Features/Calendar/CalendarView.swift
sayan eeb684b46c feat: Ipad app production readiness, Colony orchestration, Social posting (#44)
#38 Ipad app production readiness, Colony orchestration, Social posting

Co-authored-by: Sayan Datta <sayan@Sayans-MacBook-Air.local>
Reviewed-on: sagnik/Project_Velocity#44
2026-05-03 18:30:38 +05:30

1684 lines
67 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<Void, Never>?
@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<Date> {
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<String>) -> 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 todays 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()
}