Merge Conflicts (#41)
Some checks failed
Production Readiness / backend-contracts (push) Failing after 1m47s
Production Readiness / webos-typecheck (push) Successful in 1m57s
Production Readiness / ipad-parse (push) Successful in 1m32s

Co-authored-by: Sayan Datta <sayan@Sayans-MacBook-Air.local>
Reviewed-on: #41
This commit was merged in pull request #41.
This commit is contained in:
2026-04-28 11:32:56 +05:30
parent 61258978e1
commit 7ee51543d9
158 changed files with 23889 additions and 87196 deletions

View 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
}
}

View File

@@ -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)
)
}
}