diff --git a/Modules/Sources/JetpackStats/Screens/ArchiveStatsView.swift b/Modules/Sources/JetpackStats/Screens/ArchiveStatsView.swift new file mode 100644 index 000000000000..5d5ede233d7c --- /dev/null +++ b/Modules/Sources/JetpackStats/Screens/ArchiveStatsView.swift @@ -0,0 +1,127 @@ +import SwiftUI +import DesignSystem + +struct ArchiveStatsView: View { + let archiveSection: TopListItem.ArchiveSection + let dateRange: StatsDateRange + + @Environment(\.context) private var context + @Environment(\.router) private var router + @Environment(\.horizontalSizeClass) var horizontalSizeClass + + var body: some View { + ScrollView { + VStack(spacing: Constants.step3) { + headerCard + if !archiveSection.items.isEmpty { + itemsCard + } + } + .padding(.vertical, Constants.step1) + .padding(.horizontal, Constants.cardHorizontalInset(for: horizontalSizeClass)) + .dynamicTypeSize(...DynamicTypeSize.xxxLarge) + } + .background(Constants.Colors.background) + .onAppear { + context.tracker?.send(.archiveStatsScreenShown) + } + .navigationTitle(archiveSection.displayName) + .navigationBarTitleDisplayMode(.inline) + } + + var headerCard: some View { + VStack(spacing: Constants.step2) { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text(archiveSection.displayName) + .font(.headline) + .foregroundColor(.primary) + + Text(Strings.ArchiveSections.itemCount(archiveSection.items.count)) + .font(.footnote) + .foregroundColor(.secondary) + } + + Spacer() + + if let totalViews = archiveSection.metrics.views { + StandaloneMetricView(metric: .views, value: totalViews) + } + } + } + .padding(Constants.step2) + .cardStyle() + } + + var itemsCard: some View { + VStack(alignment: .leading, spacing: Constants.step2) { + Text(itemsTitle) + .font(.headline) + .foregroundColor(.primary) + .padding(.horizontal, Constants.step3) + + TopListItemsView( + data: itemsChartData, + itemLimit: archiveSection.items.count, + dateRange: dateRange + ) + } + .padding(.vertical, Constants.step2) + .cardStyle() + } + + private var itemsTitle: String { + switch archiveSection.sectionName.lowercased() { + case "author": + return Strings.ArchiveSections.author + case "other": + return Strings.ArchiveSections.other + default: + return archiveSection.displayName + } + } + + private var itemsChartData: TopListData { + return TopListData( + item: .archive, + metric: .views, + items: archiveSection.items + ) + } +} + +// MARK: - Preview + +#Preview { + NavigationView { + ArchiveStatsView( + archiveSection: .mock, + dateRange: Calendar.demo.makeDateRange(for: .thisMonth) + ) + } + .tint(Constants.Colors.jetpack) +} + +private extension TopListItem.ArchiveSection { + static let mock = TopListItem.ArchiveSection( + sectionName: "author", + items: [ + TopListItem.ArchiveItem( + href: "/author/john-doe/", + value: "John Doe", + metrics: SiteMetricsSet(views: 5000) + ), + TopListItem.ArchiveItem( + href: "/author/jane-smith/", + value: "Jane Smith", + metrics: SiteMetricsSet(views: 4200) + ), + TopListItem.ArchiveItem( + href: "/author/mike-jones/", + value: "Mike Jones", + metrics: SiteMetricsSet(views: 3100) + ) + ], + metrics: SiteMetricsSet(views: 12300) + ) +} diff --git a/Modules/Sources/JetpackStats/Screens/AuthorStatsView.swift b/Modules/Sources/JetpackStats/Screens/AuthorStatsView.swift new file mode 100644 index 000000000000..7e92342f1464 --- /dev/null +++ b/Modules/Sources/JetpackStats/Screens/AuthorStatsView.swift @@ -0,0 +1,217 @@ +import SwiftUI +import DesignSystem +@preconcurrency import WordPressKit + +struct AuthorStatsView: View { + let author: TopListItem.Author + + @State private var dateRange: StatsDateRange + + @StateObject private var viewModel: TopListViewModel + + @Environment(\.context) private var context + @Environment(\.horizontalSizeClass) var horizontalSizeClass + + @ScaledMetric private var avatarSize = 60 + + init(author: TopListItem.Author, initialDateRange: StatsDateRange? = nil, context: StatsContext) { + self.author = author + + let range = initialDateRange ?? context.calendar.makeDateRange(for: .last30Days) + self._dateRange = State(initialValue: range) + + let configuration = TopListCardConfiguration( + item: .postsAndPages, + metric: .views + ) + self._viewModel = StateObject(wrappedValue: TopListViewModel( + configuration: configuration, + dateRange: range, + service: context.service, + tracker: context.tracker, + items: [.postsAndPages], + filter: .author(userId: author.userId) + )) + } + + var body: some View { + ScrollView { + VStack(spacing: Constants.step3) { + headerView + .cardStyle() + + TopListCard( + viewModel: viewModel, + itemLimit: 6, + reserveSpace: false, + showMoreInline: true + ) + } + .padding(.vertical, Constants.step1) + .padding(.horizontal, Constants.cardHorizontalInset(for: horizontalSizeClass)) + .frame(maxWidth: horizontalSizeClass == .regular ? Constants.maxHortizontalWidth : .infinity) + .frame(maxWidth: .infinity) + .dynamicTypeSize(...DynamicTypeSize.xxxLarge) + } + .background(Constants.Colors.background) + .animation(.spring, value: viewModel.data.map(ObjectIdentifier.init)) + .onChange(of: dateRange) { newRange in + viewModel.dateRange = newRange + } + .onAppear { + context.tracker?.send(.authorStatsScreenShown) + } + .navigationTitle(Strings.AuthorDetails.title) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + if horizontalSizeClass == .regular { + ToolbarItemGroup(placement: .navigationBarTrailing) { + StatsDateRangeButtons(dateRange: $dateRange) + } + } + } + .safeAreaInset(edge: .bottom) { + if horizontalSizeClass == .compact { + LegacyFloatingDateControl(dateRange: $dateRange) + } + } + } + + private var headerView: some View { + VStack(spacing: Constants.step3) { + HStack(spacing: Constants.step3) { + // Avatar + AvatarView( + name: author.name, + imageURL: author.avatarURL, + size: avatarSize + ) + .overlay( + Circle() + .stroke(Color(.opaqueSeparator), lineWidth: 1) + ) + + // Name and metrics + VStack(alignment: .leading, spacing: Constants.step1) { + Text(author.name) + .font(.title3) + .fontWeight(.semibold) + .foregroundColor(.primary) + + // Views for period + if let data = calculatePeriodViews() { + makeViewsView(current: data.current, previous: data.previous) + } else { + makeViewsView(current: 1000, previous: 500) + .redacted(reason: .placeholder) + } + } + + Spacer() + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(Constants.step3) + } + + private func makeViewsView(current: Int, previous: Int?) -> some View { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 4) { + Image(systemName: SiteMetric.views.systemImage) + .font(.caption.weight(.medium)) + .foregroundColor(.secondary) + + Text(SiteMetric.views.localizedTitle) + .font(.caption.weight(.medium)) + .foregroundColor(.secondary) + .textCase(.uppercase) + } + + HStack(spacing: Constants.step2) { + Text(StatsValueFormatter.formatNumber(current, onlyLarge: true)) + .font(Font.make(.recoleta, textStyle: .title2, weight: .medium)) + .foregroundColor(.primary) + .contentTransition(.numericText()) + + // Trend badge + if let previous { + let trend = TrendViewModel( + currentValue: current, + previousValue: previous, + metric: .views + ) + + HStack(spacing: 4) { + Image(systemName: trend.systemImage) + .font(.caption2.weight(.semibold)) + Text(trend.formattedPercentage) + .font(.caption.weight(.medium)) + .contentTransition(.numericText()) + } + .foregroundColor(trend.sentiment.foregroundColor) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(trend.sentiment.backgroundColor) + .clipShape(Capsule()) + } + } + } + } + + private func calculatePeriodViews() -> (current: Int, previous: Int?)? { + guard let data = viewModel.data else { return nil } + + // Sum up views from all posts in the current period + let currentViews = data.items.compactMap { item in + (item as? TopListItem.Post)?.metrics.views + }.reduce(0, +) + + // Calculate previous period views if available + var previousViews: Int? + if !data.previousItems.isEmpty { + previousViews = data.previousItems.values.compactMap { item in + (item as? TopListItem.Post)?.metrics.views + }.reduce(0, +) + } + + return (current: currentViews, previous: previousViews) + } +} + +#Preview { + NavigationStack { + AuthorStatsView( + author: TopListItem.Author( + name: "Alex Johnson", + userId: "1", + role: nil, + metrics: SiteMetricsSet( + views: 5000 + ), + avatarURL: nil, + posts: [ + TopListItem.Post( + title: "The Future of Technology: AI and Machine Learning", + postID: "1", + postURL: URL(string: "https://example.com/post1"), + date: Date(), + type: "post", + author: "Alex Johnson", + metrics: SiteMetricsSet(views: 1250) + ), + TopListItem.Post( + title: "Understanding Climate Change", + postID: "2", + postURL: URL(string: "https://example.com/post2"), + date: Date(), + type: "post", + author: "Alex Johnson", + metrics: SiteMetricsSet(views: 980) + ) + ] + ), + context: StatsContext.demo + ) + } + .environment(\.context, StatsContext.demo) +} diff --git a/Modules/Sources/JetpackStats/Screens/ExternalLinkStatsView.swift b/Modules/Sources/JetpackStats/Screens/ExternalLinkStatsView.swift new file mode 100644 index 000000000000..0c7b8051d41a --- /dev/null +++ b/Modules/Sources/JetpackStats/Screens/ExternalLinkStatsView.swift @@ -0,0 +1,181 @@ +import SwiftUI +import WordPressUI +import DesignSystem + +struct ExternalLinkStatsView: View { + let externalLink: TopListItem.ExternalLink + let dateRange: StatsDateRange + + private let imageSize: CGFloat = 28 + + @Environment(\.context) private var context + @Environment(\.router) private var router + @Environment(\.horizontalSizeClass) var horizontalSizeClass + + var body: some View { + ScrollView { + VStack(spacing: Constants.step3) { + headerCard + .dynamicTypeSize(...DynamicTypeSize.xLarge) + if !externalLink.children.isEmpty { + childrenCard + } + } + .padding(.vertical, Constants.step1) + .padding(.horizontal, Constants.cardHorizontalInset(for: horizontalSizeClass)) + .frame(maxWidth: horizontalSizeClass == .regular ? Constants.maxHortizontalWidth : .infinity) + .frame(maxWidth: .infinity) + .dynamicTypeSize(...DynamicTypeSize.xxxLarge) + } + .background(Constants.Colors.background) + .onAppear { + context.tracker?.send(.externalLinkStatsScreenShown) + } + .navigationTitle(Strings.ExternalLinkDetails.title) + .navigationBarTitleDisplayMode(.inline) + } + + private var placeholderIcon: some View { + Image(systemName: "link.circle.fill") + .resizable() + .aspectRatio(contentMode: .fit) + .foregroundColor(.secondary.opacity(0.5)) + } + + var headerCard: some View { + VStack(spacing: Constants.step2) { + externalLinkInfoRow + if let url = URL(string: externalLink.url) { + Divider() + openLinkButton(url: url) + } + } + .padding(Constants.step2) + .cardStyle() + } + + var externalLinkInfoRow: some View { + HStack(spacing: Constants.step1) { + linkIcon + linkDetails + Spacer() + viewsCount + } + } + + @ViewBuilder + var linkIcon: some View { + if let url = URL(string: externalLink.url), + let host = url.host, + let iconURL = URL(string: "https://www.google.com/s2/favicons?domain=\(host)&sz=128") { + CachedAsyncImage(url: iconURL) { image in + image + .resizable() + .aspectRatio(contentMode: .fit) + } placeholder: { + placeholderIcon + } + .frame(width: imageSize, height: imageSize) + } else { + placeholderIcon + .frame(width: imageSize, height: imageSize) + } + } + + var linkDetails: some View { + VStack(alignment: .leading, spacing: 2) { + Text(externalLink.title ?? externalLink.url) + .font(.headline) + .foregroundColor(.primary) + .lineLimit(2) + + if let url = URL(string: externalLink.url), let host = url.host { + Text(host) + .font(.subheadline) + .foregroundColor(.secondary) + } + } + } + + @ViewBuilder + var viewsCount: some View { + if let views = externalLink.metrics.views { + StandaloneMetricView(metric: .views, value: views) + } + } + + func openLinkButton(url: URL) -> some View { + Link(destination: url) { + Label(Strings.ExternalLinkDetails.openLink, systemImage: "arrow.up.right.square") + .foregroundColor(Constants.Colors.blue) + .frame(maxWidth: .infinity) + } + .buttonStyle(.plain) + } + + var childrenCard: some View { + VStack(alignment: .leading, spacing: Constants.step2) { + Text(Strings.ExternalLinkDetails.childLinks) + .font(.headline) + .foregroundColor(.primary) + .padding(.horizontal, Constants.step3) + + TopListItemsView( + data: childrenChartData, + itemLimit: externalLink.children.count, + dateRange: dateRange + ) + } + .padding(.vertical, Constants.step2) + .cardStyle() + } + + private var childrenChartData: TopListData { + return TopListData( + item: .externalLinks, + metric: .views, + items: externalLink.children + ) + } +} + +// MARK: - Preview + +#Preview { + NavigationView { + ExternalLinkStatsView( + externalLink: .mock, + dateRange: Calendar.demo.makeDateRange(for: .thisYear) + ) + } + .navigationViewStyle(.stack) + .tint(Constants.Colors.jetpack) +} + +private extension TopListItem.ExternalLink { + static let mock = TopListItem.ExternalLink( + url: "https://developer.apple.com", + title: "Apple Developer", + children: [ + TopListItem.ExternalLink( + url: "https://developer.apple.com/documentation/swiftui", + title: "SwiftUI Documentation", + children: [], + metrics: SiteMetricsSet(views: 850) + ), + TopListItem.ExternalLink( + url: "https://developer.apple.com/documentation/uikit", + title: "UIKit Documentation", + children: [], + metrics: SiteMetricsSet(views: 750) + ), + TopListItem.ExternalLink( + url: "https://developer.apple.com/xcode", + title: "Xcode", + children: [], + metrics: SiteMetricsSet(views: 600) + ) + ], + metrics: SiteMetricsSet(views: 2200) + ) +} diff --git a/Modules/Sources/JetpackStats/Screens/PostStatsView.swift b/Modules/Sources/JetpackStats/Screens/PostStatsView.swift new file mode 100644 index 000000000000..a90d0a37dbd5 --- /dev/null +++ b/Modules/Sources/JetpackStats/Screens/PostStatsView.swift @@ -0,0 +1,634 @@ +import SwiftUI +import UIKit +@preconcurrency import WordPressKit + +public struct PostStatsView: View { + public struct PostInfo { + public let title: String + public let postID: String + public let postURL: URL? + public let date: Date? + + public init(title: String, postID: String, postURL: URL? = nil, date: Date? = nil) { + self.title = title + self.postID = postID + self.postURL = postURL + self.date = date + } + + init(from post: TopListItem.Post) { + self.title = post.title + self.postID = post.postID ?? "" + self.postURL = post.postURL + self.date = post.date + } + } + + private let post: PostInfo + private let initialDateRange: StatsDateRange? + + @State private var data: PostDetailsData? + @State private var likes: PostLikesData? + @State private var emailData: StatsEmailOpensData? + @State private var isLoadingDetails = true + @State private var isLoadingLikes = true + @State private var isLoadingEmailData = true + @State private var error: Error? + + @AppStorage("JetpackStatsPostDetailsChartType") private var chartType: ChartType = .columns + + @Environment(\.context) private var context + @Environment(\.router) private var router + @Environment(\.horizontalSizeClass) var horizontalSizeClass + + init(post: TopListItem.Post, dateRange: StatsDateRange) { + self.post = PostInfo(from: post) + self.initialDateRange = dateRange + } + + init(post: PostInfo, dateRange: StatsDateRange) { + self.post = post + self.initialDateRange = dateRange + } + + public static func make(post: PostInfo, context: StatsContext, router: StatsRouter) -> some View { + PostStatsView( + post: post, + dateRange: context.calendar.makeDateRange(for: .last30Days) + ) + .environment(\.context, context) + .environment(\.router, router) + } + + public var body: some View { + ScrollView { + VStack(spacing: Constants.step3) { + contents + } + .padding(.vertical, Constants.step1) + .padding(.horizontal, Constants.cardHorizontalInset(for: horizontalSizeClass)) + .frame(maxWidth: horizontalSizeClass == .regular ? Constants.maxHortizontalWidth : .infinity) + .frame(maxWidth: .infinity) + } + .background(Constants.Colors.background) + .navigationTitle(Strings.PostDetails.title) + .navigationBarTitleDisplayMode(.inline) + .onAppear { + context.tracker?.send(.postDetailsScreenShown) + } + .task { + await loadPostDetails() + } + } + + @ViewBuilder + private var contents: some View { + headerView + .cardStyle() + .dynamicTypeSize(...DynamicTypeSize.xxLarge) + .accessibilityElement(children: .contain) + + if let data { + makeChartView(dataPoints: data.dataPoints) + } else if isLoadingDetails { + makeChartView(dataPoints: mockDataPoints) + .redacted(reason: .placeholder) + } + + emailsMetricsView + .dynamicTypeSize(...DynamicTypeSize.xxLarge) + + if horizontalSizeClass == .regular { + HStack(alignment: .top, spacing: Constants.step3) { + weeklyTrendsCard + .frame(maxWidth: .infinity) + yearlyTrendsCard + .frame(maxWidth: .infinity) + } + } else { + weeklyTrendsCard + yearlyTrendsCard + } + } + + @ViewBuilder + private var weeklyTrendsCard: some View { + if let data { + VStack(alignment: .leading, spacing: Constants.step2) { + StatsCardTitleView(title: Strings.PostDetails.recentWeeks) + WeeklyTrendsView(viewModel: data.weeklyTrends) + } + .accessibilityElement(children: .contain) + .accessibilityLabel(Strings.Accessibility.cardTitle(Strings.PostDetails.recentWeeks)) + .padding(Constants.step2) + .cardStyle() + } + } + + @ViewBuilder + private var yearlyTrendsCard: some View { + if let data { + VStack(alignment: .leading, spacing: Constants.step2) { + StatsCardTitleView(title: Strings.PostDetails.monthlyActivity) + YearlyTrendsView(viewModel: data.yearlyTrends) + } + .padding(Constants.step2) + .cardStyle() + } + } + + private func makeChartView(dataPoints: [DataPoint]) -> some View { + StandaloneChartCard( + dataPoints: dataPoints, + metric: .views, + initialDateRange: dateRange, + chartType: $chartType, + configuration: .init(minimumGranularity: .day) + ) + .cardStyle() + } + + private var headerView: some View { + VStack(alignment: .leading, spacing: Constants.step2) { + postDetailsView + + if let likes { + Button { + navigateToLikesList() + } label: { + PostLikesStripView(likes: likes) + .contentShape(Rectangle()) + } + } else if isLoadingLikes { + PostLikesStripView(likes: .mock) + .redacted(reason: .placeholder) + } + + Divider() + + if let error { + SimpleErrorView(error: error) + .frame(minHeight: 210) + } else { + PostStatsMetricsStripView( + metrics: metrics ?? .mock, + onLikesTapped: navigateToLikesList, + onCommentsTapped: navigateToCommentsList + ) + // Preserving view identity for better animations + .redacted(reason: metrics == nil ? .placeholder : []) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(EdgeInsets(top: Constants.step2, leading: Constants.step2, bottom: Constants.step1, trailing: Constants.step2)) + } + + @ViewBuilder + private var emailsMetricsView: some View { + // Email Metrics Card + if let emailData { + VStack(alignment: .leading, spacing: Constants.step2) { + StatsCardTitleView(title: Strings.PostDetails.emailMetrics) + PostStatsEmailMetricsView(emailData: emailData) + } + .padding(Constants.cardPadding) + .cardStyle() + } else if isLoadingEmailData { + VStack(alignment: .leading, spacing: Constants.step2) { + StatsCardTitleView(title: Strings.PostDetails.emailMetrics) + PostStatsEmailMetricsView(emailData: StatsEmailOpensData( + totalSends: 1000, + uniqueOpens: 500, + totalOpens: 750, + opensRate: 0.5 + )) + } + .padding(Constants.cardPadding) + .cardStyle() + .redacted(reason: .placeholder) + } + } + + private var postDetailsView: some View { + VStack(alignment: .leading, spacing: 4) { + Text(post.title) + .font(.title3.weight(.semibold)) + .multilineTextAlignment(.leading) + .lineLimit(3) + + if let dateGMT = post.date ?? data?.post?.dateGMT { + HStack(spacing: 6) { + Text(Strings.PostDetails.published(formatPublishedDate(dateGMT))) + .font(.subheadline) + .foregroundColor(.secondary) + + // Permalink button + if let postURL = post.postURL ?? data?.post?.permalink.flatMap(URL.init) { + Link(destination: postURL) { + Image(systemName: "link") + .font(.footnote) + .foregroundColor(Constants.Colors.blue) + } + } + } + } + } + } + + // MARK: - Data + + private var dateRange: StatsDateRange { + initialDateRange ?? context.calendar.makeDateRange(for: .last30Days) + } + + private var metrics: SiteMetricsSet? { + guard let data else { + return nil + } + return SiteMetricsSet( + views: data.views, + likes: likes?.totalCount, + comments: data.comments + ) + } + + private func formatPublishedDate(_ date: Date) -> String { + let formatter = DateFormatter() + formatter.dateStyle = .medium + formatter.timeStyle = .none + formatter.timeZone = context.timeZone + return formatter.string(from: date) + } + + private func loadPostDetails() async { + guard let postID = Int(post.postID) else { + self.error = URLError(.unknown, userInfo: [NSLocalizedDescriptionKey: Strings.Errors.generic]) + self.isLoadingDetails = false + return + } + + // Load likes in parallel and ignore errors + Task { + do { + self.likes = try await context.service.getPostLikes(for: postID, count: 10) + } catch { + // Do nothing + } + self.isLoadingLikes = false + } + + // Load email data in parallel and ignore errors + Task { + do { + self.emailData = try await context.service.getEmailOpens(for: postID) + } catch { + // Do nothing + } + self.isLoadingEmailData = false + } + + do { + let details = try await context.service.getPostDetails(for: postID) + let data = await makeData(with: details, calendar: context.calendar) + withAnimation(.spring) { + self.data = data + self.isLoadingDetails = false + } + } catch { + withAnimation(.spring) { + self.error = error + self.isLoadingDetails = false + } + } + } + + private var mockDataPoints: [DataPoint] { + ChartData.mock( + metric: .views, + granularity: dateRange.dateInterval.preferredGranularity, + range: dateRange + ).currentData + } + + private func navigateToLikesList() { + guard let postID = Int(post.postID) else { + return + } + router.navigateToLikesList( + siteID: context.siteID, + postID: postID, + totalLikes: likes?.totalCount ?? 0 + ) + } + + private func navigateToCommentsList() { + guard let postID = Int(post.postID) else { + return + } + router.navigateToCommentsList(siteID: context.siteID, postID: postID) + } +} + +private struct PostDetailsData: @unchecked Sendable { + let post: StatsPostDetails.Post? + let views: Int? + let comments: Int? + let dataPoints: [DataPoint] + let weeklyTrends: WeeklyTrendsViewModel + let yearlyTrends: YearlyTrendsViewModel +} + +private func makeData(with details: StatsPostDetails, calendar: Calendar) async -> PostDetailsData { + let dataPoints: [DataPoint] = details.data.compactMap { postView in + guard let date = calendar.date(from: postView.date) else { return nil } + return DataPoint(date: date, value: postView.viewsCount) + } + + let weeklyTrends = WeeklyTrendsViewModel(dataPoints: dataPoints, calendar: calendar) + + let yearlyTrends = YearlyTrendsViewModel(dataPoints: dataPoints, calendar: calendar) + + return PostDetailsData( + post: details.post, + views: details.totalViewsCount, + comments: details.post?.commentCount.flatMap { Int($0) }, + dataPoints: dataPoints, + weeklyTrends: weeklyTrends, + yearlyTrends: yearlyTrends + ) +} + +private struct PostStatsMetricsStripView: View { + let metrics: SiteMetricsSet + let onLikesTapped: (() -> Void)? + let onCommentsTapped: (() -> Void)? + + var body: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: Constants.step2) { + ForEach([SiteMetric.views, .likes, .comments]) { metric in + MetricView(metric: metric, value: metrics[metric]) + .contentShape(Rectangle()) + .onTapGesture { + switch metric { + case .likes: + onLikesTapped?() + case .comments: + onCommentsTapped?() + default: + break + } + } + } + } + } + } + + struct MetricView: View { + let metric: SiteMetric + let value: Int? + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + HStack(spacing: 2) { + Image(systemName: metric.systemImage) + .font(.caption2.weight(.medium)) + .foregroundColor(.secondary) + + Text(metric.localizedTitle.uppercased()) + .font(.caption.weight(.medium)) + .foregroundColor(.secondary) + + if metric != .views && (value ?? 0) > 0 { + Image(systemName: "chevron.forward") + .font(.caption2.weight(.bold)) + .scaleEffect(x: 0.7, y: 0.7) + .foregroundStyle(.secondary) + .padding(.leading, 1) + } + } + + HStack { + Text(formattedValue) + .contentTransition(.numericText()) + .animation(.spring, value: value) + .font(Font.make(.recoleta, textStyle: .title, weight: .medium)) + .foregroundColor(.primary) + } + } + .lineLimit(1) + .frame(minWidth: 78, alignment: .leading) + } + + var formattedValue: String { + guard let value else { + return "–" + } + return StatsValueFormatter(metric: metric).format(value: value) + } + } +} + +private struct PostStatsEmailMetricsView: View { + let emailData: StatsEmailOpensData + + @Environment(\.horizontalSizeClass) var horizontalSizeClass + + var body: some View { + if horizontalSizeClass == .regular { + HStack(spacing: Constants.step4) { + ForEach(emailMetrics) { metric in + MetricView(metric: metric) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + } else { + VStack(alignment: .leading, spacing: Constants.step2) { + HStack(spacing: Constants.step2) { + ForEach(emailMetrics.prefix(2)) { metric in + MetricView(metric: metric) + } + } + HStack(spacing: Constants.step2) { + ForEach(emailMetrics.suffix(2)) { metric in + MetricView(metric: metric) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } + } + + private var emailMetrics: [EmailMetric] { + [ + EmailMetric( + id: "sends", + title: Strings.PostDetails.emailsSent.uppercased(), + value: emailData.totalSends ?? 0, + icon: "envelope" + ), + EmailMetric( + id: "rate", + title: Strings.PostDetails.openRate.uppercased(), + value: nil, + rate: emailData.opensRate, + icon: "percent" + ), + EmailMetric( + id: "unique", + title: Strings.PostDetails.uniqueOpens.uppercased(), + value: emailData.uniqueOpens ?? 0, + icon: "envelope.open" + ), + EmailMetric( + id: "total", + title: Strings.PostDetails.totalOpens.uppercased(), + value: emailData.totalOpens ?? 0, + icon: "envelope.open.fill" + ) + ] + } + + struct EmailMetric: Identifiable { + let id: String + let title: String + let value: Int? + var rate: Double? + let icon: String + } + + struct MetricView: View { + let metric: EmailMetric + + @ScaledMetric private var prererredWidth = 128 + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + HStack(alignment: .center, spacing: 2) { + Image(systemName: metric.icon) + .font(.caption2.weight(.medium)) + .foregroundColor(.secondary) + + Text(metric.title) + .font(.caption.weight(.medium)) + .foregroundColor(.secondary) + } + HStack { + Text(formattedValue) + .contentTransition(.numericText()) + .font(Font.make(.recoleta, textStyle: .title, weight: .medium)) + .foregroundColor(.primary) + } + } + .lineLimit(1) + .frame(minWidth: prererredWidth, alignment: .leading) + } + + var formattedValue: String { + if let rate = metric.rate { + return "\(Int(rate * 100))%" + } else if let value = metric.value { + return value.formatted(.number.notation(.compactName)) + } else { + return "–" + } + } + } +} + +private struct PostLikesStripView: View { + let likes: PostLikesData + + private let avatarSize: CGFloat = 28 + private let maxVisibleAvatars = 6 + + var body: some View { + if likes.users.isEmpty { + emptyStateView + } else { + HStack { + avatars + Spacer() + viewMore + } + } + } + + // Overlapping avatars + private var avatars: some View { + HStack(spacing: -8) { + ForEach(likes.users.prefix(maxVisibleAvatars)) { user in + AvatarView(name: user.name, imageURL: user.avatarURL, size: avatarSize, backgroundColor: Color(.secondarySystemBackground)) + .overlay( + Circle() + .stroke(Color(UIColor.systemBackground), lineWidth: 1) + ) + } + + // Show additional count if there are more users + if likes.totalCount > maxVisibleAvatars { + Text("+\((likes.totalCount - maxVisibleAvatars).formatted(.number.notation(.compactName)))") + .font(.caption2.weight(.medium)) + .foregroundColor(.primary.opacity(0.8)) + .padding(.horizontal, 4) + .frame(height: avatarSize + 2) + .frame(minWidth: avatarSize + 2) + .background { + RoundedRectangle(cornerRadius: 20) + .fill(Color(UIColor.secondarySystemBackground)) + } + .overlay( + RoundedRectangle(cornerRadius: 20) + .stroke(Color(UIColor.systemBackground), lineWidth: 1) + ) + } + } + } + + private var viewMore: some View { + HStack(spacing: 4) { + Text(Strings.PostDetails.likesCount(likes.totalCount)) + .font(.subheadline) + .foregroundColor(.primary) + + Image(systemName: "chevron.right") + .font(.caption.weight(.semibold)) + .foregroundColor(.secondary.opacity(0.66)) + } + } + + private var emptyStateView: some View { + HStack { + HStack(spacing: -8) { + ForEach(0...2, id: \.self) { _ in + Circle() + .frame(width: avatarSize, height: avatarSize) + .foregroundStyle(Color(.secondarySystemBackground)) + .overlay( + Circle() + .stroke(Color(UIColor.systemBackground), lineWidth: 1) + ) + } + } + Text(Strings.PostDetails.noLikesYet) + .font(.subheadline) + .foregroundColor(.secondary) + } + .lineLimit(1) + } +} + +#Preview { + NavigationStack { + PostStatsView( + post: .init( + title: "Matter Smart Home Protocol Still Doesn't Matter: A Year Later", + postID: "12345", + postURL: URL(string: "example.com"), + date: .now + ), + dateRange: Calendar.demo.makeDateRange(for: .last30Days) + ) + .environment(\.context, StatsContext.demo) + } +} diff --git a/Modules/Sources/JetpackStats/Screens/ReferrerStatsView.swift b/Modules/Sources/JetpackStats/Screens/ReferrerStatsView.swift new file mode 100644 index 000000000000..eef611ab9100 --- /dev/null +++ b/Modules/Sources/JetpackStats/Screens/ReferrerStatsView.swift @@ -0,0 +1,229 @@ +import SwiftUI +import WordPressUI +import DesignSystem + +struct ReferrerStatsView: View { + let referrer: TopListItem.Referrer + let dateRange: StatsDateRange + + private let imageSize: CGFloat = 28 + + @Environment(\.context) private var context + @Environment(\.router) private var router + @Environment(\.horizontalSizeClass) var horizontalSizeClass + @State private var isMarkingAsSpam = false + @State private var showErrorAlert = false + @State private var errorMessage = "" + @State private var isMarkedAsSpam = false + + var body: some View { + ScrollView { + VStack(spacing: Constants.step3) { + headerCard + .dynamicTypeSize(...DynamicTypeSize.xLarge) + if !referrer.children.isEmpty { + childrenCard + } + } + .padding(.vertical, Constants.step1) + .padding(.horizontal, Constants.cardHorizontalInset(for: horizontalSizeClass)) + .frame(maxWidth: horizontalSizeClass == .regular ? Constants.maxHortizontalWidth : .infinity) + .frame(maxWidth: .infinity) + .dynamicTypeSize(...DynamicTypeSize.xxxLarge) + } + .background(Constants.Colors.background) + .onAppear { + context.tracker?.send(.referrerStatsScreenShown) + } + .navigationTitle(Strings.ReferrerDetails.title) + .navigationBarTitleDisplayMode(.inline) + .alert(Strings.ReferrerDetails.errorAlertTitle, isPresented: $showErrorAlert) { + Button(Strings.Buttons.ok, role: .cancel) { } + } message: { + Text(errorMessage) + } + } + + private var placeholderIcon: some View { + Image(systemName: "link.circle.fill") + .resizable() + .aspectRatio(contentMode: .fit) + .foregroundColor(.secondary.opacity(0.5)) + } + + var headerCard: some View { + VStack(spacing: Constants.step2) { + referrerInfoRow + Divider() + markAsSpamButton + } + .padding(Constants.step2) + .cardStyle() + } + + var referrerInfoRow: some View { + HStack(spacing: Constants.step1) { + referrerIcon + referrerDetails + Spacer() + viewsCount + } + } + + @ViewBuilder + var referrerIcon: some View { + if let iconURL = referrer.iconURL { + CachedAsyncImage(url: iconURL) { image in + image + .resizable() + .aspectRatio(contentMode: .fit) + } placeholder: { + placeholderIcon + } + .frame(width: imageSize, height: imageSize) + } else { + placeholderIcon + .frame(width: imageSize, height: imageSize) + } + } + + var referrerDetails: some View { + VStack(alignment: .leading, spacing: 2) { + Text(referrer.name) + .font(.headline) + .foregroundColor(.primary) + + if let domain = referrer.domain, let url = URL(string: "https://\(domain)") { + Link(domain, destination: url) + .font(.subheadline) + .tint(Constants.Colors.blue) + } else if let domain = referrer.domain { + Text(domain) + .font(.subheadline) + .foregroundColor(.secondary) + } + } + } + + @ViewBuilder + var viewsCount: some View { + if let views = referrer.metrics.views { + StandaloneMetricView(metric: .views, value: views) + } + } + + @ViewBuilder + var markAsSpamButton: some View { + if isMarkedAsSpam { + HStack { + Image(systemName: "checkmark.shield.fill") + .font(.subheadline) + Text(Strings.ReferrerDetails.markedAsSpam) + .font(.subheadline.weight(.medium)) + } + .foregroundColor(.secondary) + .frame(maxWidth: .infinity) + } else if isMarkingAsSpam { + ProgressView() + .frame(maxWidth: .infinity) + } else { + Button(role: .destructive) { + Task { + await markAsSpam() + } + } label: { + Label(Strings.ReferrerDetails.markAsSpam, systemImage: "exclamationmark.triangle") + .foregroundColor(.red) + .frame(maxWidth: .infinity) + } + .buttonStyle(.plain) + } + } + + var childrenCard: some View { + VStack(alignment: .leading, spacing: Constants.step2) { + Text(Strings.ReferrerDetails.referralSources) + .font(.headline) + .foregroundColor(.primary) + .padding(.horizontal, Constants.step3) + + TopListItemsView( + data: childrenChartData, + itemLimit: referrer.children.count, + dateRange: dateRange + ) + } + .padding(.vertical, Constants.step2) + .cardStyle() + } + + private var childrenChartData: TopListData { + return TopListData( + item: .referrers, + metric: .views, + items: referrer.children + ) + } + + private func markAsSpam() async { + guard let domain = referrer.domain else { return } + + isMarkingAsSpam = true + + do { + try await context.service.toggleSpamState(for: domain, currentValue: isMarkedAsSpam) + // Update local state to reflect the change + isMarkedAsSpam = true + } catch { + errorMessage = error.localizedDescription.isEmpty ? Strings.ReferrerDetails.markAsSpamError : error.localizedDescription + showErrorAlert = true + } + + isMarkingAsSpam = false + } +} + +// MARK: - Preview + +#Preview { + NavigationView { + ReferrerStatsView( + referrer: .mock, + dateRange: Calendar.demo.makeDateRange(for: .thisYear) + ) + } + .navigationViewStyle(.stack) + .tint(Constants.Colors.jetpack) +} + +private extension TopListItem.Referrer { + static let mock = TopListItem.Referrer( + name: "Google Search", + domain: "google.com", + iconURL: URL(string: "https://www.google.com/favicon.ico"), + children: [ + TopListItem.Referrer( + name: "wordpress development tutorial", + domain: "google.com", + iconURL: URL(string: "https://www.google.com/favicon.ico"), + children: [], + metrics: SiteMetricsSet(views: 850) + ), + TopListItem.Referrer( + name: "swift programming blog", + domain: "google.com", + iconURL: URL(string: "https://www.google.com/favicon.ico"), + children: [], + metrics: SiteMetricsSet(views: 750) + ), + TopListItem.Referrer( + name: "ios app development best practices", + domain: "google.com", + iconURL: URL(string: "https://www.google.com/favicon.ico"), + children: [], + metrics: SiteMetricsSet(views: 600) + ) + ], + metrics: SiteMetricsSet(views: 2200) + ) +} diff --git a/Modules/Sources/JetpackStats/Views/Heatmap/HeatmapView.swift b/Modules/Sources/JetpackStats/Views/Heatmap/HeatmapView.swift new file mode 100644 index 000000000000..d63fa211f57e --- /dev/null +++ b/Modules/Sources/JetpackStats/Views/Heatmap/HeatmapView.swift @@ -0,0 +1,94 @@ +import SwiftUI + +// MARK: - HeatmapCellView + +/// A reusable heatmap cell view that displays a colored rectangle with an optional value label. +/// Used in both WeeklyTrendsView and YearlyTrendsView for consistent visual representation. +struct HeatmapCellView: View { + let value: Int + let formattedValue: String + let color: Color + let intensity: Double + + @Environment(\.colorScheme) var colorScheme + + /// Creates a heatmap cell with automatic formatting and color calculation based on metric + init( + value: Int, + metric: SiteMetric, + maxValue: Int + ) { + let intensity = maxValue > 0 ? min(1.0, Double(value) / Double(maxValue)) : 0 + let formatter = StatsValueFormatter(metric: metric) + + self.value = value + self.formattedValue = formatter.format(value: value, context: .compact) + self.color = metric.primaryColor + self.intensity = intensity + } + + var body: some View { + RoundedRectangle(cornerRadius: Constants.step1) + .fill(Constants.heatmapColor(baseColor: color, intensity: intensity, colorScheme: colorScheme)) + .overlay { + if value > 0 { + Text(formattedValue) + .font(.caption.weight(.medium)) + .foregroundStyle(.primary) + .minimumScaleFactor(0.5) + .lineLimit(1) + .dynamicTypeSize(...DynamicTypeSize.xLarge) + } + } + } +} + +// MARK: - HeatmapLegendView + +/// A reusable legend view for heatmaps showing the intensity gradient from less to more +struct HeatmapLegendView: View { + let metric: SiteMetric + let labelWidth: CGFloat? + + @Environment(\.colorScheme) var colorScheme + + init(metric: SiteMetric, labelWidth: CGFloat? = nil) { + self.metric = metric + self.labelWidth = labelWidth + } + + var body: some View { + HStack(spacing: Constants.step2) { + HStack(spacing: 8) { + if let labelWidth { + Text(Strings.PostDetails.less) + .font(.caption2) + .foregroundColor(.secondary) + .frame(width: labelWidth, alignment: .trailing) + } else { + Text(Strings.PostDetails.less) + .font(.caption2) + .foregroundColor(.secondary) + } + + HStack(spacing: 3) { + ForEach(0..<5) { level in + RoundedRectangle(cornerRadius: Constants.step1) + .fill(heatmapColor(for: Double(level) / 4.0)) + .frame(width: 16, height: 16) + } + } + + Text(Strings.PostDetails.more) + .font(.caption2) + .foregroundColor(.secondary) + } + + Spacer() + } + } + + private func heatmapColor(for intensity: Double) -> Color { + Constants.heatmapColor(baseColor: metric.primaryColor, intensity: intensity, colorScheme: colorScheme) + } +} diff --git a/Modules/Sources/JetpackStats/Views/Heatmap/WeeklyTrendsView.swift b/Modules/Sources/JetpackStats/Views/Heatmap/WeeklyTrendsView.swift new file mode 100644 index 000000000000..8a2de07dcdcd --- /dev/null +++ b/Modules/Sources/JetpackStats/Views/Heatmap/WeeklyTrendsView.swift @@ -0,0 +1,469 @@ +import SwiftUI +@preconcurrency import WordPressKit + +struct WeeklyTrendsView: View { + let viewModel: WeeklyTrendsViewModel + + private let cellSpacing: CGFloat = 4 + private let weekLabelWidth: CGFloat = 40 + + @State private var selectedDay: DataPoint? + @State private var selectedWeek: Week? + + init(viewModel: WeeklyTrendsViewModel) { + self.viewModel = viewModel + } + + struct Week { + let startDate: Date + let days: [DataPoint] + let averagePerDay: Int + + static func make(from breakdown: StatsWeeklyBreakdown, using calendar: Calendar) -> Week? { + guard let startDate = calendar.date(from: breakdown.startDay) else { return nil } + + let days = breakdown.days.compactMap { day -> DataPoint? in + guard let date = calendar.date(from: day.date) else { return nil } + return DataPoint(date: date, value: day.viewsCount) + } + + return Week(startDate: startDate, days: days, averagePerDay: 0) + } + + static func make(from breakdowns: [StatsWeeklyBreakdown], using calendar: Calendar) -> [Week] { + breakdowns.compactMap { make(from: $0, using: calendar) } + } + } + + var body: some View { + VStack(alignment: .leading, spacing: cellSpacing) { + header + heatmap + legend + .padding(.top, Constants.step1) + } + .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityElement(children: .combine) + .dynamicTypeSize(...DynamicTypeSize.xxLarge) + } + + private var header: some View { + HStack(spacing: 0) { + Color.clear + .frame(width: weekLabelWidth) + + HStack(spacing: cellSpacing) { + ForEach(viewModel.dayLabels, id: \.self) { day in + Text(day) + .font(.caption2) + .foregroundColor(.secondary) + .frame(maxWidth: .infinity) + .multilineTextAlignment(.center) + .accessibilityHidden(true) + } + } + } + } + + private var heatmap: some View { + VStack(spacing: cellSpacing) { + // Show last 4 weeks, 7 days per week + ForEach(Array(viewModel.weeks.prefix(4).enumerated()), id: \.offset) { weekIndex, week in + HStack(spacing: 8) { + // Week label + Text(viewModel.weekLabel(for: week)) + .font(.caption2) + .foregroundColor(.secondary) + .frame(width: weekLabelWidth, alignment: .trailing) + .dynamicTypeSize(...DynamicTypeSize.large) + + HStack(spacing: cellSpacing) { + // Days in the week + ForEach(week.days, id: \.date) { day in + DayCell( + day: day, + week: week, + previousWeek: viewModel.previousWeek(for: week), + maxValue: viewModel.maxValue, + metric: viewModel.metric, + formatter: viewModel, + calendar: viewModel.calendar + ) + .frame(maxWidth: .infinity) + .aspectRatio(1, contentMode: .fill) + } + } + } + } + } + } + + private var legend: some View { + HeatmapLegendView(metric: viewModel.metric, labelWidth: weekLabelWidth) + } +} + +final class WeeklyTrendsViewModel: ObservableObject { + let weeks: [WeeklyTrendsView.Week] + let calendar: Calendar + let metric: SiteMetric + + private let valueFormatter: StatsValueFormatter + private let weekFormatter: DateFormatter + private let aggregator: StatsDataAggregator + + let dayLabels: [String] + let maxValue: Int + + init(dataPoints: [DataPoint], calendar: Calendar, metric: SiteMetric = .views) { + self.calendar = calendar + self.metric = metric + + // Initialize aggregator + self.aggregator = StatsDataAggregator(calendar: calendar) + + // Initialize formatters + self.valueFormatter = StatsValueFormatter(metric: metric) + + self.weekFormatter = DateFormatter() + self.weekFormatter.dateFormat = "MMM d" + self.weekFormatter.calendar = calendar + self.weekFormatter.timeZone = calendar.timeZone + + // Cache day labels + let formatter = DateFormatter() + formatter.calendar = calendar + formatter.locale = calendar.locale ?? Locale.current + + // Get weekday symbols in the order defined by the calendar's firstWeekday + let symbols = formatter.veryShortWeekdaySymbols ?? [] + let firstWeekday = calendar.firstWeekday + + // Reorder symbols to start with the calendar's first weekday + let reorderedSymbols = Array(symbols[(firstWeekday - 1)...]) + Array(symbols[..<(firstWeekday - 1)]) + self.dayLabels = reorderedSymbols + + // Process data points into weeks + let allWeeks = Self.processDataIntoWeeks(dataPoints: dataPoints, calendar: calendar, metric: metric) + + // Keep only the most recent 5 weeks + self.weeks = Array(allWeeks.prefix(5)) + + // Calculate max value once + self.maxValue = self.weeks.flatMap { $0.days }.map { $0.value }.max() ?? 1 + } + + private static func processDataIntoWeeks(dataPoints: [DataPoint], calendar: Calendar, metric: SiteMetric) -> [WeeklyTrendsView.Week] { + guard !dataPoints.isEmpty else { return [] } + + // Group data points by week + var weeklyData: [Date: [DataPoint]] = [:] + + for dataPoint in dataPoints { + let startOfWeek = calendar.dateInterval(of: .weekOfYear, for: dataPoint.date)?.start ?? dataPoint.date + weeklyData[startOfWeek, default: []].append(dataPoint) + } + + // Create Week objects with sorted days and calculated average + let weeks = weeklyData.map { startDate, days in + // Create a dictionary of existing data points by date + var daysByDate: [Date: DataPoint] = [:] + for day in days { + // Normalize to start of day to avoid time component issues + let normalizedDate = calendar.startOfDay(for: day.date) + daysByDate[normalizedDate] = day + } + + // Fill in all 7 days of the week + var completeDays: [DataPoint] = [] + for dayOffset in 0..<7 { + if let date = calendar.date(byAdding: .day, value: dayOffset, to: startDate) { + let normalizedDate = calendar.startOfDay(for: date) + if let existingDay = daysByDate[normalizedDate] { + completeDays.append(existingDay) + } else { + // Add empty day with 0 value + completeDays.append(DataPoint(date: date, value: 0)) + } + } + } + + let weekTotal = DataPoint.getTotalValue(for: completeDays, metric: metric) ?? 0 + let averagePerDay: Int + if completeDays.isEmpty { + averagePerDay = 0 + } else if metric.aggregationStrategy == .average { + averagePerDay = weekTotal + } else { + averagePerDay = weekTotal / completeDays.count + } + return WeeklyTrendsView.Week(startDate: startDate, days: completeDays, averagePerDay: averagePerDay) + } + + // Sort weeks by start date (most recent first) + return weeks.sorted { $0.startDate > $1.startDate } + } + + func weekLabel(for week: WeeklyTrendsView.Week) -> String { + weekFormatter.string(from: week.startDate) + } + + func formatValue(_ value: Int) -> String { + valueFormatter.format(value: value, context: .compact) + } + + func previousWeek(for week: WeeklyTrendsView.Week) -> WeeklyTrendsView.Week? { + guard let weekIndex = weeks.firstIndex(where: { $0.startDate == week.startDate }), + weekIndex < weeks.count - 1 else { + return nil + } + return weeks[weekIndex + 1] + } +} + +private struct DayCell: View { + let day: DataPoint + let week: WeeklyTrendsView.Week + let previousWeek: WeeklyTrendsView.Week? + let maxValue: Int + let metric: SiteMetric + let formatter: WeeklyTrendsViewModel + let calendar: Calendar + + @State private var showingPopover = false + + private var value: Int { day.value } + + private var intensity: Double { + guard maxValue > 0 else { + return 0 + } + return min(1.0, Double(value) / Double(maxValue)) + } + + var body: some View { + HeatmapCellView( + value: value, + metric: metric, + maxValue: maxValue + ) + .onTapGesture { + showingPopover = true + } + .popover(isPresented: $showingPopover) { + WeeklyTrendsTooltipView( + day: day, + week: week, + previousWeek: previousWeek, + metric: metric, + calendar: calendar, + formatter: formatter + ) + .modifier(PopoverPresentationModifier()) + } + .accessibilityElement() + .accessibilityAddTraits(.isButton) + } + + private var accessibilityLabel: String { + let dateFormatter = DateFormatter() + dateFormatter.dateStyle = .medium + dateFormatter.timeStyle = .none + dateFormatter.calendar = calendar + + let dateString = dateFormatter.string(from: day.date) + let valueString = formatter.formatValue(value) + + return "\(dateString), \(valueString) \(metric.localizedTitle)" + } +} + +private struct WeeklyTrendsTooltipView: View { + let day: DataPoint + let week: WeeklyTrendsView.Week + let previousWeek: WeeklyTrendsView.Week? + let metric: SiteMetric + let calendar: Calendar + let formatter: WeeklyTrendsViewModel + + private var weekTotal: Int? { + week.days.isEmpty ? nil : DataPoint.getTotalValue(for: week.days, metric: metric) + } + + private var previousWeekTotal: Int? { + guard let previousWeek else { return nil } + return previousWeek.days.isEmpty ? nil : DataPoint.getTotalValue(for: previousWeek.days, metric: metric) + } + + private var averagePerDay: Int { + week.averagePerDay + } + + private var trendViewModel: TrendViewModel? { + guard let weekTotal, + let previousWeekTotal else { + return nil + } + return TrendViewModel( + currentValue: weekTotal, + previousValue: previousWeekTotal, + metric: metric, + context: .regular + ) + } + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + // Date header + Text(formattedDate) + .font(.subheadline) + .fontWeight(.semibold) + + // Day value + HStack(spacing: 6) { + Circle() + .fill(metric.primaryColor) + .frame(width: 8, height: 8) + Text(formatter.formatValue(day.value)) + .font(.subheadline) + .fontWeight(.medium) + Text(metric.localizedTitle) + .font(.subheadline) + .foregroundColor(.secondary) + } + + // Week stats + VStack(alignment: .leading, spacing: 4) { + // Week total + if let weekTotal { + HStack(spacing: 4) { + Text(Strings.PostDetails.weekTotal) + .font(.caption) + .foregroundColor(.secondary) + Text(formatter.formatValue(weekTotal)) + .font(.caption) + .fontWeight(.medium) + } + } + + // Average per day + HStack(spacing: 4) { + Text(Strings.PostDetails.dailyAverage) + .font(.caption) + .foregroundColor(.secondary) + Text(formatter.formatValue(averagePerDay)) + .font(.caption) + .fontWeight(.medium) + } + + // Week-over-week change + if let trendViewModel, + let weekTotal, + let previousWeekTotal, + weekTotal != previousWeekTotal { + HStack(spacing: 4) { + Text(Strings.PostDetails.weekOverWeek) + .font(.caption) + .foregroundColor(.secondary) + Text(trendViewModel.formattedTrendShort) + .font(.caption) + .fontWeight(.medium) + .foregroundColor(trendViewModel.sentiment.foregroundColor) + } + } + } + } + .padding() + } + + private var formattedDate: String { + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "MMM d, yyyy" + dateFormatter.calendar = calendar + return dateFormatter.string(from: day.date) + } +} + +// MARK: - Mock Data + +extension WeeklyTrendsViewModel { + @MainActor + static let mock = WeeklyTrendsViewModel(dataPoints: mockDataPoints(), calendar: .demo) +} + +private func mockDataPoints(weeks: Int = 4) -> [DataPoint] { + let calendar = Calendar.demo + let today = Date() + var dataPoints: [DataPoint] = [] + + for weekOffset in 0.. [DataPoint] { + mockDataPoints(weeks: weeks).map { dataPoint in + DataPoint(date: dataPoint.date, value: Int.random(in: 150...250)) + } +} + +private func mockEmptyDataPoints(weeks: Int = 4) -> [DataPoint] { + mockDataPoints(weeks: weeks).map { dataPoint in + DataPoint(date: dataPoint.date, value: 0) + } +} + +// MARK: - Previews + +#Preview { + ScrollView { + VStack(spacing: Constants.step2) { + WeeklyTrendsView( + viewModel: WeeklyTrendsViewModel( + dataPoints: mockDataPoints(), + calendar: StatsContext.demo.calendar, + metric: .views + ) + ) + .padding(Constants.step2) + .cardStyle() + + WeeklyTrendsView( + viewModel: WeeklyTrendsViewModel( + dataPoints: mockHighTrafficDataPoints(), + calendar: StatsContext.demo.calendar, + metric: .views + ) + ) + .padding(Constants.step2) + .cardStyle() + + WeeklyTrendsView( + viewModel: WeeklyTrendsViewModel( + dataPoints: mockEmptyDataPoints(), + calendar: StatsContext.demo.calendar, + metric: .views + ) + ) + .padding(Constants.step2) + .cardStyle() + } + } + .background(Constants.Colors.background) +} diff --git a/Modules/Sources/JetpackStats/Views/Heatmap/YearlyTrendsView.swift b/Modules/Sources/JetpackStats/Views/Heatmap/YearlyTrendsView.swift new file mode 100644 index 000000000000..9d858ae54efe --- /dev/null +++ b/Modules/Sources/JetpackStats/Views/Heatmap/YearlyTrendsView.swift @@ -0,0 +1,280 @@ +import SwiftUI +@preconcurrency import WordPressKit + +struct YearlyTrendsView: View { + let viewModel: YearlyTrendsViewModel + + private let cellSpacing: CGFloat = 6 + private let yearLabelWidth: CGFloat = 40 + + init(viewModel: YearlyTrendsViewModel) { + self.viewModel = viewModel + } + + var body: some View { + VStack(alignment: .leading, spacing: Constants.step2) { + yearlyHeatmap + legend + } + .frame(maxWidth: .infinity, alignment: .leading) + .dynamicTypeSize(...DynamicTypeSize.xxLarge) + } + + private var yearlyHeatmap: some View { + VStack(spacing: cellSpacing) { + ForEach(viewModel.sortedYears, id: \.self) { year in + yearRow(for: year) + } + } + } + + @ViewBuilder + private func yearRow(for year: Int) -> some View { + let monthlyData = viewModel.getMonthlyData(for: year) + + HStack(spacing: 8) { + Text(String(year)) + .font(.caption) + .foregroundColor(.secondary) + .frame(width: yearLabelWidth, alignment: .trailing) + .dynamicTypeSize(...DynamicTypeSize.xLarge) + VStack(spacing: cellSpacing) { + // First row: Jul-Dec (top) + HStack(spacing: cellSpacing) { + ForEach(6..<12) { index in + monthCell(dataPoint: monthlyData[index]) + .frame(maxWidth: .infinity) + .aspectRatio(1, contentMode: .fit) + } + } + // Second row: Jan-Jun (bottom) + HStack(spacing: cellSpacing) { + ForEach(0..<6) { index in + monthCell(dataPoint: monthlyData[index]) + .frame(maxWidth: .infinity) + .aspectRatio(1, contentMode: .fit) + } + } + } + } + } + + @ViewBuilder + private func monthCell(dataPoint: DataPoint) -> some View { + MonthCell( + dataPoint: dataPoint, + metric: viewModel.metric, + maxValue: viewModel.maxMonthlyViews, + formatter: viewModel + ) + } + + private var legend: some View { + HeatmapLegendView(metric: viewModel.metric, labelWidth: yearLabelWidth) + } +} + +final class YearlyTrendsViewModel: ObservableObject { + let metric: SiteMetric + + private let calendar: Calendar + private let valueFormatter: StatsValueFormatter + + let sortedYears: [Int] + let maxMonthlyViews: Int + + private var monthlyData: [Int: [DataPoint]] = [:] // year -> array of 12 DataPoints (Jan=0, Dec=11) + + init(dataPoints: [DataPoint], calendar: Calendar, metric: SiteMetric = .views) { + self.metric = metric + self.calendar = calendar + + self.valueFormatter = StatsValueFormatter(metric: metric) + + // Initialize aggregator with the calendar + let aggregator = StatsDataAggregator(calendar: calendar) + + // Use StatsDataAggregator to aggregate data by month + let normalizedData = aggregator.aggregate(dataPoints, granularity: .month, metric: metric) + + // Process normalized data into year -> array of 12 months structure + var monthlyData: [Int: [DataPoint]] = [:] + var maxMonthlyViews = 0 + + // First, collect all years that have data + var yearsWithData = Set() + for (date, _) in normalizedData { + let components = calendar.dateComponents([.year], from: date) + if let year = components.year { + yearsWithData.insert(year) + } + } + + // Initialize arrays with empty DataPoints for each year + for year in yearsWithData { + var yearData: [DataPoint] = [] + + // Create DataPoint for each month + for month in 1...12 { + var dateComponents = DateComponents() + dateComponents.year = year + dateComponents.month = month + dateComponents.day = 1 + + if let monthDate = calendar.date(from: dateComponents) { + yearData.append(DataPoint(date: monthDate, value: 0)) + } + } + + monthlyData[year] = yearData + } + + // Fill in actual values + for (date, value) in normalizedData { + let components = calendar.dateComponents([.year, .month], from: date) + guard let year = components.year, let month = components.month, month >= 1 && month <= 12 else { continue } + + // Update the DataPoint with the actual value + monthlyData[year]?[month - 1] = DataPoint(date: date, value: value) + + // Track max monthly value + maxMonthlyViews = max(maxMonthlyViews, value) + } + + self.monthlyData = monthlyData + // Sort years in descending order and take only the last 5 years + let allSortedYears = monthlyData.keys.sorted(by: >) + self.sortedYears = Array(allSortedYears.prefix(4)) + self.maxMonthlyViews = max(maxMonthlyViews, 1) // Avoid division by zero + } + + func getMonthlyData(for year: Int) -> [DataPoint] { + guard let yearData = monthlyData[year] else { + return [] + } + return yearData + } + + func formatValue(_ value: Int) -> String { + valueFormatter.format(value: value, context: .compact) + } +} + +private struct MonthCell: View { + let dataPoint: DataPoint + let metric: SiteMetric + let maxValue: Int + let formatter: YearlyTrendsViewModel + + @State private var showingPopover = false + + var body: some View { + HeatmapCellView( + value: dataPoint.value, + metric: metric, + maxValue: maxValue + ) + .onTapGesture { + showingPopover = true + } + .popover(isPresented: $showingPopover) { + MonthlyTrendsTooltipView( + date: dataPoint.date, + value: dataPoint.value, + metric: metric, + formatter: formatter + ) + .modifier(PopoverPresentationModifier()) + } + .accessibilityElement() + .accessibilityLabel(accessibilityLabel) + .accessibilityAddTraits(.isButton) + } + + private var accessibilityLabel: String { + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "MMMM yyyy" + let dateString = dateFormatter.string(from: dataPoint.date) + return "\(dateString), \(formatter.formatValue(dataPoint.value)) \(metric.localizedTitle)" + } +} + +private struct MonthlyTrendsTooltipView: View { + let date: Date + let value: Int + let metric: SiteMetric + let formatter: YearlyTrendsViewModel + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + // Month header + Text(formattedDate) + .font(.subheadline) + .fontWeight(.semibold) + + // Month value + HStack(spacing: 6) { + Circle() + .fill(metric.primaryColor) + .frame(width: 8, height: 8) + Text(formatter.formatValue(value)) + .font(.subheadline) + .fontWeight(.medium) + Text(metric.localizedTitle) + .font(.subheadline) + .foregroundColor(.secondary) + } + } + .padding() + } + + private var formattedDate: String { + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "MMMM yyyy" + return dateFormatter.string(from: date) + } +} + +// MARK: - Previews + +#Preview { + ScrollView { + VStack(spacing: Constants.step2) { + YearlyTrendsView( + viewModel: YearlyTrendsViewModel( + dataPoints: mockDataPoints(), + calendar: Calendar.demo, + metric: .views + ) + ) + .padding(Constants.step2) + .cardStyle() + } + } + .background(Constants.Colors.background) +} + +private func mockDataPoints() -> [DataPoint] { + var dataPoints: [DataPoint] = [] + let calendar = Calendar.demo + + for year in [2021, 2022, 2023, 2024] { + for month in 1...12 { + // Skip future months + if year == 2024 && month > 7 { continue } + + // Generate daily data points for each month + let daysInMonth = calendar.range(of: .day, in: .month, for: calendar.date(from: DateComponents(year: year, month: month))!)?.count ?? 30 + + for day in 1...daysInMonth { + if let date = calendar.date(from: DateComponents(year: year, month: month, day: day)) { + let baseViews = year == 2024 ? 500 : (year == 2023 ? 400 : 200) + let viewsCount = Int.random(in: (baseViews / 2)...baseViews) + dataPoints.append(DataPoint(date: date, value: viewsCount)) + } + } + } + } + + return dataPoints +} diff --git a/Modules/Sources/JetpackStats/Views/StandaloneMetricView.swift b/Modules/Sources/JetpackStats/Views/StandaloneMetricView.swift new file mode 100644 index 000000000000..680d088e00df --- /dev/null +++ b/Modules/Sources/JetpackStats/Views/StandaloneMetricView.swift @@ -0,0 +1,31 @@ +import SwiftUI +import DesignSystem + +struct StandaloneMetricView: View { + let metric: SiteMetric + let value: Int + + var body: some View { + VStack(alignment: .trailing, spacing: 0) { + HStack(spacing: 4) { + Image(systemName: metric.systemImage) + .font(.caption.weight(.medium)) + .foregroundColor(.secondary) + + Text(metric.localizedTitle) + .font(.caption.weight(.medium)) + .foregroundColor(.secondary) + .textCase(.uppercase) + } + Text(StatsValueFormatter.formatNumber(value, onlyLarge: true)) + .font(Font.make(.recoleta, textStyle: .title2, weight: .medium)) + .foregroundColor(.primary) + .contentTransition(.numericText()) + } + } +} + +#Preview { + StandaloneMetricView(metric: .views, value: 12345) + .padding() +} diff --git a/Modules/Tests/JetpackStatsTests/WeeklyTrendsViewModelTests.swift b/Modules/Tests/JetpackStatsTests/WeeklyTrendsViewModelTests.swift new file mode 100644 index 000000000000..0b560159e68e --- /dev/null +++ b/Modules/Tests/JetpackStatsTests/WeeklyTrendsViewModelTests.swift @@ -0,0 +1,381 @@ +import Testing +import Foundation +@testable import JetpackStats + +@Suite("WeeklyTrendsViewModel Tests") +@MainActor +struct WeeklyTrendsViewModelTests { + + private let calendar = Calendar.mock(timeZone: TimeZone(secondsFromGMT: 0)!) + + // MARK: - Initialization Tests + + @Test("Initializes with data points") + func initialization() { + // Given + let dataPoints = [ + DataPoint(date: Date("2025-01-01T00:00:00Z"), value: 100), + DataPoint(date: Date("2025-01-02T00:00:00Z"), value: 150), + DataPoint(date: Date("2025-01-08T00:00:00Z"), value: 200), + DataPoint(date: Date("2025-01-09T00:00:00Z"), value: 250) + ] + + // When + let viewModel = WeeklyTrendsViewModel( + dataPoints: dataPoints, + calendar: calendar, + metric: .views + ) + + // Then + #expect(viewModel.weeks.count == 2) + #expect(viewModel.metric == .views) + #expect(viewModel.calendar == calendar) + #expect(viewModel.maxValue > 0) + } + + @Test("Handles empty data points") + func emptyDataPoints() { + // Given + let dataPoints: [DataPoint] = [] + + // When + let viewModel = WeeklyTrendsViewModel( + dataPoints: dataPoints, + calendar: calendar, + metric: .views + ) + + // Then + #expect(viewModel.weeks.count == 0) + #expect(viewModel.maxValue == 1) + } + + // MARK: - Week Processing Tests + + @Test("Sorts weeks by most recent first") + func weeksAreSortedByMostRecent() { + // Given + let dataPoints = [ + // Week 1: Dec 29, 2024 - Jan 4, 2025 + DataPoint(date: Date("2024-12-29T00:00:00Z"), value: 100), + DataPoint(date: Date("2024-12-30T00:00:00Z"), value: 110), + DataPoint(date: Date("2025-01-01T00:00:00Z"), value: 120), + // Week 2: Jan 5-11, 2025 + DataPoint(date: Date("2025-01-05T00:00:00Z"), value: 130), + DataPoint(date: Date("2025-01-06T00:00:00Z"), value: 140), + DataPoint(date: Date("2025-01-07T00:00:00Z"), value: 150), + // Week 3: Jan 12-18, 2025 + DataPoint(date: Date("2025-01-12T00:00:00Z"), value: 160), + DataPoint(date: Date("2025-01-13T00:00:00Z"), value: 170), + DataPoint(date: Date("2025-01-14T00:00:00Z"), value: 180) + ] + + // When + let viewModel = WeeklyTrendsViewModel( + dataPoints: dataPoints, + calendar: calendar, + metric: .views + ) + + // Then + #expect(viewModel.weeks.count == 3) + for i in 0.. viewModel.weeks[i + 1].startDate) + } + } + + @Test("Limits to five most recent weeks") + func limitsToFiveMostRecentWeeks() { + // Given + var dataPoints: [DataPoint] = [] + let baseDate = Date("2025-01-15T00:00:00Z") + + // Create 8 weeks of data + for weekOffset in 0..<8 { + for dayOffset in 0..<7 { + let date = calendar.date(byAdding: .day, value: -(weekOffset * 7 + dayOffset), to: baseDate)! + dataPoints.append(DataPoint(date: date, value: 100 + weekOffset * 10 + dayOffset)) + } + } + + // When + let viewModel = WeeklyTrendsViewModel( + dataPoints: dataPoints, + calendar: calendar, + metric: .views + ) + + // Then + #expect(viewModel.weeks.count == 5) + // Verify they are the most recent weeks + for i in 0.. viewModel.weeks[i + 1].startDate) + } + } + + @Test("Sorts days within week") + func daysWithinWeekAreSorted() { + // Given - shuffled days within a single week + let dataPoints = [ + DataPoint(date: Date("2025-01-08T00:00:00Z"), value: 130), // Wed + DataPoint(date: Date("2025-01-06T00:00:00Z"), value: 110), // Mon + DataPoint(date: Date("2025-01-10T00:00:00Z"), value: 150), // Fri + DataPoint(date: Date("2025-01-05T00:00:00Z"), value: 100), // Sun + DataPoint(date: Date("2025-01-07T00:00:00Z"), value: 120), // Tue + DataPoint(date: Date("2025-01-09T00:00:00Z"), value: 140), // Thu + DataPoint(date: Date("2025-01-11T00:00:00Z"), value: 160) // Sat + ] + + // When + let viewModel = WeeklyTrendsViewModel( + dataPoints: dataPoints, + calendar: calendar, + metric: .views + ) + + // Then + #expect(viewModel.weeks.count == 1) + let week = viewModel.weeks[0] + #expect(week.days.count == 7) + for i in 0..