From 4978dacdd73329c8c5e1f9dc8a76919c0b26fe84 Mon Sep 17 00:00:00 2001 From: Yilin Jing Date: Sat, 28 Feb 2026 11:18:59 +0800 Subject: [PATCH 1/2] feat(#36): investor-friendly UI with sparklines, backers, and momentum metrics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: - Add GET /campaigns/:pid/history endpoint (14-day default, 30-day max) - Returns campaign snapshots for trend visualization iOS: - Add backers_count display with person icon - Show 24h dollar changes ($50K) instead of just percentages - Add state badges (Funded ✓, Failed, Canceled) - Expand detail stats to 4 columns (Goal, Pledged, Backers, Days) - Add expandable project blurb (collapses at 150 chars) - Add momentum section with sparkline chart and metrics - Create SparklineView with green/red gradient trends - Implement 5-min history cache in APIClient Visual design: Color-coded badges, SF Symbol icons, glanceable metrics for investors. Co-Authored-By: Claude Sonnet 4.5 --- backend/cmd/api/main.go | 1 + backend/internal/handler/campaigns.go | 27 +++ .../Sources/Services/APIClient.swift | 32 +++ .../Sources/Views/CampaignDetailView.swift | 201 +++++++++++++++++- .../Sources/Views/CampaignRowView.swift | 67 +++++- .../Sources/Views/SparklineView.swift | 62 ++++++ 6 files changed, 376 insertions(+), 14 deletions(-) create mode 100644 ios/KickWatch/Sources/Views/SparklineView.swift diff --git a/backend/cmd/api/main.go b/backend/cmd/api/main.go index ecd28e4..adb8802 100644 --- a/backend/cmd/api/main.go +++ b/backend/cmd/api/main.go @@ -72,6 +72,7 @@ func main() { api.GET("/campaigns", handler.ListCampaigns(scrapingService)) api.GET("/campaigns/search", handler.SearchCampaigns(scrapingService)) api.GET("/campaigns/:pid", handler.GetCampaign) + api.GET("/campaigns/:pid/history", handler.GetCampaignHistory) api.GET("/categories", handler.ListCategories(scrapingService)) api.POST("/devices/register", handler.RegisterDevice) diff --git a/backend/internal/handler/campaigns.go b/backend/internal/handler/campaigns.go index 381671a..d3f1f7d 100644 --- a/backend/internal/handler/campaigns.go +++ b/backend/internal/handler/campaigns.go @@ -3,6 +3,7 @@ package handler import ( "net/http" "strconv" + "time" "github.com/gin-gonic/gin" "github.com/kickwatch/backend/internal/db" @@ -136,3 +137,29 @@ func ListCategories(client *service.KickstarterScrapingService) gin.HandlerFunc c.JSON(http.StatusOK, cats) } } + +func GetCampaignHistory(c *gin.Context) { + pid := c.Param("pid") + days := c.DefaultQuery("days", "14") + daysInt, _ := strconv.Atoi(days) + if daysInt > 30 { + daysInt = 30 + } + + if !db.IsEnabled() { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "database not available"}) + return + } + + cutoff := time.Now().Add(-time.Duration(daysInt) * 24 * time.Hour) + + var snapshots []model.CampaignSnapshot + if err := db.DB.Where("campaign_pid = ? AND snapshot_at >= ?", pid, cutoff). + Order("snapshot_at ASC"). + Find(&snapshots).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"history": snapshots}) +} diff --git a/ios/KickWatch/Sources/Services/APIClient.swift b/ios/KickWatch/Sources/Services/APIClient.swift index 8ee9b46..60d5535 100644 --- a/ios/KickWatch/Sources/Services/APIClient.swift +++ b/ios/KickWatch/Sources/Services/APIClient.swift @@ -15,6 +15,7 @@ struct CampaignDTO: Codable { let project_url: String? let creator_name: String? let percent_funded: Double? + let backers_count: Int? let slug: String? let velocity_24h: Double? let pledge_delta_24h: Double? @@ -38,6 +39,22 @@ struct SearchResponse: Codable { let next_cursor: String? } +struct HistoryDataPoint: Codable, Identifiable { + let id: String + let campaign_pid: String + let pledged_amount: Double + let percent_funded: Double + let snapshot_at: String + + var date: Date? { + ISO8601DateFormatter().date(from: snapshot_at) + } +} + +struct CampaignHistoryResponse: Codable { + let history: [HistoryDataPoint] +} + struct RegisterDeviceRequest: Codable { let device_token: String } @@ -94,6 +111,7 @@ actor APIClient { private let baseURL: String private let session: URLSession + private var historyCache: [String: (data: CampaignHistoryResponse, timestamp: Date)] = [:] init(baseURL: String? = nil) { #if DEBUG @@ -160,6 +178,20 @@ actor APIClient { return try await get(url: url) } + func fetchCampaignHistory(pid: String, days: Int = 14) async throws -> CampaignHistoryResponse { + if let cached = historyCache[pid], + Date().timeIntervalSince(cached.timestamp) < 300 { + return cached.data + } + + var components = URLComponents(string: baseURL + "/api/campaigns/\(pid)/history")! + components.queryItems = [URLQueryItem(name: "days", value: String(days))] + + let response: CampaignHistoryResponse = try await get(url: components.url!) + historyCache[pid] = (response, Date()) + return response + } + private func get(url: URL) async throws -> R { let (data, response) = try await session.data(from: url) guard let http = response as? HTTPURLResponse else { throw APIError.invalidResponse } diff --git a/ios/KickWatch/Sources/Views/CampaignDetailView.swift b/ios/KickWatch/Sources/Views/CampaignDetailView.swift index b244edc..de9331d 100644 --- a/ios/KickWatch/Sources/Views/CampaignDetailView.swift +++ b/ios/KickWatch/Sources/Views/CampaignDetailView.swift @@ -5,6 +5,8 @@ struct CampaignDetailView: View { let campaign: CampaignDTO @Query private var watchlist: [Campaign] @Environment(\.modelContext) private var modelContext + @State private var historyData: [HistoryDataPoint] = [] + @State private var isLoadingHistory = false private var isWatched: Bool { watchlist.contains { $0.pid == campaign.pid && $0.isWatched } @@ -28,6 +30,9 @@ struct CampaignDetailView: View { } .navigationBarTitleDisplayMode(.inline) .toolbar { toolbarItems } + .task { + await loadHistory() + } } private var heroImage: some View { @@ -53,6 +58,15 @@ struct CampaignDetailView: View { fundingStats + if let blurb = campaign.blurb, !blurb.isEmpty { + ExpandableBlurbView(blurb: blurb) + } + + if let velocity = campaign.velocity_24h, + let delta = campaign.pledge_delta_24h { + momentumSection(velocity: velocity, delta: delta) + } + if let url = campaign.project_url, let link = URL(string: url) { Link(destination: link) { Label("Back this project", systemImage: "arrow.up.right.square.fill") @@ -70,14 +84,30 @@ struct CampaignDetailView: View { private var fundingStats: some View { VStack(spacing: 12) { fundingRing - HStack { - statBox(label: "Goal", value: formattedAmount(campaign.goal_amount, currency: campaign.goal_currency)) - Divider() - statBox(label: "Pledged", value: formattedAmount(campaign.pledged_amount, currency: campaign.goal_currency)) - Divider() - statBox(label: "Days Left", value: "\(daysLeft)") + + VStack(spacing: 8) { + HStack { + statBox(label: "Goal", + value: formattedAmount(campaign.goal_amount, currency: campaign.goal_currency), + icon: "target") + Divider() + statBox(label: "Pledged", + value: formattedAmount(campaign.pledged_amount, currency: campaign.goal_currency), + icon: "dollarsign.circle.fill") + } + .frame(height: 60) + + HStack { + statBox(label: "Backers", + value: formatBackersLarge(campaign.backers_count), + icon: "person.2.fill") + Divider() + statBox(label: "Days Left", + value: "\(daysLeft)", + icon: "calendar") + } + .frame(height: 60) } - .frame(height: 60) .padding() .background(Color(.systemGray6)) .clipShape(RoundedRectangle(cornerRadius: 12)) @@ -100,14 +130,29 @@ struct CampaignDetailView: View { .padding(.vertical, 8) } - private func statBox(label: String, value: String) -> some View { - VStack(spacing: 2) { - Text(value).font(.subheadline).fontWeight(.semibold) - Text(label).font(.caption2).foregroundStyle(.secondary) + private func statBox(label: String, value: String, icon: String? = nil) -> some View { + VStack(spacing: 4) { + if let icon = icon { + Image(systemName: icon) + .font(.system(size: 16)) + .foregroundStyle(.secondary) + } + Text(value).font(.title3).fontWeight(.bold) + Text(label).font(.caption).foregroundStyle(.secondary) } .frame(maxWidth: .infinity) } + private func formatBackersLarge(_ count: Int?) -> String { + guard let count = count else { return "—" } + if count >= 1_000_000 { + return String(format: "%.1fM", Double(count) / 1_000_000) + } else if count >= 1_000 { + return String(format: "%.1fK", Double(count) / 1_000) + } + return "\(count)" + } + private func formattedAmount(_ amount: Double?, currency: String?) -> String { guard let amount else { return "—" } let sym = currency == "USD" ? "$" : (currency ?? "") @@ -156,4 +201,138 @@ struct CampaignDetailView: View { } try? modelContext.save() } + + private func momentumSection(velocity: Double, delta: Double) -> some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + Image(systemName: "bolt.fill") + .foregroundStyle(.orange) + Text("24-Hour Momentum") + .font(.subheadline).fontWeight(.semibold) + Spacer() + momentumBadge(velocity: velocity, delta: delta) + } + + if !historyData.isEmpty { + SparklineView(dataPoints: historyData) + } else if isLoadingHistory { + ProgressView() + .frame(height: 60) + .frame(maxWidth: .infinity) + } + + HStack(spacing: 16) { + metricCard( + icon: "dollarsign.circle.fill", + label: "24h Change", + value: formatDelta(delta), + color: delta > 0 ? .green : .red + ) + metricCard( + icon: "percent", + label: "Growth Rate", + value: String(format: "%.1f%%", velocity), + color: velocity > 0 ? .green : .red + ) + } + } + .padding() + .background(Color(.systemGray6)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } + + private func momentumBadge(velocity: Double, delta: Double) -> some View { + HStack(spacing: 4) { + Image(systemName: velocity > 0 ? "arrow.up.right" : "arrow.down.right") + .font(.system(size: 10)) + Text(delta > 0 ? "+\(formatDelta(delta))" : formatDelta(delta)) + .font(.caption).fontWeight(.semibold) + } + .padding(.horizontal, 8).padding(.vertical, 4) + .background((velocity > 0 ? Color.green : Color.red).opacity(0.15)) + .foregroundStyle(velocity > 0 ? .green : .red) + .clipShape(Capsule()) + } + + private func metricCard(icon: String, label: String, value: String, color: Color) -> some View { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 4) { + Image(systemName: icon) + .font(.system(size: 14)) + Text(label) + .font(.caption2) + } + .foregroundStyle(.secondary) + + Text(value) + .font(.title3).fontWeight(.bold) + .foregroundStyle(color) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding() + .background(Color(.systemBackground)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + + private func formatDelta(_ amount: Double) -> String { + let absAmount = abs(amount) + if absAmount >= 1_000_000 { + return String(format: "$%.1fM", amount / 1_000_000) + } else if absAmount >= 1_000 { + return String(format: "$%.0fK", amount / 1_000) + } + return "$\(Int(amount))" + } + + private func loadHistory() async { + isLoadingHistory = true + defer { isLoadingHistory = false } + + do { + let response = try await APIClient.shared.fetchCampaignHistory(pid: campaign.pid, days: 14) + historyData = response.history + } catch { + print("Failed to load history: \(error)") + } + } +} + +struct ExpandableBlurbView: View { + let blurb: String + @State private var isExpanded = false + + private var shouldShowButton: Bool { + blurb.count > 150 + } + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text("About this project") + .font(.subheadline).fontWeight(.semibold) + + Text(blurb) + .font(.subheadline) + .foregroundStyle(.secondary) + .lineLimit(isExpanded ? nil : 3) + + if shouldShowButton { + Button { + withAnimation(.easeInOut(duration: 0.2)) { + isExpanded.toggle() + } + } label: { + HStack(spacing: 4) { + Text(isExpanded ? "Show less" : "Read more") + Image(systemName: isExpanded ? "chevron.up" : "chevron.down") + .font(.system(size: 10)) + } + .font(.caption).fontWeight(.medium) + .foregroundStyle(.accentColor) + } + } + } + .padding() + .background(Color(.systemGray6)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } } diff --git a/ios/KickWatch/Sources/Views/CampaignRowView.swift b/ios/KickWatch/Sources/Views/CampaignRowView.swift index 96d3828..3618134 100644 --- a/ios/KickWatch/Sources/Views/CampaignRowView.swift +++ b/ios/KickWatch/Sources/Views/CampaignRowView.swift @@ -37,6 +37,7 @@ struct CampaignRowView: View { } fundingBar HStack(spacing: 8) { + stateBadge Text("\(Int(campaign.percent_funded ?? 0))% funded") .font(.caption2).foregroundStyle(.secondary) if let deadline = campaign.deadline, let date = ISO8601DateFormatter().date(from: deadline) { @@ -44,6 +45,14 @@ struct CampaignRowView: View { Text("\(days)d left") .font(.caption2).foregroundStyle(.secondary) } + if let backers = campaign.backers_count, backers > 0 { + HStack(spacing: 2) { + Image(systemName: "person.2.fill") + .font(.system(size: 9)) + Text(formatBackers(backers)) + } + .font(.caption2).foregroundStyle(.secondary) + } momentumBadge } } @@ -53,9 +62,16 @@ struct CampaignRowView: View { private var momentumBadge: some View { if let v = campaign.velocity_24h, v > 0 { let (icon, color): (String, Color) = v >= 200 ? ("🔥", .red) : ("⚡", .orange) - Text("\(icon) +\(Int(v))%") - .font(.caption2).fontWeight(.semibold) - .foregroundStyle(color) + + if let delta = campaign.pledge_delta_24h, delta > 0 { + Text("\(icon) +\(formatDelta(delta))") + .font(.caption2).fontWeight(.semibold) + .foregroundStyle(color) + } else { + Text("\(icon) +\(Int(v))%") + .font(.caption2).fontWeight(.semibold) + .foregroundStyle(color) + } } else if isNew { Text("New") .font(.caption2).fontWeight(.semibold) @@ -66,12 +82,57 @@ struct CampaignRowView: View { } } + @ViewBuilder + private var stateBadge: some View { + if let state = campaign.state, state != "live" { + Text(stateLabel(state)) + .font(.caption2).fontWeight(.medium) + .padding(.horizontal, 6).padding(.vertical, 2) + .background(stateColor(state).opacity(0.15)) + .foregroundStyle(stateColor(state)) + .clipShape(Capsule()) + } + } + private var isNew: Bool { guard let s = campaign.first_seen_at, let date = ISO8601DateFormatter().date(from: s) else { return false } return Date().timeIntervalSince(date) < 48 * 3600 } + private func formatBackers(_ count: Int) -> String { + if count >= 1000 { + return String(format: "%.1fK", Double(count) / 1000) + } + return "\(count)" + } + + private func formatDelta(_ amount: Double) -> String { + if amount >= 1_000_000 { + return String(format: "$%.1fM", amount / 1_000_000) + } else if amount >= 1_000 { + return String(format: "$%.0fK", amount / 1_000) + } + return "$\(Int(amount))" + } + + private func stateLabel(_ state: String) -> String { + switch state { + case "successful": return "Funded ✓" + case "failed": return "Failed" + case "canceled": return "Canceled" + default: return "Live" + } + } + + private func stateColor(_ state: String) -> Color { + switch state { + case "successful": return .green + case "failed", "canceled": return .red + default: return .accentColor + } + } + private var fundingBar: some View { GeometryReader { geo in ZStack(alignment: .leading) { diff --git a/ios/KickWatch/Sources/Views/SparklineView.swift b/ios/KickWatch/Sources/Views/SparklineView.swift new file mode 100644 index 0000000..ab9c6e0 --- /dev/null +++ b/ios/KickWatch/Sources/Views/SparklineView.swift @@ -0,0 +1,62 @@ +import SwiftUI +import Charts + +struct SparklineView: View { + let dataPoints: [HistoryDataPoint] + let height: CGFloat = 60 + + private var trendColor: Color { + guard dataPoints.count >= 2 else { return .gray } + let first = dataPoints.first?.pledged_amount ?? 0 + let last = dataPoints.last?.pledged_amount ?? 0 + if last > first * 1.05 { return .green } + if last < first * 0.95 { return .red } + return .gray + } + + var body: some View { + if dataPoints.isEmpty { + emptyState + } else { + chart + } + } + + private var emptyState: some View { + HStack { + Image(systemName: "chart.line.uptrend.xyaxis") + .font(.system(size: 24)) + .foregroundStyle(.secondary) + Text("No trend data yet") + .font(.caption).foregroundStyle(.secondary) + } + .frame(height: height) + .frame(maxWidth: .infinity) + } + + private var chart: some View { + Chart(dataPoints) { point in + AreaMark( + x: .value("Date", point.date ?? Date()), + y: .value("Pledged", point.pledged_amount) + ) + .foregroundStyle( + LinearGradient( + colors: [trendColor.opacity(0.3), trendColor.opacity(0.05)], + startPoint: .top, + endPoint: .bottom + ) + ) + + LineMark( + x: .value("Date", point.date ?? Date()), + y: .value("Pledged", point.pledged_amount) + ) + .foregroundStyle(trendColor) + .lineStyle(StrokeStyle(lineWidth: 2, lineCap: .round)) + } + .chartXAxis(.hidden) + .chartYAxis(.hidden) + .frame(height: height) + } +} From 32f3abd2c6ab488f495b5282ccbe702e0f7a8ea4 Mon Sep 17 00:00:00 2001 From: Yilin Jing Date: Sat, 28 Feb 2026 11:30:09 +0800 Subject: [PATCH 2/2] fix: address Codex review findings 1. Add missing backers_count parameter in WatchlistView toCampaignDTO 2. Prevent showing $0/0% momentum section for campaigns with zero velocity/delta 3. Handle strconv.Atoi errors properly in GetCampaignHistory, default to 14 days Co-Authored-By: Claude Sonnet 4.5 --- backend/internal/handler/campaigns.go | 5 ++++- ios/KickWatch/Sources/Views/CampaignDetailView.swift | 3 ++- ios/KickWatch/Sources/Views/WatchlistView.swift | 2 +- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/backend/internal/handler/campaigns.go b/backend/internal/handler/campaigns.go index d3f1f7d..205b7f1 100644 --- a/backend/internal/handler/campaigns.go +++ b/backend/internal/handler/campaigns.go @@ -141,7 +141,10 @@ func ListCategories(client *service.KickstarterScrapingService) gin.HandlerFunc func GetCampaignHistory(c *gin.Context) { pid := c.Param("pid") days := c.DefaultQuery("days", "14") - daysInt, _ := strconv.Atoi(days) + daysInt, err := strconv.Atoi(days) + if err != nil || daysInt < 1 { + daysInt = 14 + } if daysInt > 30 { daysInt = 30 } diff --git a/ios/KickWatch/Sources/Views/CampaignDetailView.swift b/ios/KickWatch/Sources/Views/CampaignDetailView.swift index de9331d..e0458d0 100644 --- a/ios/KickWatch/Sources/Views/CampaignDetailView.swift +++ b/ios/KickWatch/Sources/Views/CampaignDetailView.swift @@ -63,7 +63,8 @@ struct CampaignDetailView: View { } if let velocity = campaign.velocity_24h, - let delta = campaign.pledge_delta_24h { + let delta = campaign.pledge_delta_24h, + velocity > 0 || delta != 0 { momentumSection(velocity: velocity, delta: delta) } diff --git a/ios/KickWatch/Sources/Views/WatchlistView.swift b/ios/KickWatch/Sources/Views/WatchlistView.swift index 698539f..fcd734f 100644 --- a/ios/KickWatch/Sources/Views/WatchlistView.swift +++ b/ios/KickWatch/Sources/Views/WatchlistView.swift @@ -53,7 +53,7 @@ struct WatchlistView: View { deadline: ISO8601DateFormatter().string(from: c.deadline), state: c.state, category_name: c.categoryName, category_id: c.categoryID, project_url: c.projectURL, creator_name: c.creatorName, - percent_funded: c.percentFunded, slug: nil, + percent_funded: c.percentFunded, backers_count: nil, slug: nil, velocity_24h: nil, pledge_delta_24h: nil, first_seen_at: nil ) }