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 } private struct CalendarQuickMetric: Identifiable { let id: String let label: String let value: String let color: Color } struct CalendarView: View { @State private var selectedDay = "Wednesday" @State private var agendaItems: [CalendarAgendaItem] = [] @State private var calendarMetrics: [CalendarQuickMetric] = [] @State private var isLoading = true @State private var errorMessage: String? private let refreshTimer = Timer.publish(every: 15, on: .main, in: .common).autoconnect() var body: some View { ScrollView { VStack(alignment: .leading, spacing: 20) { header if let errorMessage { errorBanner(errorMessage) } if isLoading { loadingPanel } else { metricsRow HStack(alignment: .top, spacing: 18) { scheduleRail agendaPanel } } } .padding(20) } .background(VelocityTheme.background) .scrollContentBackground(.hidden) .task { await loadCalendar() } .refreshable { await loadCalendar() } .onReceive(refreshTimer) { _ in Task { await loadCalendar(silent: true) } } } 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 follow-ups, tours, and legal milestones.") .font(.system(size: 12)) .foregroundStyle(VelocityTheme.mutedFg) } Spacer() Text("Live sync") .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)) ) } } 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(["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"], 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(selectedDay == day ? VelocityTheme.accent.opacity(0.12) : VelocityTheme.surface) .overlay( RoundedRectangle(cornerRadius: 14) .stroke(selectedDay == day ? VelocityTheme.borderAccent : VelocityTheme.borderSubtle, lineWidth: 1) ) ) } .buttonStyle(.plain) } } .padding(18) .frame(maxWidth: 300, alignment: .topLeading) .glassCard(cornerRadius: 20) } 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("Confirmed live schedule for the authenticated Velocity operator.") .font(.system(size: 12)) .foregroundStyle(VelocityTheme.mutedFg) } Spacer() } if agendaItems.isEmpty { Text("No live calendar events are scheduled yet for this user.") .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() 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) } 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 filteredAgendaItems: [CalendarAgendaItem] { let weekday = selectedDay.lowercased() let filtered = agendaItems.filter { $0.slot.lowercased().contains(weekday) } return filtered.isEmpty ? agendaItems : filtered } private var calendarSynthesis: String { if agendaItems.isEmpty { return "Velocity has not received any live calendar events yet. Once mobile-edge reminders and confirmed follow-ups are written, they will appear here automatically." } return "Live calendar events are being pulled from the mobile-edge backend and refreshed automatically so follow-up timing stays aligned with confirmed operator actions." } 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 for the authenticated Velocity user.") .font(.system(size: 12)) .foregroundStyle(VelocityTheme.mutedFg) } .padding(20) .frame(maxWidth: .infinity, alignment: .leading) .glassCard(cornerRadius: 20) } 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 loadCalendar(silent: Bool = false) async { if !silent { isLoading = true } do { let events = try await VelocityAPIClient.shared.fetchCalendarEvents() let mapped = events.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: event.status.capitalized, color: color(for: event.status) ) } let metrics = buildMetrics(from: events) await MainActor.run { agendaItems = mapped calendarMetrics = metrics if let firstDay = mapped.first?.slot.components(separatedBy: " · ").first { selectedDay = firstDay } errorMessage = nil isLoading = false } } catch { await MainActor.run { agendaItems = [] calendarMetrics = [ CalendarQuickMetric(id: "today", label: "Today", value: "0 slots", color: VelocityTheme.accent), CalendarQuickMetric(id: "priority", label: "Confirmed", value: "0", color: VelocityTheme.success), CalendarQuickMetric(id: "pending", label: "Pending invites", value: "0", color: VelocityTheme.warning), ] errorMessage = error.localizedDescription isLoading = false } } } private func buildMetrics(from events: [VelocityCalendarEventDTO]) -> [CalendarQuickMetric] { let today = events.filter { isToday($0.startAt) }.count let confirmed = events.filter { $0.status.lowercased() == "confirmed" }.count let tentative = events.filter { $0.status.lowercased() == "tentative" }.count return [ CalendarQuickMetric(id: "today", label: "Today", value: "\(today) slots", color: VelocityTheme.accent), CalendarQuickMetric(id: "priority", label: "Confirmed", value: "\(confirmed)", color: VelocityTheme.success), CalendarQuickMetric(id: "pending", label: "Pending invites", value: "\(tentative)", color: VelocityTheme.warning), ] } private func daySubtitle(_ day: String) -> String { let count = agendaItems.filter { $0.slot.lowercased().contains(day.lowercased()) }.count return count == 1 ? "1 scheduled item" : "\(count) scheduled items" } 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 isToday(_ startAt: String) -> Bool { guard let date = ISO8601DateFormatter().date(from: startAt) else { return false } return Calendar.current.isDateInToday(date) } private func color(for status: String) -> Color { switch status.lowercased() { case "confirmed": return VelocityTheme.success case "tentative": return VelocityTheme.warning default: return VelocityTheme.mutedFg } } } #Preview { CalendarView() }