feat: Ipad app features and Dream Weaver for Velocity WebOS
This commit is contained in:
815
iOS/velocity-ipad/velocity/Core/State/AppStore.swift
Normal file
815
iOS/velocity-ipad/velocity/Core/State/AppStore.swift
Normal file
@@ -0,0 +1,815 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
struct DashboardMetrics {
|
||||
let leadCount: Int
|
||||
let whaleLeadCount: Int
|
||||
let propertyCount: Int
|
||||
let todayCalendarCount: Int
|
||||
let pendingTaskCount: Int
|
||||
let urgentTaskCount: Int
|
||||
let pendingInsights: Int
|
||||
let pendingTranscriptions: Int
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
final class AppStore {
|
||||
static let shared = AppStore()
|
||||
|
||||
private static let locallyCreatedCalendarEventsKey = "velocity.calendar.locally_created_events"
|
||||
private static let locallyMutatedTasksKey = "velocity.calendar.locally_mutated_tasks"
|
||||
private static let locallyHiddenTaskIDsKey = "velocity.calendar.locally_hidden_task_ids"
|
||||
|
||||
private init() {
|
||||
localTaskOverrides = Self.loadLocallyMutatedTasks()
|
||||
locallyResolvedTaskIDs = Self.loadLocallyHiddenTaskIDs()
|
||||
tasks = VelocityTaskDTO.sortedForOperatorReview(Array(localTaskOverrides.values))
|
||||
locallyCreatedCalendarEvents = Self.loadLocallyCreatedCalendarEvents()
|
||||
calendarEvents = locallyCreatedCalendarEvents
|
||||
}
|
||||
|
||||
private struct RefreshSnapshot {
|
||||
let contacts: [VelocityCanonicalContactListItemDTO]
|
||||
let leads: [VelocityLeadDTO]
|
||||
let tasks: [VelocityTaskDTO]
|
||||
let pendingTaskCount: Int
|
||||
let pendingTaskIDs: Set<String>
|
||||
let kanbanColumns: [VelocityKanbanColumnDTO]
|
||||
let opportunities: [VelocityOpportunityDTO]
|
||||
let properties: [VelocityPropertyDTO]
|
||||
let calendarEvents: [VelocityCalendarEventDTO]
|
||||
let alertSnapshot: VelocityAlertSnapshotDTO
|
||||
let leadEvents: [String: [VelocityCommunicationEventDTO]]
|
||||
}
|
||||
|
||||
private struct CalendarTaskRefresh {
|
||||
let tasks: [VelocityTaskDTO]
|
||||
let pendingTaskCount: Int
|
||||
let pendingTaskIDs: Set<String>
|
||||
}
|
||||
|
||||
private struct PersistedCalendarEvent: Codable {
|
||||
let calendarEventId: String
|
||||
let leadId: String?
|
||||
let title: String
|
||||
let description: String?
|
||||
let startAt: String
|
||||
let endAt: String
|
||||
let allDay: Bool
|
||||
let status: String
|
||||
let reminderMinutes: [Int]
|
||||
let createdBy: String
|
||||
let location: String?
|
||||
let createdAt: String
|
||||
|
||||
init(event: VelocityCalendarEventDTO) {
|
||||
calendarEventId = event.calendarEventId
|
||||
leadId = event.leadId
|
||||
title = event.title
|
||||
description = event.description
|
||||
startAt = event.startAt
|
||||
endAt = event.endAt
|
||||
allDay = event.allDay
|
||||
status = event.status
|
||||
reminderMinutes = event.reminderMinutes
|
||||
createdBy = event.createdBy
|
||||
location = event.location
|
||||
createdAt = event.createdAt
|
||||
}
|
||||
|
||||
var event: VelocityCalendarEventDTO {
|
||||
VelocityCalendarEventDTO(
|
||||
calendarEventId: calendarEventId,
|
||||
leadId: leadId,
|
||||
title: title,
|
||||
description: description,
|
||||
startAt: startAt,
|
||||
endAt: endAt,
|
||||
allDay: allDay,
|
||||
status: status,
|
||||
reminderMinutes: reminderMinutes,
|
||||
createdBy: createdBy,
|
||||
location: location,
|
||||
createdAt: createdAt
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private struct PersistedTask: Codable {
|
||||
let reminderId: String
|
||||
let reminderType: String
|
||||
let title: String
|
||||
let notes: String?
|
||||
let dueAt: String?
|
||||
let status: String
|
||||
let priority: String
|
||||
let personId: String?
|
||||
let clientName: String?
|
||||
let clientPhone: String?
|
||||
|
||||
init(task: VelocityTaskDTO) {
|
||||
reminderId = task.reminderId
|
||||
reminderType = task.reminderType
|
||||
title = task.title
|
||||
notes = task.notes
|
||||
dueAt = task.dueAt
|
||||
status = task.status
|
||||
priority = task.priority
|
||||
personId = task.personId
|
||||
clientName = task.clientName
|
||||
clientPhone = task.clientPhone
|
||||
}
|
||||
|
||||
var task: VelocityTaskDTO {
|
||||
VelocityTaskDTO(
|
||||
reminderId: reminderId,
|
||||
reminderType: reminderType,
|
||||
title: title,
|
||||
notes: notes,
|
||||
dueAt: dueAt,
|
||||
status: status,
|
||||
priority: priority,
|
||||
personId: personId,
|
||||
clientName: clientName,
|
||||
clientPhone: clientPhone
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
var contacts: [VelocityCanonicalContactListItemDTO] = []
|
||||
var leads: [VelocityLeadDTO] = []
|
||||
var tasks: [VelocityTaskDTO] = []
|
||||
var kanbanColumns: [VelocityKanbanColumnDTO] = []
|
||||
var opportunities: [VelocityOpportunityDTO] = []
|
||||
var properties: [VelocityPropertyDTO] = []
|
||||
var calendarEvents: [VelocityCalendarEventDTO] = []
|
||||
var leadEvents: [String: [VelocityCommunicationEventDTO]] = [:]
|
||||
var alertSnapshot: VelocityAlertSnapshotDTO?
|
||||
var pendingTaskMetricCount = 0
|
||||
var isLoading = false
|
||||
var errorMessage: String?
|
||||
var lastRefreshAt: Date?
|
||||
private var activeRefreshTask: Task<RefreshSnapshot, Error>?
|
||||
private var canonicalPendingTaskCount = 0
|
||||
private var canonicalPendingTaskIDs: Set<String> = []
|
||||
private var locallyResolvedTaskIDs: Set<String> = []
|
||||
private var localTaskOverrides: [String: VelocityTaskDTO] = [:]
|
||||
private var locallyCreatedCalendarEvents: [VelocityCalendarEventDTO] = []
|
||||
|
||||
var operatorIdentity: String {
|
||||
if let email = AppConfig.apiEmail, !email.isEmpty {
|
||||
return email
|
||||
}
|
||||
if let token = AppConfig.apiBearerToken, !token.isEmpty {
|
||||
return "Token authenticated operator"
|
||||
}
|
||||
return "Unconfigured operator"
|
||||
}
|
||||
|
||||
var authDescription: String {
|
||||
if let _ = AppConfig.apiBearerToken {
|
||||
return "Bearer token"
|
||||
}
|
||||
if AppConfig.apiEmail != nil, AppConfig.apiPassword != nil {
|
||||
return "Email/password login"
|
||||
}
|
||||
return "Credentials required"
|
||||
}
|
||||
|
||||
var isConfigured: Bool {
|
||||
AppConfig.isLiveConfigured
|
||||
}
|
||||
|
||||
var metrics: DashboardMetrics {
|
||||
DashboardMetrics(
|
||||
leadCount: leads.count,
|
||||
whaleLeadCount: leads.filter { $0.score >= 90 || $0.qualification.lowercased() == "whale" }.count,
|
||||
propertyCount: properties.count,
|
||||
todayCalendarCount: calendarEvents.filter { $0.startsToday }.count,
|
||||
pendingTaskCount: pendingTaskMetricCount,
|
||||
urgentTaskCount: tasks.filter {
|
||||
$0.status.lowercased() == "pending" && ["urgent", "high"].contains($0.priority.lowercased())
|
||||
}.count,
|
||||
pendingInsights: alertSnapshot?.pendingInsights ?? 0,
|
||||
pendingTranscriptions: alertSnapshot?.pendingTranscriptions ?? 0
|
||||
)
|
||||
}
|
||||
|
||||
var highlightedLeads: [VelocityLeadDTO] {
|
||||
Array(leads.sorted(by: { $0.score > $1.score }).prefix(5))
|
||||
}
|
||||
|
||||
var highlightedContacts: [VelocityCanonicalContactListItemDTO] {
|
||||
Array(contacts.prefix(12))
|
||||
}
|
||||
|
||||
var timelineEvents: [TimelineEvent] {
|
||||
leadEvents
|
||||
.flatMap { leadId, events in
|
||||
events.map { TimelineEvent(leadId: leadId, event: $0, leadName: leadName(for: leadId)) }
|
||||
}
|
||||
.sorted(by: { $0.date > $1.date })
|
||||
}
|
||||
|
||||
var prioritizedTasks: [VelocityTaskDTO] {
|
||||
VelocityTaskDTO.sortedForOperatorReview(tasks)
|
||||
}
|
||||
|
||||
func resetLiveData() {
|
||||
contacts = []
|
||||
leads = []
|
||||
tasks = []
|
||||
kanbanColumns = []
|
||||
opportunities = []
|
||||
properties = []
|
||||
calendarEvents = []
|
||||
leadEvents = [:]
|
||||
alertSnapshot = nil
|
||||
pendingTaskMetricCount = 0
|
||||
canonicalPendingTaskCount = 0
|
||||
canonicalPendingTaskIDs = []
|
||||
isLoading = false
|
||||
errorMessage = nil
|
||||
lastRefreshAt = nil
|
||||
canonicalPendingTaskCount = 0
|
||||
canonicalPendingTaskIDs = []
|
||||
locallyResolvedTaskIDs = []
|
||||
localTaskOverrides = [:]
|
||||
locallyCreatedCalendarEvents = []
|
||||
Self.saveLocallyHiddenTaskIDs([])
|
||||
Self.saveLocallyMutatedTasks([])
|
||||
Self.saveLocallyCreatedCalendarEvents([])
|
||||
}
|
||||
|
||||
func refresh(silent: Bool = false) async {
|
||||
if !silent {
|
||||
isLoading = true
|
||||
}
|
||||
|
||||
do {
|
||||
let task = activeRefreshTask ?? makeRefreshTask()
|
||||
activeRefreshTask = task
|
||||
|
||||
let snapshot = try await task.value
|
||||
activeRefreshTask = nil
|
||||
|
||||
contacts = snapshot.contacts
|
||||
leads = snapshot.leads
|
||||
tasks = mergedTasks(with: snapshot.tasks)
|
||||
canonicalPendingTaskCount = snapshot.pendingTaskCount
|
||||
canonicalPendingTaskIDs = snapshot.pendingTaskIDs
|
||||
kanbanColumns = snapshot.kanbanColumns
|
||||
opportunities = snapshot.opportunities
|
||||
properties = snapshot.properties
|
||||
calendarEvents = mergedCalendarEvents(with: snapshot.calendarEvents)
|
||||
refreshPendingTaskMetricCount()
|
||||
alertSnapshot = snapshot.alertSnapshot
|
||||
leadEvents = snapshot.leadEvents
|
||||
lastRefreshAt = Date()
|
||||
errorMessage = nil
|
||||
isLoading = false
|
||||
} catch {
|
||||
activeRefreshTask = nil
|
||||
if !silent || lastRefreshAt == nil {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
if !silent {
|
||||
contacts = []
|
||||
leads = []
|
||||
tasks = []
|
||||
kanbanColumns = []
|
||||
opportunities = []
|
||||
properties = []
|
||||
calendarEvents = []
|
||||
alertSnapshot = nil
|
||||
pendingTaskMetricCount = 0
|
||||
canonicalPendingTaskCount = 0
|
||||
canonicalPendingTaskIDs = []
|
||||
leadEvents = [:]
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
func leadName(for leadId: String) -> String {
|
||||
leads.first(where: { $0.id == leadId })?.name ?? "Unknown lead"
|
||||
}
|
||||
|
||||
func updateTaskStatus(
|
||||
reminderId: String,
|
||||
status: String,
|
||||
dueAt: String? = nil,
|
||||
notes: String? = nil
|
||||
) async throws -> VelocityTaskDTO {
|
||||
let serverTask: VelocityTaskDTO
|
||||
do {
|
||||
serverTask = try await VelocityAPIClient.shared.updateTask(
|
||||
reminderId: reminderId,
|
||||
status: status,
|
||||
dueAt: dueAt,
|
||||
notes: notes
|
||||
)
|
||||
} catch let error as VelocityAPIError where error.statusCode == 404 {
|
||||
serverTask = locallyResolveMissingTask(
|
||||
reminderId: reminderId,
|
||||
status: status,
|
||||
dueAt: dueAt
|
||||
)
|
||||
}
|
||||
let updatedTask = locallyMutatedTask(from: serverTask, status: status, dueAt: dueAt)
|
||||
if updatedTask.status.lowercased() == "cancelled" {
|
||||
localTaskOverrides.removeValue(forKey: reminderId)
|
||||
locallyResolvedTaskIDs.insert(reminderId)
|
||||
Self.saveLocallyMutatedTasks(Array(localTaskOverrides.values))
|
||||
Self.saveLocallyHiddenTaskIDs(Array(locallyResolvedTaskIDs))
|
||||
tasks.removeAll { $0.reminderId == reminderId }
|
||||
} else {
|
||||
locallyResolvedTaskIDs.remove(reminderId)
|
||||
upsertLocalTaskOverride(updatedTask)
|
||||
if let index = tasks.firstIndex(where: { $0.reminderId == reminderId }) {
|
||||
tasks[index] = updatedTask
|
||||
} else {
|
||||
tasks.append(updatedTask)
|
||||
}
|
||||
tasks = VelocityTaskDTO.sortedForOperatorReview(tasks)
|
||||
}
|
||||
refreshPendingTaskMetricCount()
|
||||
errorMessage = nil
|
||||
await refresh(silent: true)
|
||||
return updatedTask
|
||||
}
|
||||
|
||||
func createCalendarEvent(
|
||||
leadId: String?,
|
||||
title: String,
|
||||
description: String?,
|
||||
startAt: String,
|
||||
endAt: String,
|
||||
allDay: Bool,
|
||||
status: String,
|
||||
reminderMinutes: [Int],
|
||||
location: String?,
|
||||
metadata: [String: String] = [:]
|
||||
) async throws -> VelocityCalendarEventCreateResultDTO {
|
||||
let createdEvent: VelocityCalendarEventCreateResultDTO
|
||||
var shouldPersistLocalFallback = false
|
||||
do {
|
||||
createdEvent = try await VelocityAPIClient.shared.createCalendarEvent(
|
||||
leadId: leadId,
|
||||
title: title,
|
||||
description: description,
|
||||
startAt: startAt,
|
||||
endAt: endAt,
|
||||
allDay: allDay,
|
||||
status: status,
|
||||
reminderMinutes: reminderMinutes,
|
||||
location: location,
|
||||
metadata: metadata
|
||||
)
|
||||
} catch let error as VelocityAPIError where error.isRecoverableCalendarCreateFailure {
|
||||
createdEvent = VelocityCalendarEventCreateResultDTO(
|
||||
calendarEventId: "local-\(UUID().uuidString)",
|
||||
createdAt: ISO8601DateFormatter().string(from: Date())
|
||||
)
|
||||
shouldPersistLocalFallback = true
|
||||
}
|
||||
let optimisticEvent = VelocityCalendarEventDTO(
|
||||
calendarEventId: createdEvent.calendarEventId,
|
||||
leadId: leadId,
|
||||
title: title,
|
||||
description: description,
|
||||
startAt: startAt,
|
||||
endAt: endAt,
|
||||
allDay: allDay,
|
||||
status: status,
|
||||
reminderMinutes: reminderMinutes,
|
||||
createdBy: "user",
|
||||
location: location,
|
||||
createdAt: createdEvent.createdAt
|
||||
)
|
||||
upsertLocalCalendarEvent(optimisticEvent, persist: shouldPersistLocalFallback)
|
||||
calendarEvents = mergedCalendarEvents(with: calendarEvents)
|
||||
refreshPendingTaskMetricCount()
|
||||
errorMessage = nil
|
||||
await refresh(silent: true)
|
||||
return createdEvent
|
||||
}
|
||||
|
||||
func updateCalendarEvent(
|
||||
_ event: VelocityCalendarEventDTO,
|
||||
status: String? = nil,
|
||||
startAt: String? = nil,
|
||||
endAt: String? = nil
|
||||
) async throws -> VelocityCalendarEventDTO {
|
||||
let shouldPersistFallback: Bool
|
||||
do {
|
||||
try await VelocityAPIClient.shared.updateCalendarEvent(
|
||||
calendarEventId: event.calendarEventId,
|
||||
startAt: startAt,
|
||||
endAt: endAt,
|
||||
status: status
|
||||
)
|
||||
shouldPersistFallback = event.calendarEventId.hasPrefix("local-")
|
||||
} catch let error as VelocityAPIError where error.isRecoverableCalendarCreateFailure {
|
||||
shouldPersistFallback = true
|
||||
}
|
||||
|
||||
let updatedEvent = VelocityCalendarEventDTO(
|
||||
calendarEventId: event.calendarEventId,
|
||||
leadId: event.leadId,
|
||||
title: event.title,
|
||||
description: event.description,
|
||||
startAt: startAt ?? event.startAt,
|
||||
endAt: endAt ?? event.endAt,
|
||||
allDay: event.allDay,
|
||||
status: status ?? event.status,
|
||||
reminderMinutes: event.reminderMinutes,
|
||||
createdBy: event.createdBy,
|
||||
location: event.location,
|
||||
createdAt: event.createdAt
|
||||
)
|
||||
|
||||
if updatedEvent.status == "cancelled" {
|
||||
calendarEvents.removeAll { $0.calendarEventId == event.calendarEventId }
|
||||
locallyCreatedCalendarEvents.removeAll { $0.calendarEventId == event.calendarEventId }
|
||||
Self.saveLocallyCreatedCalendarEvents(locallyCreatedCalendarEvents.filter { $0.calendarEventId.hasPrefix("local-") })
|
||||
} else {
|
||||
upsertLocalCalendarEvent(updatedEvent, persist: shouldPersistFallback)
|
||||
calendarEvents = mergedCalendarEvents(with: calendarEvents)
|
||||
}
|
||||
refreshPendingTaskMetricCount()
|
||||
errorMessage = nil
|
||||
await refresh(silent: true)
|
||||
return updatedEvent
|
||||
}
|
||||
|
||||
func cancelCalendarEvent(_ event: VelocityCalendarEventDTO) async throws {
|
||||
var shouldPersistFallback = event.calendarEventId.hasPrefix("local-")
|
||||
do {
|
||||
try await VelocityAPIClient.shared.cancelCalendarEvent(calendarEventId: event.calendarEventId)
|
||||
} catch let error as VelocityAPIError where error.isRecoverableCalendarCreateFailure {
|
||||
shouldPersistFallback = true
|
||||
}
|
||||
let cancelledEvent = VelocityCalendarEventDTO(
|
||||
calendarEventId: event.calendarEventId,
|
||||
leadId: event.leadId,
|
||||
title: event.title,
|
||||
description: event.description,
|
||||
startAt: event.startAt,
|
||||
endAt: event.endAt,
|
||||
allDay: event.allDay,
|
||||
status: "cancelled",
|
||||
reminderMinutes: event.reminderMinutes,
|
||||
createdBy: event.createdBy,
|
||||
location: event.location,
|
||||
createdAt: event.createdAt
|
||||
)
|
||||
upsertLocalCalendarEvent(cancelledEvent, persist: shouldPersistFallback)
|
||||
calendarEvents.removeAll { $0.calendarEventId == event.calendarEventId }
|
||||
refreshPendingTaskMetricCount()
|
||||
errorMessage = nil
|
||||
await refresh(silent: true)
|
||||
}
|
||||
|
||||
func updateLeadStage(
|
||||
leadId: String,
|
||||
status: String,
|
||||
notes: String? = nil
|
||||
) async throws -> VelocityLeadStageUpdateDTO {
|
||||
let updatedLead = try await VelocityAPIClient.shared.updateLeadStage(
|
||||
leadId: leadId,
|
||||
status: status,
|
||||
notes: notes
|
||||
)
|
||||
await refresh(silent: true)
|
||||
return updatedLead
|
||||
}
|
||||
|
||||
func updateOpportunity(
|
||||
opportunityId: String,
|
||||
stage: String? = nil,
|
||||
probability: Int? = nil,
|
||||
nextAction: String? = nil,
|
||||
notes: String? = nil
|
||||
) async throws -> VelocityOpportunityDTO {
|
||||
let updatedOpportunity = try await VelocityAPIClient.shared.updateOpportunity(
|
||||
opportunityId: opportunityId,
|
||||
stage: stage,
|
||||
probability: probability,
|
||||
nextAction: nextAction,
|
||||
notes: notes
|
||||
)
|
||||
await refresh(silent: true)
|
||||
return updatedOpportunity
|
||||
}
|
||||
|
||||
private func locallyResolveMissingTask(
|
||||
reminderId: String,
|
||||
status: String,
|
||||
dueAt: String?
|
||||
) -> VelocityTaskDTO {
|
||||
if status.lowercased() == "cancelled" {
|
||||
locallyResolvedTaskIDs.insert(reminderId)
|
||||
}
|
||||
let existing = tasks.first { $0.reminderId == reminderId }
|
||||
return VelocityTaskDTO(
|
||||
reminderId: reminderId,
|
||||
reminderType: existing?.reminderType ?? "follow_up",
|
||||
title: existing?.title ?? "Calendar task",
|
||||
notes: existing?.notes,
|
||||
dueAt: dueAt ?? existing?.dueAt,
|
||||
status: status,
|
||||
priority: existing?.priority ?? "normal",
|
||||
personId: existing?.personId,
|
||||
clientName: existing?.clientName,
|
||||
clientPhone: existing?.clientPhone
|
||||
)
|
||||
}
|
||||
|
||||
private func locallyMutatedTask(
|
||||
from task: VelocityTaskDTO,
|
||||
status: String,
|
||||
dueAt: String?
|
||||
) -> VelocityTaskDTO {
|
||||
VelocityTaskDTO(
|
||||
reminderId: task.reminderId,
|
||||
reminderType: task.reminderType,
|
||||
title: task.title,
|
||||
notes: task.notes,
|
||||
dueAt: dueAt ?? task.dueAt,
|
||||
status: status,
|
||||
priority: task.priority,
|
||||
personId: task.personId,
|
||||
clientName: task.clientName,
|
||||
clientPhone: task.clientPhone
|
||||
)
|
||||
}
|
||||
|
||||
private func upsertLocalTaskOverride(_ task: VelocityTaskDTO) {
|
||||
localTaskOverrides[task.reminderId] = task
|
||||
Self.saveLocallyMutatedTasks(Array(localTaskOverrides.values))
|
||||
Self.saveLocallyHiddenTaskIDs(Array(locallyResolvedTaskIDs))
|
||||
}
|
||||
|
||||
private func mergedTasks(with fetchedTasks: [VelocityTaskDTO]) -> [VelocityTaskDTO] {
|
||||
var taskByID = Dictionary(uniqueKeysWithValues: fetchedTasks.map { ($0.reminderId, $0) })
|
||||
for task in localTaskOverrides.values {
|
||||
taskByID[task.reminderId] = task
|
||||
}
|
||||
let visibleTasks = taskByID.values.filter { !locallyResolvedTaskIDs.contains($0.reminderId) }
|
||||
return VelocityTaskDTO.sortedForOperatorReview(Array(visibleTasks))
|
||||
}
|
||||
|
||||
private func refreshPendingTaskMetricCount() {
|
||||
var localDelta = 0
|
||||
for task in localTaskOverrides.values {
|
||||
let isCanonicalPending = canonicalPendingTaskIDs.contains(task.reminderId)
|
||||
let isLocallyPending = task.status.lowercased() == "pending"
|
||||
if isCanonicalPending && !isLocallyPending {
|
||||
localDelta -= 1
|
||||
} else if !isCanonicalPending && isLocallyPending {
|
||||
localDelta += 1
|
||||
}
|
||||
}
|
||||
|
||||
let locallyHiddenPendingCount = locallyResolvedTaskIDs
|
||||
.filter { canonicalPendingTaskIDs.contains($0) }
|
||||
.count
|
||||
let normalCalendarTaskCount = calendarEvents.filter { event in
|
||||
event.status.lowercased() == "tentative"
|
||||
}.count
|
||||
|
||||
pendingTaskMetricCount = max(
|
||||
0,
|
||||
canonicalPendingTaskCount + localDelta - locallyHiddenPendingCount + normalCalendarTaskCount
|
||||
)
|
||||
}
|
||||
|
||||
private static func loadLocallyMutatedTasks() -> [String: VelocityTaskDTO] {
|
||||
guard let data = UserDefaults.standard.data(forKey: locallyMutatedTasksKey),
|
||||
let persistedTasks = try? JSONDecoder().decode([PersistedTask].self, from: data)
|
||||
else {
|
||||
return [:]
|
||||
}
|
||||
return Dictionary(uniqueKeysWithValues: persistedTasks.map { ($0.reminderId, $0.task) })
|
||||
}
|
||||
|
||||
private static func saveLocallyMutatedTasks(_ tasks: [VelocityTaskDTO]) {
|
||||
let persistedTasks = tasks.map(PersistedTask.init(task:))
|
||||
if let data = try? JSONEncoder().encode(persistedTasks) {
|
||||
UserDefaults.standard.set(data, forKey: locallyMutatedTasksKey)
|
||||
}
|
||||
}
|
||||
|
||||
private static func loadLocallyHiddenTaskIDs() -> Set<String> {
|
||||
let ids = UserDefaults.standard.stringArray(forKey: locallyHiddenTaskIDsKey) ?? []
|
||||
return Set(ids)
|
||||
}
|
||||
|
||||
private static func saveLocallyHiddenTaskIDs(_ taskIDs: [String]) {
|
||||
UserDefaults.standard.set(taskIDs, forKey: locallyHiddenTaskIDsKey)
|
||||
}
|
||||
|
||||
private func upsertLocalCalendarEvent(_ event: VelocityCalendarEventDTO, persist: Bool) {
|
||||
locallyCreatedCalendarEvents.removeAll { $0.calendarEventId == event.calendarEventId }
|
||||
locallyCreatedCalendarEvents.append(event)
|
||||
if persist {
|
||||
Self.saveLocallyCreatedCalendarEvents(locallyCreatedCalendarEvents)
|
||||
}
|
||||
}
|
||||
|
||||
private func mergedCalendarEvents(with fetchedEvents: [VelocityCalendarEventDTO]) -> [VelocityCalendarEventDTO] {
|
||||
var eventByID = Dictionary(uniqueKeysWithValues: fetchedEvents.map { ($0.calendarEventId, $0) })
|
||||
for event in locallyCreatedCalendarEvents {
|
||||
eventByID[event.calendarEventId] = event
|
||||
}
|
||||
return eventByID.values.filter { $0.status != "cancelled" }.sorted {
|
||||
($0.startDate ?? .distantFuture) < ($1.startDate ?? .distantFuture)
|
||||
}
|
||||
}
|
||||
|
||||
private static func loadLocallyCreatedCalendarEvents() -> [VelocityCalendarEventDTO] {
|
||||
guard let data = UserDefaults.standard.data(forKey: locallyCreatedCalendarEventsKey),
|
||||
let persistedEvents = try? JSONDecoder().decode([PersistedCalendarEvent].self, from: data)
|
||||
else {
|
||||
return []
|
||||
}
|
||||
return persistedEvents.map(\.event)
|
||||
}
|
||||
|
||||
private static func saveLocallyCreatedCalendarEvents(_ events: [VelocityCalendarEventDTO]) {
|
||||
let persistedEvents = events.map(PersistedCalendarEvent.init(event:))
|
||||
if let data = try? JSONEncoder().encode(persistedEvents) {
|
||||
UserDefaults.standard.set(data, forKey: locallyCreatedCalendarEventsKey)
|
||||
}
|
||||
}
|
||||
|
||||
private func makeRefreshTask() -> Task<RefreshSnapshot, Error> {
|
||||
let cachedContacts = contacts
|
||||
return Task {
|
||||
async let tasksTask = fetchCalendarTasks()
|
||||
async let kanbanTask: [VelocityKanbanColumnDTO]? = try? await VelocityAPIClient.shared.fetchKanbanBoard()
|
||||
async let opportunitiesTask: [VelocityOpportunityDTO]? = try? await VelocityAPIClient.shared.fetchOpportunities()
|
||||
async let propertiesTask: [VelocityPropertyDTO]? = try? await VelocityAPIClient.shared.fetchProperties(
|
||||
limit: AppStoreRefreshPolicy.inventoryPropertyLimit
|
||||
)
|
||||
async let calendarTask: [VelocityCalendarEventDTO]? = try? await VelocityAPIClient.shared.fetchCalendarEvents()
|
||||
async let alertsTask: VelocityAlertSnapshotDTO? = try? await VelocityAPIClient.shared.fetchAlerts()
|
||||
|
||||
let fetchedContacts: [VelocityCanonicalContactListItemDTO]
|
||||
do {
|
||||
fetchedContacts = try await VelocityAPIClient.shared.fetchContacts()
|
||||
} catch let error as VelocityAPIError where error.statusCode == 404 {
|
||||
fetchedContacts = cachedContacts
|
||||
}
|
||||
let fetchedLeads = VelocityLeadDTO.activeLeadSummaries(from: fetchedContacts)
|
||||
let taskRefresh = await tasksTask
|
||||
let fetchedTasks = taskRefresh.tasks.filter { !locallyResolvedTaskIDs.contains($0.reminderId) }
|
||||
let fetchedKanban = await kanbanTask ?? []
|
||||
let fetchedOpportunities = await opportunitiesTask ?? []
|
||||
let fetchedProperties = await propertiesTask ?? []
|
||||
let fetchedCalendar = await calendarTask ?? []
|
||||
let fetchedAlerts = await alertsTask ?? VelocityAlertSnapshotDTO.empty
|
||||
let leadEvents = await fetchLeadEvents(for: fetchedLeads)
|
||||
|
||||
return RefreshSnapshot(
|
||||
contacts: fetchedContacts,
|
||||
leads: fetchedLeads,
|
||||
tasks: fetchedTasks,
|
||||
pendingTaskCount: taskRefresh.pendingTaskCount,
|
||||
pendingTaskIDs: taskRefresh.pendingTaskIDs,
|
||||
kanbanColumns: fetchedKanban,
|
||||
opportunities: fetchedOpportunities,
|
||||
properties: fetchedProperties,
|
||||
calendarEvents: fetchedCalendar,
|
||||
alertSnapshot: fetchedAlerts,
|
||||
leadEvents: leadEvents
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func fetchCalendarTasks() async -> CalendarTaskRefresh {
|
||||
async let allTasks: [VelocityTaskDTO]? = try? await VelocityAPIClient.shared.fetchTasks(status: "all")
|
||||
async let pendingTasks: [VelocityTaskDTO]? = try? await VelocityAPIClient.shared.fetchTasks(status: "pending")
|
||||
async let confirmedTasks: [VelocityTaskDTO]? = try? await VelocityAPIClient.shared.fetchTasks(status: "confirmed")
|
||||
async let doneTasks: [VelocityTaskDTO]? = try? await VelocityAPIClient.shared.fetchTasks(status: "done")
|
||||
async let snoozedTasks: [VelocityTaskDTO]? = try? await VelocityAPIClient.shared.fetchTasks(status: "snoozed")
|
||||
|
||||
let fetchedAllTasks = await allTasks ?? []
|
||||
let pendingTaskResponse = await pendingTasks
|
||||
let fetchedPendingTasks = pendingTaskResponse ?? []
|
||||
let fetchedConfirmedTasks = await confirmedTasks ?? []
|
||||
let fetchedDoneTasks = await doneTasks ?? []
|
||||
let fetchedSnoozedTasks = await snoozedTasks ?? []
|
||||
|
||||
var taskByID: [String: VelocityTaskDTO] = [:]
|
||||
for task in fetchedAllTasks {
|
||||
taskByID[task.reminderId] = task
|
||||
}
|
||||
for task in fetchedPendingTasks {
|
||||
taskByID[task.reminderId] = task
|
||||
}
|
||||
for task in fetchedConfirmedTasks {
|
||||
taskByID[task.reminderId] = task
|
||||
}
|
||||
for task in fetchedDoneTasks {
|
||||
taskByID[task.reminderId] = task
|
||||
}
|
||||
for task in fetchedSnoozedTasks {
|
||||
taskByID[task.reminderId] = task
|
||||
}
|
||||
let pendingTaskCount = pendingTaskResponse?.count ?? fetchedAllTasks.filter { $0.status.lowercased() == "pending" }.count
|
||||
let pendingTaskIDs = Set(fetchedPendingTasks.map(\.reminderId))
|
||||
return CalendarTaskRefresh(
|
||||
tasks: VelocityTaskDTO.sortedForOperatorReview(Array(taskByID.values)),
|
||||
pendingTaskCount: pendingTaskCount,
|
||||
pendingTaskIDs: pendingTaskIDs
|
||||
)
|
||||
}
|
||||
|
||||
private func fetchLeadEvents(
|
||||
for leads: [VelocityLeadDTO]
|
||||
) async -> [String: [VelocityCommunicationEventDTO]] {
|
||||
let prioritizedLeadIDs = AppStoreRefreshPolicy.prioritizedLeadIDs(from: leads)
|
||||
|
||||
return await withTaskGroup(
|
||||
of: (String, [VelocityCommunicationEventDTO]).self,
|
||||
returning: [String: [VelocityCommunicationEventDTO]].self
|
||||
) { group in
|
||||
for leadID in prioritizedLeadIDs {
|
||||
group.addTask {
|
||||
do {
|
||||
let events = try await VelocityAPIClient.shared.fetchEvents(
|
||||
for: leadID,
|
||||
limit: AppStoreRefreshPolicy.leadEventLimitPerLead
|
||||
)
|
||||
return (leadID, events)
|
||||
} catch {
|
||||
return (leadID, [])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var eventMap: [String: [VelocityCommunicationEventDTO]] = [:]
|
||||
for await (leadID, events) in group {
|
||||
eventMap[leadID] = events
|
||||
}
|
||||
return eventMap
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct TimelineEvent: Identifiable {
|
||||
let leadId: String
|
||||
let event: VelocityCommunicationEventDTO
|
||||
let leadName: String
|
||||
|
||||
var id: String { event.id }
|
||||
var date: Date { event.timestampDate ?? .distantPast }
|
||||
}
|
||||
|
||||
extension VelocityCalendarEventDTO {
|
||||
var startsToday: Bool {
|
||||
guard let date = startDate else { return false }
|
||||
return Calendar.current.isDateInToday(date)
|
||||
}
|
||||
}
|
||||
|
||||
extension Date {
|
||||
var relativeShort: String {
|
||||
let delta = Int(Date().timeIntervalSince(self))
|
||||
if delta < 60 { return "now" }
|
||||
if delta < 3600 { return "\(delta / 60)m ago" }
|
||||
if delta < 86400 { return "\(delta / 3600)h ago" }
|
||||
return "\(delta / 86400)d ago"
|
||||
}
|
||||
|
||||
var taskDueLabel: String {
|
||||
if Calendar.current.isDateInToday(self) {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "'Today' · h:mm a"
|
||||
return formatter.string(from: self)
|
||||
}
|
||||
if Calendar.current.isDateInTomorrow(self) {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "'Tomorrow' · h:mm a"
|
||||
return formatter.string(from: self)
|
||||
}
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "EEE, MMM d · h:mm a"
|
||||
return formatter.string(from: self)
|
||||
}
|
||||
}
|
||||
|
||||
private extension VelocityAPIError {
|
||||
var isRecoverableCalendarCreateFailure: Bool {
|
||||
if let statusCode {
|
||||
return statusCode == 404 || (500...599).contains(statusCode)
|
||||
}
|
||||
if case .invalidResponse = self {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import Foundation
|
||||
|
||||
enum AppStoreRefreshPolicy {
|
||||
/// Match the WebOS bootstrap so Inventory, Dashboard, and shared summaries
|
||||
/// are based on the same production property slice by default.
|
||||
static let inventoryPropertyLimit = 100
|
||||
|
||||
/// Keep the canonical CRM follow-up inbox bounded while still representing
|
||||
/// the operator's active task load on iPad surfaces.
|
||||
static let canonicalTaskLimit = 50
|
||||
|
||||
/// iPad surfaces only render a small operator-focused timeline, so keep the
|
||||
/// lead-event hydration set intentionally narrower than WebOS.
|
||||
static let leadTimelineHydrationLimit = 6
|
||||
|
||||
/// Fetch enough recent communication context for the visible iPad rails
|
||||
/// without inflating each refresh unnecessarily.
|
||||
static let leadEventLimitPerLead = 4
|
||||
|
||||
static func prioritizedLeadIDs(
|
||||
from leads: [VelocityLeadDTO],
|
||||
limit: Int = leadTimelineHydrationLimit
|
||||
) -> [String] {
|
||||
Array(
|
||||
leads
|
||||
.sorted(by: { $0.score > $1.score })
|
||||
.prefix(limit)
|
||||
.map(\.id)
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user