Shared CocotteCockpitKit (views/model/LiveCockpitAPI) + iOS TabView shell (Drops/Assets/Fleet/Activity/Insights) + macOS shell. XcodeGen project.yml. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
80 lines
3.1 KiB
Swift
80 lines
3.1 KiB
Swift
import SwiftUI
|
|
|
|
// Insights — per-surface performance (analytics-dashboard slice).
|
|
|
|
public struct AnalyticsView: View {
|
|
@Environment(\.tokens) private var t
|
|
var model: CockpitModel
|
|
|
|
public init(model: CockpitModel) { self.model = model }
|
|
|
|
private var totalConversions: Int { model.metrics.reduce(0) { $0 + $1.conversions } }
|
|
private var totalImpressions: Int { model.metrics.reduce(0) { $0 + $1.impressions } }
|
|
private var maxImpressions: Int { max(1, model.metrics.map(\.impressions).max() ?? 1) }
|
|
|
|
public var body: some View {
|
|
Scroll {
|
|
VStack(alignment: .leading, spacing: t.s5) {
|
|
SectionLabel(text: "Insights · last 30 days")
|
|
HStack(spacing: 12) {
|
|
bigStat("\(totalConversions)", "unlocks / subs")
|
|
bigStat(compact(totalImpressions), "impressions")
|
|
bigStat("\(model.metrics.reduce(0) { $0 + $1.posts })", "posts")
|
|
}
|
|
SectionLabel(text: "By surface")
|
|
VStack(spacing: 10) {
|
|
ForEach(model.metrics) { m in metricRow(m) }
|
|
}
|
|
}
|
|
.padding(t.s5)
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
|
.background(t.bg)
|
|
}
|
|
|
|
private func bigStat(_ v: String, _ k: String) -> some View {
|
|
Card {
|
|
VStack(alignment: .leading, spacing: 3) {
|
|
Text(v).font(.system(size: 22, weight: .bold)).foregroundStyle(t.ink)
|
|
Text(k).font(.system(size: 10)).foregroundStyle(t.ink3)
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
}
|
|
|
|
private func metricRow(_ m: SurfaceMetric) -> some View {
|
|
Card {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
HStack {
|
|
SurfaceChip(surface: m.surface)
|
|
Spacer()
|
|
Text("\(m.conversions) conv").font(.system(size: 11, weight: .semibold)).foregroundStyle(t.accent)
|
|
}
|
|
GeometryReader { geo in
|
|
ZStack(alignment: .leading) {
|
|
Capsule().fill(t.bgElev).frame(height: 6)
|
|
Capsule().fill(t.accent)
|
|
.frame(width: geo.size.width * CGFloat(m.impressions) / CGFloat(maxImpressions), height: 6)
|
|
}
|
|
}
|
|
.frame(height: 6)
|
|
HStack(spacing: 14) {
|
|
metric("\(m.posts)", "posts")
|
|
metric(compact(m.impressions), "impr")
|
|
metric(String(format: "%.1f%%", m.engagementPct * 100), "eng")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func metric(_ v: String, _ k: String) -> some View {
|
|
HStack(spacing: 4) {
|
|
Text(v).font(.system(size: 11, weight: .semibold)).foregroundStyle(t.ink2)
|
|
Text(k).font(.system(size: 10)).foregroundStyle(t.ink3)
|
|
}
|
|
}
|
|
|
|
private func compact(_ n: Int) -> String {
|
|
n >= 1000 ? String(format: "%.1fk", Double(n) / 1000) : "\(n)"
|
|
}
|
|
}
|