mirror of
https://github.com/warmbly/warmbly.git
synced 2026-10-17 00:02:47 +00:00
143 lines
6.0 KiB
Swift
143 lines
6.0 KiB
Swift
import Foundation
|
|
|
|
/// Loads the four analytics surfaces in parallel; each section fails
|
|
/// independently so one broken endpoint never blanks the whole screen.
|
|
@MainActor
|
|
@Observable
|
|
final class AnalyticsStore {
|
|
var period: AnalyticsPeriod = .week
|
|
/// Narrows the overview's campaign sections; mailbox health stays workspace-wide.
|
|
var campaignFilter = AnalyticsCampaignFilter()
|
|
|
|
private(set) var dashboard: DashboardAnalytics?
|
|
private(set) var deliverability: DeliverabilitySummary?
|
|
private(set) var warmup: WarmupAnalytics?
|
|
private(set) var accounts: [AccountHealthRow] = []
|
|
|
|
private(set) var isLoading = false
|
|
private(set) var dashboardError: String?
|
|
|
|
/// Accounts actively warming, for the drawer's live badge.
|
|
var warmingCount: Int {
|
|
accounts.count { $0.warmupStatus?.enabled == true && $0.warmupStatus?.paused != true }
|
|
}
|
|
|
|
/// Accounts whose health checks flagged problems.
|
|
var issueCount: Int {
|
|
accounts.count { $0.health?.status == "warning" || $0.health?.status == "error" }
|
|
}
|
|
|
|
private(set) var deliverabilityError: String?
|
|
private(set) var warmupError: String?
|
|
private(set) var accountsError: String?
|
|
|
|
func load(_ api: APIClient) async {
|
|
if isLoading { return }
|
|
isLoading = true
|
|
async let dashboardDone: Void = loadDashboard(api)
|
|
async let deliverabilityDone: Void = loadDeliverability(api)
|
|
async let warmupDone: Void = loadWarmup(api)
|
|
async let accountsDone: Void = loadAccounts(api)
|
|
_ = await (dashboardDone, deliverabilityDone, warmupDone, accountsDone)
|
|
isLoading = false
|
|
}
|
|
|
|
/// Reloads only the overview, for a filter change.
|
|
func reloadDashboard(_ api: APIClient) async {
|
|
await loadDashboard(api)
|
|
}
|
|
|
|
/// "All campaigns", a folder or campaign name, or a count.
|
|
func scopeLabel(folderName: (String) -> String?) -> String {
|
|
let filter = campaignFilter
|
|
if filter.isEmpty { return "All campaigns" }
|
|
let scope = dashboard?.scope
|
|
let count = scope?.campaignCount
|
|
let total = count.map { " · \($0) campaign\($0 == 1 ? "" : "s")" } ?? ""
|
|
if filter.campaigns.isEmpty, filter.folders.count == 1, let id = filter.folders.first {
|
|
let name = scope?.folders?.first { $0.id == id }?.name ?? folderName(id)
|
|
return (name ?? "1 folder") + total
|
|
}
|
|
if filter.folders.isEmpty, filter.campaigns.count == 1, let id = filter.campaigns.first {
|
|
return scope?.campaigns?.first { $0.id == id }?.name ?? "1 campaign"
|
|
}
|
|
if filter.folders.isEmpty {
|
|
let n = count ?? filter.campaigns.count
|
|
return "\(n) campaign\(n == 1 ? "" : "s")"
|
|
}
|
|
var parts = ["\(filter.folders.count) folder\(filter.folders.count == 1 ? "" : "s")"]
|
|
if !filter.campaigns.isEmpty {
|
|
parts.append("\(filter.campaigns.count) campaign\(filter.campaigns.count == 1 ? "" : "s")")
|
|
}
|
|
return parts.joined(separator: " + ") + total
|
|
}
|
|
|
|
private func loadDashboard(_ api: APIClient) async {
|
|
let filter = campaignFilter
|
|
var query: [String: String?] = ["period": period.rawValue]
|
|
if !filter.campaigns.isEmpty { query["campaign_ids"] = filter.campaigns.sorted().joined(separator: ",") }
|
|
if !filter.folders.isEmpty { query["folder_ids"] = filter.folders.sorted().joined(separator: ",") }
|
|
do {
|
|
let result: DashboardAnalytics = try await api.get("analytics/dashboard", query: query)
|
|
// A newer filter is already on its way; this answer describes the old one.
|
|
guard filter == campaignFilter else { return }
|
|
dashboard = result
|
|
dashboardError = nil
|
|
// Drop selections the server no longer recognises (deleted, or another workspace's).
|
|
if let scope = result.scope {
|
|
let campaigns = Set((scope.campaigns ?? []).map(\.id)).intersection(filter.campaigns)
|
|
let folders = Set((scope.folders ?? []).map(\.id)).intersection(filter.folders)
|
|
if campaigns != filter.campaigns || folders != filter.folders {
|
|
campaignFilter = AnalyticsCampaignFilter(campaigns: campaigns, folders: folders)
|
|
}
|
|
}
|
|
} catch {
|
|
guard filter == campaignFilter else { return }
|
|
dashboardError = error.localizedDescription
|
|
}
|
|
}
|
|
|
|
private func loadDeliverability(_ api: APIClient) async {
|
|
// from/to are RFC3339 here (unlike the warmup endpoint).
|
|
let to = Date()
|
|
let from = Calendar.current.date(byAdding: .day, value: -period.days, to: to) ?? to
|
|
let iso = ISO8601DateFormatter()
|
|
do {
|
|
let result: DeliverabilitySummary = try await api.get(
|
|
"analytics/deliverability",
|
|
query: ["from": iso.string(from: from), "to": iso.string(from: to)]
|
|
)
|
|
deliverability = result
|
|
deliverabilityError = nil
|
|
} catch {
|
|
deliverabilityError = error.localizedDescription
|
|
}
|
|
}
|
|
|
|
private func loadWarmup(_ api: APIClient) async {
|
|
// from/to are required, YYYY-MM-DD; no email_id = all accounts.
|
|
let to = Date()
|
|
let from = Calendar.current.date(byAdding: .day, value: -(period.days - 1), to: to) ?? to
|
|
do {
|
|
let result: WarmupAnalytics = try await api.get(
|
|
"analytics/warmup",
|
|
query: ["from": AnalyticsDay.string(from: from), "to": AnalyticsDay.string(from: to)]
|
|
)
|
|
warmup = result
|
|
warmupError = nil
|
|
} catch {
|
|
warmupError = error.localizedDescription
|
|
}
|
|
}
|
|
|
|
private func loadAccounts(_ api: APIClient) async {
|
|
do {
|
|
let envelope: AnalyticsDataEnvelope<AccountHealthRow> = try await api.get("analytics/accounts")
|
|
accounts = envelope.data ?? []
|
|
accountsError = nil
|
|
} catch {
|
|
accountsError = error.localizedDescription
|
|
}
|
|
}
|
|
}
|