mirror of
https://github.com/wahyd4/links.git
synced 2026-08-09 05:06:16 +10:00
477 lines
15 KiB
Swift
477 lines
15 KiB
Swift
import SwiftUI
|
|
import Charts
|
|
|
|
struct AnalyticsView: View {
|
|
@EnvironmentObject var linkViewModel: LinkViewModel
|
|
@State private var selectedTimeRange: TimeRange = .oneMonth
|
|
@State private var selectedMetric: Metric = .clicks
|
|
@State private var showingExport = false
|
|
|
|
enum TimeRange: String, CaseIterable {
|
|
case oneWeek = "1w"
|
|
case oneMonth = "1m"
|
|
case threeMonths = "3m"
|
|
case sixMonths = "6m"
|
|
case oneYear = "1y"
|
|
case allTime = "all"
|
|
|
|
var displayName: String {
|
|
switch self {
|
|
case .oneWeek:
|
|
return NSLocalizedString("1_week", comment: "1 week")
|
|
case .oneMonth:
|
|
return NSLocalizedString("1_month", comment: "1 month")
|
|
case .threeMonths:
|
|
return NSLocalizedString("3_months", comment: "3 months")
|
|
case .sixMonths:
|
|
return NSLocalizedString("6_months", comment: "6 months")
|
|
case .oneYear:
|
|
return NSLocalizedString("1_year", comment: "1 year")
|
|
case .allTime:
|
|
return NSLocalizedString("all_time", comment: "All time")
|
|
}
|
|
}
|
|
|
|
var dateRange: DateInterval? {
|
|
let calendar = Calendar.current
|
|
let endDate = Date()
|
|
|
|
switch self {
|
|
case .oneWeek:
|
|
let startDate = calendar.date(byAdding: .weekOfYear, value: -1, to: endDate)!
|
|
return DateInterval(start: startDate, end: endDate)
|
|
case .oneMonth:
|
|
let startDate = calendar.date(byAdding: .month, value: -1, to: endDate)!
|
|
return DateInterval(start: startDate, end: endDate)
|
|
case .threeMonths:
|
|
let startDate = calendar.date(byAdding: .month, value: -3, to: endDate)!
|
|
return DateInterval(start: startDate, end: endDate)
|
|
case .sixMonths:
|
|
let startDate = calendar.date(byAdding: .month, value: -6, to: endDate)!
|
|
return DateInterval(start: startDate, end: endDate)
|
|
case .oneYear:
|
|
let startDate = calendar.date(byAdding: .year, value: -1, to: endDate)!
|
|
return DateInterval(start: startDate, end: endDate)
|
|
case .allTime:
|
|
return nil
|
|
}
|
|
}
|
|
}
|
|
|
|
enum Metric: String, CaseIterable {
|
|
case clicks = "clicks"
|
|
case links = "links"
|
|
case topLinks = "top_links"
|
|
|
|
var displayName: String {
|
|
switch self {
|
|
case .clicks:
|
|
return NSLocalizedString("clicks_over_time", comment: "Clicks over time")
|
|
case .links:
|
|
return NSLocalizedString("links_created", comment: "Links created")
|
|
case .topLinks:
|
|
return NSLocalizedString("top_links", comment: "Top links")
|
|
}
|
|
}
|
|
}
|
|
|
|
var analyticsData: AnalyticsData {
|
|
return AnalyticsCalculator.calculateAnalytics(
|
|
for: linkViewModel.links,
|
|
timeRange: selectedTimeRange,
|
|
metric: selectedMetric
|
|
)
|
|
}
|
|
|
|
var body: some View {
|
|
NavigationView {
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 20) {
|
|
timeRangePicker
|
|
overviewCards
|
|
metricSelector
|
|
mainChart
|
|
topLinksSection
|
|
linkTypeDistributionSection
|
|
recentActivitySection
|
|
}
|
|
.padding()
|
|
}
|
|
.navigationTitle("analytics")
|
|
.navigationBarTitleDisplayMode(.large)
|
|
.toolbar {
|
|
ToolbarItem(placement: .navigationBarTrailing) {
|
|
analyticsToolbar
|
|
}
|
|
}
|
|
}
|
|
.sheet(isPresented: $showingExport) {
|
|
ExportView(analyticsData: analyticsData)
|
|
}
|
|
}
|
|
|
|
private var timeRangePicker: some View {
|
|
VStack(alignment: .leading) {
|
|
Text("time_range")
|
|
.font(.headline)
|
|
|
|
Picker("time_range", selection: $selectedTimeRange) {
|
|
ForEach(TimeRange.allCases, id: \.self) { range in
|
|
Text(range.displayName).tag(range)
|
|
}
|
|
}
|
|
.pickerStyle(SegmentedPickerStyle())
|
|
}
|
|
}
|
|
|
|
private var overviewCards: some View {
|
|
LazyVGrid(columns: [
|
|
GridItem(.flexible()),
|
|
GridItem(.flexible())
|
|
], spacing: 16) {
|
|
OverviewCard(
|
|
title: "total_links",
|
|
value: "\(analyticsData.totalLinks)",
|
|
change: analyticsData.linksChange,
|
|
icon: "link"
|
|
)
|
|
|
|
OverviewCard(
|
|
title: "total_clicks",
|
|
value: "\(analyticsData.totalClicks)",
|
|
change: analyticsData.clicksChange,
|
|
icon: "hand.tap"
|
|
)
|
|
|
|
OverviewCard(
|
|
title: "avg_clicks_per_link",
|
|
value: String(format: "%.1f", analyticsData.averageClicksPerLink),
|
|
change: analyticsData.avgClicksChange,
|
|
icon: "chart.bar"
|
|
)
|
|
|
|
OverviewCard(
|
|
title: "active_links",
|
|
value: "\(analyticsData.activeLinks)",
|
|
change: analyticsData.activeLinksChange,
|
|
icon: "bolt"
|
|
)
|
|
}
|
|
}
|
|
|
|
private var metricSelector: some View {
|
|
VStack(alignment: .leading) {
|
|
Text("metric")
|
|
.font(.headline)
|
|
|
|
Picker("metric", selection: $selectedMetric) {
|
|
ForEach(Metric.allCases, id: \.self) { metric in
|
|
Text(metric.displayName).tag(metric)
|
|
}
|
|
}
|
|
.pickerStyle(SegmentedPickerStyle())
|
|
}
|
|
}
|
|
|
|
private var mainChart: some View {
|
|
VStack(alignment: .leading) {
|
|
Text(selectedMetric.displayName)
|
|
.font(.headline)
|
|
|
|
if analyticsData.chartData.isEmpty {
|
|
Text("no_data_available")
|
|
.font(.body)
|
|
.foregroundColor(.secondary)
|
|
.frame(height: 200)
|
|
.frame(maxWidth: .infinity)
|
|
.background(Color(.systemGray6))
|
|
.cornerRadius(12)
|
|
} else {
|
|
Chart(analyticsData.chartData) { dataPoint in
|
|
switch selectedMetric {
|
|
case .clicks:
|
|
LineMark(
|
|
x: .value("Date", dataPoint.date),
|
|
y: .value("Clicks", dataPoint.count)
|
|
)
|
|
.foregroundStyle(.blue)
|
|
case .links:
|
|
BarMark(
|
|
x: .value("Date", dataPoint.date),
|
|
y: .value("Links", dataPoint.count)
|
|
)
|
|
.foregroundStyle(.green)
|
|
case .topLinks:
|
|
BarMark(
|
|
x: .value("Link", dataPoint.label),
|
|
y: .value("Clicks", dataPoint.count)
|
|
)
|
|
.foregroundStyle(.orange)
|
|
}
|
|
}
|
|
.frame(height: 200)
|
|
.chartXAxisLabel(selectedMetric == .topLinks ? "Links" : "Date")
|
|
.chartYAxisLabel(selectedMetric == .links ? "Links Created" : "Clicks")
|
|
}
|
|
}
|
|
}
|
|
|
|
private var topLinksSection: some View {
|
|
VStack(alignment: .leading) {
|
|
Text("top_performing_links")
|
|
.font(.headline)
|
|
|
|
ForEach(analyticsData.topLinks, id: \.link.id) { linkData in
|
|
TopLinkRow(linkData: linkData)
|
|
}
|
|
}
|
|
}
|
|
|
|
private var linkTypeDistributionSection: some View {
|
|
VStack(alignment: .leading) {
|
|
Text("link_type_distribution")
|
|
.font(.headline)
|
|
|
|
if !analyticsData.linkTypeDistribution.isEmpty {
|
|
Chart(analyticsData.linkTypeDistribution, id: \.type) { data in
|
|
SectorMark(
|
|
angle: .value("Count", data.count),
|
|
innerRadius: .ratio(0.5),
|
|
angularInset: 2
|
|
)
|
|
.foregroundStyle(linkTypeColor(data.type))
|
|
}
|
|
.frame(height: 200)
|
|
}
|
|
|
|
LazyVGrid(columns: [
|
|
GridItem(.flexible()),
|
|
GridItem(.flexible()),
|
|
GridItem(.flexible())
|
|
], spacing: 12) {
|
|
ForEach(analyticsData.linkTypeDistribution, id: \.type) { data in
|
|
VStack {
|
|
Text(data.type.displayName)
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
Text("\(data.count)")
|
|
.font(.headline)
|
|
.foregroundColor(linkTypeColor(data.type))
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.padding()
|
|
.background(Color(.systemGray6))
|
|
.cornerRadius(8)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private var recentActivitySection: some View {
|
|
VStack(alignment: .leading) {
|
|
Text("recent_activity")
|
|
.font(.headline)
|
|
|
|
ForEach(analyticsData.recentActivity.prefix(10), id: \.id) { activity in
|
|
RecentActivityRow(activity: activity)
|
|
}
|
|
}
|
|
}
|
|
|
|
private var analyticsToolbar: some View {
|
|
Menu {
|
|
Button("export_data") {
|
|
showingExport = true
|
|
}
|
|
|
|
Button("refresh_data") {
|
|
linkViewModel.fetchLinks()
|
|
}
|
|
} label: {
|
|
Image(systemName: "ellipsis.circle")
|
|
}
|
|
}
|
|
|
|
private func linkTypeColor(_ linkType: LinkType) -> Color {
|
|
switch linkType {
|
|
case .link:
|
|
return .blue
|
|
case .custom:
|
|
return .green
|
|
case .template:
|
|
return .orange
|
|
}
|
|
}
|
|
}
|
|
|
|
struct OverviewCard: View {
|
|
let title: String
|
|
let value: String
|
|
let change: Double?
|
|
let icon: String
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
HStack {
|
|
Image(systemName: icon)
|
|
.foregroundColor(.blue)
|
|
Text(title)
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
Spacer()
|
|
}
|
|
|
|
Text(value)
|
|
.font(.title2)
|
|
.fontWeight(.semibold)
|
|
|
|
if let change = change {
|
|
HStack {
|
|
Image(systemName: change >= 0 ? "arrow.up.right" : "arrow.down.right")
|
|
.font(.caption2)
|
|
Text(String(format: "%.1f%%", abs(change)))
|
|
.font(.caption2)
|
|
}
|
|
.foregroundColor(change >= 0 ? .green : .red)
|
|
}
|
|
}
|
|
.padding()
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.background(Color(.systemGray6))
|
|
.cornerRadius(12)
|
|
}
|
|
}
|
|
|
|
struct TopLinkRow: View {
|
|
let linkData: TopLinkData
|
|
|
|
var body: some View {
|
|
HStack {
|
|
VStack(alignment: .leading) {
|
|
Text(linkData.link.alias)
|
|
.font(.body)
|
|
.fontWeight(.medium)
|
|
|
|
if let url = linkData.link.originalURL {
|
|
Text(url)
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
.lineLimit(1)
|
|
}
|
|
}
|
|
|
|
Spacer()
|
|
|
|
VStack(alignment: .trailing) {
|
|
Text("\(linkData.clicks)")
|
|
.font(.headline)
|
|
.foregroundColor(.blue)
|
|
|
|
Text("clicks")
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
}
|
|
.padding()
|
|
.background(Color(.systemGray6))
|
|
.cornerRadius(8)
|
|
}
|
|
}
|
|
|
|
struct RecentActivityRow: View {
|
|
let activity: RecentActivity
|
|
|
|
var body: some View {
|
|
HStack {
|
|
Image(systemName: activity.type.icon)
|
|
.foregroundColor(activity.type.color)
|
|
.frame(width: 24)
|
|
|
|
VStack(alignment: .leading) {
|
|
Text(activity.description)
|
|
.font(.body)
|
|
Text(activity.timestamp, format: .relative(presentation: .named))
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
|
|
Spacer()
|
|
}
|
|
.padding(.vertical, 4)
|
|
}
|
|
}
|
|
|
|
struct ExportView: View {
|
|
let analyticsData: AnalyticsData
|
|
@Environment(\.presentationMode) var presentationMode
|
|
@State private var exportFormat: ExportFormat = .json
|
|
@State private var includeChartData = true
|
|
@State private var includeTopLinks = true
|
|
@State private var isExporting = false
|
|
|
|
enum ExportFormat: String, CaseIterable {
|
|
case json = "JSON"
|
|
case csv = "CSV"
|
|
|
|
var displayName: String {
|
|
return rawValue
|
|
}
|
|
}
|
|
|
|
var body: some View {
|
|
NavigationView {
|
|
Form {
|
|
Section("export_options") {
|
|
Picker("format", selection: $exportFormat) {
|
|
ForEach(ExportFormat.allCases, id: \.self) { format in
|
|
Text(format.displayName).tag(format)
|
|
}
|
|
}
|
|
|
|
Toggle("include_chart_data", isOn: $includeChartData)
|
|
Toggle("include_top_links", isOn: $includeTopLinks)
|
|
}
|
|
|
|
Section("preview") {
|
|
Text("total_links: \(analyticsData.totalLinks)")
|
|
Text("total_clicks: \(analyticsData.totalClicks)")
|
|
Text("chart_data_points: \(analyticsData.chartData.count)")
|
|
Text("top_links: \(analyticsData.topLinks.count)")
|
|
}
|
|
}
|
|
.navigationTitle("export_analytics")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
ToolbarItem(placement: .navigationBarLeading) {
|
|
Button("cancel") {
|
|
presentationMode.wrappedValue.dismiss()
|
|
}
|
|
}
|
|
|
|
ToolbarItem(placement: .navigationBarTrailing) {
|
|
Button("export") {
|
|
exportData()
|
|
}
|
|
.disabled(isExporting)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func exportData() {
|
|
isExporting = true
|
|
|
|
// Implementation would depend on the specific export requirements
|
|
// For now, we'll just simulate the export
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
|
|
isExporting = false
|
|
presentationMode.wrappedValue.dismiss()
|
|
}
|
|
}
|
|
}
|
|
|
|
#Preview {
|
|
AnalyticsView()
|
|
.environmentObject(LinkViewModel())
|
|
}
|