mirror of
https://github.com/warmbly/warmbly.git
synced 2026-09-06 08:01:24 +00:00
501 lines
18 KiB
Swift
501 lines
18 KiB
Swift
import SwiftUI
|
|
import Charts
|
|
|
|
// MARK: - Tone maps
|
|
|
|
enum AnalyticsToneMap {
|
|
/// Deliverability band -> chip tone.
|
|
static func band(_ raw: String?) -> Tone {
|
|
switch raw {
|
|
case "healthy": .emerald
|
|
case "warning": .amber
|
|
case "quarantine": .orange
|
|
case "blocked": .rose
|
|
default: .slate
|
|
}
|
|
}
|
|
|
|
/// Account health status -> chip tone.
|
|
static func health(_ raw: String?) -> Tone {
|
|
switch raw {
|
|
case "healthy": .emerald
|
|
case "warning": .amber
|
|
case "error": .rose
|
|
default: .slate
|
|
}
|
|
}
|
|
|
|
/// Warmup pool state -> chip tone.
|
|
static func warmupState(_ raw: String?) -> Tone {
|
|
switch raw {
|
|
case "healthy": .emerald
|
|
case "watch": .amber
|
|
case "throttled": .amber
|
|
case "quarantined": .orange
|
|
case "blocked": .rose
|
|
default: .slate
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Flat list helpers
|
|
|
|
/// Eyebrow caption as a bare row — never a grouped `Section` header (that
|
|
/// reintroduces the sticky gray band). Whitespace above is the separator.
|
|
struct AnalyticsSectionCaption<Trailing: View>: View {
|
|
let title: String
|
|
let icon: String?
|
|
let iconTone: Tone
|
|
let top: CGFloat
|
|
let trailing: Trailing
|
|
|
|
init(
|
|
_ title: String,
|
|
icon: String? = nil,
|
|
iconTone: Tone = .slate,
|
|
top: CGFloat = 20,
|
|
@ViewBuilder trailing: () -> Trailing
|
|
) {
|
|
self.title = title
|
|
self.icon = icon
|
|
self.iconTone = iconTone
|
|
self.top = top
|
|
self.trailing = trailing()
|
|
}
|
|
|
|
var body: some View {
|
|
HStack(spacing: 5) {
|
|
if let icon {
|
|
Image(systemName: icon)
|
|
.font(.caption2)
|
|
.foregroundStyle(iconTone.color)
|
|
}
|
|
EyebrowLabel(title)
|
|
Spacer(minLength: 8)
|
|
trailing
|
|
}
|
|
.padding(.horizontal, 20)
|
|
.padding(.top, top)
|
|
.padding(.bottom, 8)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.listRowInsets(EdgeInsets())
|
|
.listRowSeparator(.hidden)
|
|
.listRowBackground(Color(.systemBackground))
|
|
}
|
|
}
|
|
|
|
extension AnalyticsSectionCaption where Trailing == EmptyView {
|
|
init(_ title: String, icon: String? = nil, iconTone: Tone = .slate, top: CGFloat = 20) {
|
|
self.init(title, icon: icon, iconTone: iconTone, top: top) { EmptyView() }
|
|
}
|
|
}
|
|
|
|
/// Flat row treatment for the analytics plain lists: 20pt gutters, white row
|
|
/// background, and (when given) a hairline tucked under the text column.
|
|
private struct AnalyticsPlainRowModifier: ViewModifier {
|
|
let separator: Visibility
|
|
let separatorLeading: CGFloat?
|
|
|
|
func body(content: Content) -> some View {
|
|
let base = content
|
|
.listRowInsets(EdgeInsets(top: 0, leading: 20, bottom: 0, trailing: 20))
|
|
.listRowSeparator(separator)
|
|
.listRowBackground(Color(.systemBackground))
|
|
if let separatorLeading {
|
|
base.alignmentGuide(.listRowSeparatorLeading) { _ in separatorLeading }
|
|
} else {
|
|
base
|
|
}
|
|
}
|
|
}
|
|
|
|
extension View {
|
|
func analyticsPlainRow(separator: Visibility = .automatic, separatorLeading: CGFloat? = nil) -> some View {
|
|
modifier(AnalyticsPlainRowModifier(separator: separator, separatorLeading: separatorLeading))
|
|
}
|
|
}
|
|
|
|
/// Redacted stat grid shown while a section's endpoint is still loading.
|
|
struct AnalyticsSectionLoading: View {
|
|
var body: some View {
|
|
LazyVGrid(
|
|
columns: [GridItem(.flexible(), alignment: .leading), GridItem(.flexible(), alignment: .leading), GridItem(.flexible(), alignment: .leading)],
|
|
alignment: .leading,
|
|
spacing: 14
|
|
) {
|
|
ForEach(0 ..< 3, id: \.self) { _ in
|
|
AnalyticsMiniStat(label: "Loading", value: "0000")
|
|
}
|
|
}
|
|
.redacted(reason: .placeholder)
|
|
.padding(.vertical, 8)
|
|
}
|
|
}
|
|
|
|
/// Inline section failure row; the rest of the screen keeps working.
|
|
struct AnalyticsSectionError: View {
|
|
let text: String
|
|
|
|
var body: some View {
|
|
HStack(spacing: 8) {
|
|
Image(systemName: "exclamationmark.triangle")
|
|
.font(.footnote)
|
|
.foregroundStyle(WTheme.negative)
|
|
Text(text)
|
|
.font(.footnote)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
.padding(.vertical, 6)
|
|
}
|
|
}
|
|
|
|
// MARK: - Deliverability
|
|
|
|
struct AnalyticsDeliverabilitySection: View {
|
|
let summary: DeliverabilitySummary?
|
|
let error: String?
|
|
var captionTop: CGFloat = 14
|
|
|
|
private let columns = [
|
|
GridItem(.flexible(), alignment: .leading),
|
|
GridItem(.flexible(), alignment: .leading),
|
|
GridItem(.flexible(), alignment: .leading),
|
|
]
|
|
|
|
var body: some View {
|
|
AnalyticsSectionCaption("Deliverability", top: captionTop) {
|
|
if let band = summary?.band {
|
|
StatusPill(text: band, tone: AnalyticsToneMap.band(band))
|
|
}
|
|
}
|
|
if let summary {
|
|
LazyVGrid(columns: columns, alignment: .leading, spacing: 16) {
|
|
AnalyticsMiniStat(label: "Sent", value: AnalyticsFmt.count(summary.emailsSent))
|
|
AnalyticsMiniStat(
|
|
label: "Bounce rate",
|
|
value: AnalyticsFmt.rate(summary.bounceRate),
|
|
tone: (summary.bounceRate ?? 0) >= 5 ? .rose : ((summary.bounceRate ?? 0) >= 2 ? .amber : nil)
|
|
)
|
|
AnalyticsMiniStat(
|
|
label: "Complaints",
|
|
value: AnalyticsFmt.rate(summary.complaintRate),
|
|
tone: (summary.complaintRate ?? 0) >= 0.10 ? .rose : ((summary.complaintRate ?? 0) >= 0.03 ? .amber : nil)
|
|
)
|
|
AnalyticsMiniStat(
|
|
label: "Spam placement",
|
|
value: summary.spamPlacementRate.map { AnalyticsFmt.rate($0) } ?? "no samples",
|
|
tone: (summary.spamPlacementRate ?? 0) >= 20 ? .rose : ((summary.spamPlacementRate ?? 0) >= 10 ? .amber : nil)
|
|
)
|
|
AnalyticsMiniStat(label: "Suppressed", value: AnalyticsFmt.count(summary.suppressedRecipients))
|
|
AnalyticsMiniStat(label: "Unsubscribes", value: AnalyticsFmt.count(summary.unsubscribeCount))
|
|
}
|
|
.padding(.vertical, 10)
|
|
.analyticsPlainRow(separator: .hidden)
|
|
if let pending = summary.dlqPending, pending > 0 {
|
|
HStack(spacing: 8) {
|
|
Image(systemName: "clock.badge.exclamationmark")
|
|
.font(.footnote)
|
|
.foregroundStyle(WTheme.warning)
|
|
Text("\(pending) deliverability events pending retry")
|
|
.font(.footnote)
|
|
.monospacedDigit()
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
.padding(.vertical, 6)
|
|
.analyticsPlainRow(separator: .hidden)
|
|
}
|
|
} else if let error {
|
|
AnalyticsSectionError(text: error)
|
|
.analyticsPlainRow(separator: .hidden)
|
|
} else {
|
|
AnalyticsSectionLoading()
|
|
.analyticsPlainRow(separator: .hidden)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Warmup
|
|
|
|
struct AnalyticsWarmupSection: View {
|
|
let warmup: WarmupAnalytics?
|
|
let error: String?
|
|
let periodLabel: String
|
|
var captionTop: CGFloat = 14
|
|
|
|
private let columns = [
|
|
GridItem(.flexible(), alignment: .leading),
|
|
GridItem(.flexible(), alignment: .leading),
|
|
GridItem(.flexible(), alignment: .leading),
|
|
]
|
|
|
|
private var chartDays: [(id: Int, day: Date, sent: Int, target: Int)] {
|
|
(warmup?.dailyStats ?? []).enumerated().compactMap { index, day in
|
|
guard let date = AnalyticsDay.parse(day.date) else { return nil }
|
|
return (id: index, day: date, sent: day.emailsSent ?? 0, target: day.targetVolume ?? 0)
|
|
}
|
|
}
|
|
|
|
var body: some View {
|
|
AnalyticsSectionCaption("Warmup", icon: "flame", iconTone: .orange, top: captionTop)
|
|
if let summary = warmup?.summary {
|
|
LazyVGrid(columns: columns, alignment: .leading, spacing: 16) {
|
|
AnalyticsMiniStat(label: "Warmup sent", value: AnalyticsFmt.count(summary.totalSent), tone: .orange)
|
|
AnalyticsMiniStat(label: "Replies", value: AnalyticsFmt.count(summary.totalReplied))
|
|
AnalyticsMiniStat(label: "Reply rate", value: AnalyticsFmt.rate(summary.replyRate))
|
|
AnalyticsMiniStat(label: "Avg / day", value: String(format: "%.1f", summary.averageDaily ?? 0))
|
|
AnalyticsMiniStat(label: "Days active", value: "\(summary.daysActive ?? 0)")
|
|
AnalyticsMiniStat(label: "To target", value: String(format: "%.0f%%", summary.targetProgress ?? 0))
|
|
}
|
|
.padding(.vertical, 10)
|
|
.analyticsPlainRow(separator: .hidden)
|
|
|
|
if (summary.totalSent ?? 0) > 0, chartDays.count > 1 {
|
|
Chart(chartDays, id: \.id) { day in
|
|
BarMark(
|
|
x: .value("Day", day.day),
|
|
y: .value("Sent", day.sent)
|
|
)
|
|
.foregroundStyle(Tone.orange.color.opacity(0.75))
|
|
LineMark(
|
|
x: .value("Day", day.day),
|
|
y: .value("Target", day.target)
|
|
)
|
|
.lineStyle(StrokeStyle(lineWidth: 1, dash: [3, 3]))
|
|
.foregroundStyle(WTheme.paused)
|
|
}
|
|
.chartXAxis {
|
|
AxisMarks(values: .automatic(desiredCount: 4)) { _ in
|
|
AxisGridLine().foregroundStyle(Color(.separator).opacity(0.4))
|
|
AxisValueLabel(format: .dateTime.month(.abbreviated).day())
|
|
.font(.system(size: 10, weight: .medium))
|
|
.foregroundStyle(Color.secondary)
|
|
}
|
|
}
|
|
.chartYAxis {
|
|
AxisMarks(position: .trailing, values: .automatic(desiredCount: 3)) { _ in
|
|
AxisGridLine().foregroundStyle(Color(.separator).opacity(0.4))
|
|
AxisValueLabel()
|
|
.font(.system(size: 10))
|
|
.foregroundStyle(Color.secondary)
|
|
}
|
|
}
|
|
.frame(height: 130)
|
|
.padding(.vertical, 8)
|
|
.analyticsPlainRow(separator: .hidden)
|
|
} else if (summary.totalSent ?? 0) == 0 {
|
|
Text("No warmup activity in the last \(periodLabel).")
|
|
.font(.subheadline)
|
|
.foregroundStyle(.secondary)
|
|
.padding(.vertical, 4)
|
|
.analyticsPlainRow(separator: .hidden)
|
|
}
|
|
} else if let error {
|
|
AnalyticsSectionError(text: error)
|
|
.analyticsPlainRow(separator: .hidden)
|
|
} else {
|
|
AnalyticsSectionLoading()
|
|
.analyticsPlainRow(separator: .hidden)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Account health
|
|
|
|
struct AnalyticsAccountsSection: View {
|
|
let accounts: [AccountHealthRow]
|
|
let counts: AnalyticsAccountHealthCounts?
|
|
let error: String?
|
|
var isLoading: Bool = false
|
|
var captionTop: CGFloat = 14
|
|
|
|
var body: some View {
|
|
AnalyticsSectionCaption("Account health", top: captionTop) {
|
|
if let counts {
|
|
HStack(spacing: 6) {
|
|
if let healthy = counts.healthyAccounts, healthy > 0 {
|
|
StatusPill(text: "\(healthy)", tone: .emerald)
|
|
}
|
|
if let warning = counts.warningAccounts, warning > 0 {
|
|
StatusPill(text: "\(warning)", tone: .amber)
|
|
}
|
|
if let bad = counts.errorAccounts, bad > 0 {
|
|
StatusPill(text: "\(bad)", tone: .rose)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if let error, accounts.isEmpty {
|
|
AnalyticsSectionError(text: error)
|
|
.analyticsPlainRow(separator: .hidden)
|
|
} else if accounts.isEmpty, isLoading {
|
|
// First load in flight; don't flash the empty message.
|
|
AnalyticsSectionLoading()
|
|
.analyticsPlainRow(separator: .hidden)
|
|
} else if accounts.isEmpty {
|
|
Text("No mailboxes connected yet.")
|
|
.font(.subheadline)
|
|
.foregroundStyle(.secondary)
|
|
.padding(.vertical, 6)
|
|
.analyticsPlainRow(separator: .hidden)
|
|
} else {
|
|
ForEach(accounts) { account in
|
|
NavigationLink {
|
|
AnalyticsAccountDetailView(account: account)
|
|
} label: {
|
|
AnalyticsAccountRowView(account: account)
|
|
}
|
|
.analyticsPlainRow(separatorLeading: 66)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
struct AnalyticsAccountRowView: View {
|
|
let account: AccountHealthRow
|
|
|
|
private var usage: String {
|
|
guard let usage = account.dailyUsage else { return "" }
|
|
var parts: [String] = []
|
|
if let limit = usage.campaignLimit, limit > 0 {
|
|
parts.append("\(usage.campaignSent ?? 0) of \(limit) / day")
|
|
}
|
|
if let warmupLimit = usage.warmupLimit, warmupLimit > 0 {
|
|
parts.append("warmup \(usage.warmupSent ?? 0) of \(warmupLimit)")
|
|
}
|
|
return parts.joined(separator: " · ")
|
|
}
|
|
|
|
var body: some View {
|
|
HStack(spacing: 12) {
|
|
IconTile(symbol: "envelope", tone: AnalyticsToneMap.health(account.health?.status), size: 38)
|
|
VStack(alignment: .leading, spacing: 3) {
|
|
Text(account.email ?? "mailbox")
|
|
.font(.body.weight(.medium))
|
|
.lineLimit(1)
|
|
HStack(spacing: 4) {
|
|
if let provider = account.provider, !provider.isEmpty {
|
|
Text(provider)
|
|
}
|
|
if !usage.isEmpty {
|
|
if account.provider?.isEmpty == false { Text("·") }
|
|
Text(usage).monospacedDigit()
|
|
}
|
|
}
|
|
.font(.footnote)
|
|
.foregroundStyle(.secondary)
|
|
.lineLimit(1)
|
|
}
|
|
Spacer()
|
|
StatusPill(
|
|
text: account.health?.status ?? "unknown",
|
|
tone: AnalyticsToneMap.health(account.health?.status)
|
|
)
|
|
if let score = account.health?.score {
|
|
HealthRing(score: score, tone: AnalyticsToneMap.health(account.health?.status), size: 34)
|
|
}
|
|
}
|
|
.padding(.vertical, 9)
|
|
}
|
|
}
|
|
|
|
// MARK: - Top campaigns
|
|
|
|
struct AnalyticsTopCampaignsSection: View {
|
|
let campaigns: [AnalyticsTopCampaign]
|
|
|
|
private func statusTone(_ raw: String?) -> Tone {
|
|
switch raw {
|
|
case "active": .emerald
|
|
case "paused": .amber
|
|
case "draft": .slate
|
|
default: .slate
|
|
}
|
|
}
|
|
|
|
var body: some View {
|
|
if !campaigns.isEmpty {
|
|
AnalyticsSectionCaption("Top campaigns")
|
|
ForEach(campaigns) { campaign in
|
|
HStack(spacing: 12) {
|
|
IconTile(symbol: "megaphone", tone: statusTone(campaign.status), size: 36)
|
|
VStack(alignment: .leading, spacing: 3) {
|
|
Text(campaign.name ?? "Untitled campaign")
|
|
.font(.body.weight(.medium))
|
|
.lineLimit(1)
|
|
Text("\(WFormat.compact(campaign.emailsSent ?? 0)) sent · \(AnalyticsFmt.rate(campaign.openRate)) open · \(AnalyticsFmt.rate(campaign.replyRate)) reply")
|
|
.font(.footnote)
|
|
.monospacedDigit()
|
|
.foregroundStyle(.secondary)
|
|
.lineLimit(1)
|
|
}
|
|
Spacer()
|
|
StatusPill(
|
|
text: campaign.status ?? "unknown",
|
|
tone: statusTone(campaign.status),
|
|
pulsing: campaign.status == "active"
|
|
)
|
|
}
|
|
.padding(.vertical, 9)
|
|
.analyticsPlainRow(separatorLeading: 64)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Recent activity
|
|
|
|
struct AnalyticsActivitySection: View {
|
|
let items: [AnalyticsActivityItem]
|
|
|
|
private func icon(_ type: String?) -> String {
|
|
switch type {
|
|
case "sent": "paperplane"
|
|
case "opened": "envelope.open"
|
|
case "clicked": "link"
|
|
case "replied": "arrowshape.turn.up.left"
|
|
case "bounced": "exclamationmark.triangle"
|
|
default: "bolt"
|
|
}
|
|
}
|
|
|
|
private func tone(_ type: String?) -> Tone {
|
|
switch type {
|
|
case "sent": .slate
|
|
case "opened": .sky
|
|
case "clicked": .indigo
|
|
case "replied": .emerald
|
|
case "bounced": .rose
|
|
default: .slate
|
|
}
|
|
}
|
|
|
|
var body: some View {
|
|
if !items.isEmpty {
|
|
AnalyticsSectionCaption("Recent activity")
|
|
ForEach(Array(items.prefix(12).enumerated()), id: \.offset) { _, item in
|
|
HStack(spacing: 12) {
|
|
IconTile(symbol: icon(item.type), tone: tone(item.type), size: 34)
|
|
VStack(alignment: .leading, spacing: 3) {
|
|
Text(item.contactEmail ?? "unknown recipient")
|
|
.font(.body.weight(.medium))
|
|
.lineLimit(1)
|
|
Text("\(item.type ?? "event") · \(item.campaignName ?? "campaign")")
|
|
.font(.footnote)
|
|
.foregroundStyle(.secondary)
|
|
.lineLimit(1)
|
|
}
|
|
Spacer()
|
|
if let timestamp = item.timestamp {
|
|
Text(WFormat.relative(timestamp))
|
|
.font(.footnote)
|
|
.monospacedDigit()
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
.padding(.vertical, 8)
|
|
.analyticsPlainRow(separatorLeading: 62)
|
|
}
|
|
}
|
|
}
|
|
}
|