Skip to content

Commit 1e0dc22

Browse files
Jing-yilinclaude
andauthored
feat(#36): Investor-Friendly UI with Sparklines, Backers, and Momentum Metrics (#39)
* feat(#36): investor-friendly UI with sparklines, backers, and momentum metrics 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
1 parent 8b25d5a commit 1e0dc22

7 files changed

Lines changed: 381 additions & 15 deletions

File tree

backend/cmd/api/main.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ func main() {
7272
api.GET("/campaigns", handler.ListCampaigns(scrapingService))
7373
api.GET("/campaigns/search", handler.SearchCampaigns(scrapingService))
7474
api.GET("/campaigns/:pid", handler.GetCampaign)
75+
api.GET("/campaigns/:pid/history", handler.GetCampaignHistory)
7576
api.GET("/categories", handler.ListCategories(scrapingService))
7677

7778
api.POST("/devices/register", handler.RegisterDevice)

backend/internal/handler/campaigns.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"encoding/base64"
55
"net/http"
66
"strconv"
7+
"time"
78

89
"github.com/gin-gonic/gin"
910
"github.com/kickwatch/backend/internal/db"
@@ -156,3 +157,32 @@ func ListCategories(client *service.KickstarterScrapingService) gin.HandlerFunc
156157
c.JSON(http.StatusOK, cats)
157158
}
158159
}
160+
161+
func GetCampaignHistory(c *gin.Context) {
162+
pid := c.Param("pid")
163+
days := c.DefaultQuery("days", "14")
164+
daysInt, err := strconv.Atoi(days)
165+
if err != nil || daysInt < 1 {
166+
daysInt = 14
167+
}
168+
if daysInt > 30 {
169+
daysInt = 30
170+
}
171+
172+
if !db.IsEnabled() {
173+
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "database not available"})
174+
return
175+
}
176+
177+
cutoff := time.Now().Add(-time.Duration(daysInt) * 24 * time.Hour)
178+
179+
var snapshots []model.CampaignSnapshot
180+
if err := db.DB.Where("campaign_pid = ? AND snapshot_at >= ?", pid, cutoff).
181+
Order("snapshot_at ASC").
182+
Find(&snapshots).Error; err != nil {
183+
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
184+
return
185+
}
186+
187+
c.JSON(http.StatusOK, gin.H{"history": snapshots})
188+
}

ios/KickWatch/Sources/Services/APIClient.swift

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ struct CampaignDTO: Codable {
1515
let project_url: String?
1616
let creator_name: String?
1717
let percent_funded: Double?
18+
let backers_count: Int?
1819
let slug: String?
1920
let velocity_24h: Double?
2021
let pledge_delta_24h: Double?
@@ -38,6 +39,22 @@ struct SearchResponse: Codable {
3839
let next_cursor: String?
3940
}
4041

42+
struct HistoryDataPoint: Codable, Identifiable {
43+
let id: String
44+
let campaign_pid: String
45+
let pledged_amount: Double
46+
let percent_funded: Double
47+
let snapshot_at: String
48+
49+
var date: Date? {
50+
ISO8601DateFormatter().date(from: snapshot_at)
51+
}
52+
}
53+
54+
struct CampaignHistoryResponse: Codable {
55+
let history: [HistoryDataPoint]
56+
}
57+
4158
struct RegisterDeviceRequest: Codable {
4259
let device_token: String
4360
}
@@ -94,6 +111,7 @@ actor APIClient {
94111

95112
private let baseURL: String
96113
private let session: URLSession
114+
private var historyCache: [String: (data: CampaignHistoryResponse, timestamp: Date)] = [:]
97115

98116
init(baseURL: String? = nil) {
99117
#if DEBUG
@@ -160,6 +178,20 @@ actor APIClient {
160178
return try await get(url: url)
161179
}
162180

181+
func fetchCampaignHistory(pid: String, days: Int = 14) async throws -> CampaignHistoryResponse {
182+
if let cached = historyCache[pid],
183+
Date().timeIntervalSince(cached.timestamp) < 300 {
184+
return cached.data
185+
}
186+
187+
var components = URLComponents(string: baseURL + "/api/campaigns/\(pid)/history")!
188+
components.queryItems = [URLQueryItem(name: "days", value: String(days))]
189+
190+
let response: CampaignHistoryResponse = try await get(url: components.url!)
191+
historyCache[pid] = (response, Date())
192+
return response
193+
}
194+
163195
private func get<R: Decodable>(url: URL) async throws -> R {
164196
let (data, response) = try await session.data(from: url)
165197
guard let http = response as? HTTPURLResponse else { throw APIError.invalidResponse }

ios/KickWatch/Sources/Views/CampaignDetailView.swift

Lines changed: 191 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ struct CampaignDetailView: View {
55
let campaign: CampaignDTO
66
@Query private var watchlist: [Campaign]
77
@Environment(\.modelContext) private var modelContext
8+
@State private var historyData: [HistoryDataPoint] = []
9+
@State private var isLoadingHistory = false
810

911
private var isWatched: Bool {
1012
watchlist.contains { $0.pid == campaign.pid && $0.isWatched }
@@ -28,6 +30,9 @@ struct CampaignDetailView: View {
2830
}
2931
.navigationBarTitleDisplayMode(.inline)
3032
.toolbar { toolbarItems }
33+
.task {
34+
await loadHistory()
35+
}
3136
}
3237

3338
private var heroImage: some View {
@@ -53,6 +58,16 @@ struct CampaignDetailView: View {
5358

5459
fundingStats
5560

61+
if let blurb = campaign.blurb, !blurb.isEmpty {
62+
ExpandableBlurbView(blurb: blurb)
63+
}
64+
65+
if let velocity = campaign.velocity_24h,
66+
let delta = campaign.pledge_delta_24h,
67+
velocity > 0 || delta != 0 {
68+
momentumSection(velocity: velocity, delta: delta)
69+
}
70+
5671
if let url = campaign.project_url, let link = URL(string: url) {
5772
Link(destination: link) {
5873
Label("Back this project", systemImage: "arrow.up.right.square.fill")
@@ -70,14 +85,30 @@ struct CampaignDetailView: View {
7085
private var fundingStats: some View {
7186
VStack(spacing: 12) {
7287
fundingRing
73-
HStack {
74-
statBox(label: "Goal", value: formattedAmount(campaign.goal_amount, currency: campaign.goal_currency))
75-
Divider()
76-
statBox(label: "Pledged", value: formattedAmount(campaign.pledged_amount, currency: campaign.goal_currency))
77-
Divider()
78-
statBox(label: "Days Left", value: "\(daysLeft)")
88+
89+
VStack(spacing: 8) {
90+
HStack {
91+
statBox(label: "Goal",
92+
value: formattedAmount(campaign.goal_amount, currency: campaign.goal_currency),
93+
icon: "target")
94+
Divider()
95+
statBox(label: "Pledged",
96+
value: formattedAmount(campaign.pledged_amount, currency: campaign.goal_currency),
97+
icon: "dollarsign.circle.fill")
98+
}
99+
.frame(height: 60)
100+
101+
HStack {
102+
statBox(label: "Backers",
103+
value: formatBackersLarge(campaign.backers_count),
104+
icon: "person.2.fill")
105+
Divider()
106+
statBox(label: "Days Left",
107+
value: "\(daysLeft)",
108+
icon: "calendar")
109+
}
110+
.frame(height: 60)
79111
}
80-
.frame(height: 60)
81112
.padding()
82113
.background(Color(.systemGray6))
83114
.clipShape(RoundedRectangle(cornerRadius: 12))
@@ -100,14 +131,29 @@ struct CampaignDetailView: View {
100131
.padding(.vertical, 8)
101132
}
102133

103-
private func statBox(label: String, value: String) -> some View {
104-
VStack(spacing: 2) {
105-
Text(value).font(.subheadline).fontWeight(.semibold)
106-
Text(label).font(.caption2).foregroundStyle(.secondary)
134+
private func statBox(label: String, value: String, icon: String? = nil) -> some View {
135+
VStack(spacing: 4) {
136+
if let icon = icon {
137+
Image(systemName: icon)
138+
.font(.system(size: 16))
139+
.foregroundStyle(.secondary)
140+
}
141+
Text(value).font(.title3).fontWeight(.bold)
142+
Text(label).font(.caption).foregroundStyle(.secondary)
107143
}
108144
.frame(maxWidth: .infinity)
109145
}
110146

147+
private func formatBackersLarge(_ count: Int?) -> String {
148+
guard let count = count else { return "" }
149+
if count >= 1_000_000 {
150+
return String(format: "%.1fM", Double(count) / 1_000_000)
151+
} else if count >= 1_000 {
152+
return String(format: "%.1fK", Double(count) / 1_000)
153+
}
154+
return "\(count)"
155+
}
156+
111157
private func formattedAmount(_ amount: Double?, currency: String?) -> String {
112158
guard let amount else { return "" }
113159
let sym = currency == "USD" ? "$" : (currency ?? "")
@@ -156,4 +202,138 @@ struct CampaignDetailView: View {
156202
}
157203
try? modelContext.save()
158204
}
205+
206+
private func momentumSection(velocity: Double, delta: Double) -> some View {
207+
VStack(alignment: .leading, spacing: 12) {
208+
HStack {
209+
Image(systemName: "bolt.fill")
210+
.foregroundStyle(.orange)
211+
Text("24-Hour Momentum")
212+
.font(.subheadline).fontWeight(.semibold)
213+
Spacer()
214+
momentumBadge(velocity: velocity, delta: delta)
215+
}
216+
217+
if !historyData.isEmpty {
218+
SparklineView(dataPoints: historyData)
219+
} else if isLoadingHistory {
220+
ProgressView()
221+
.frame(height: 60)
222+
.frame(maxWidth: .infinity)
223+
}
224+
225+
HStack(spacing: 16) {
226+
metricCard(
227+
icon: "dollarsign.circle.fill",
228+
label: "24h Change",
229+
value: formatDelta(delta),
230+
color: delta > 0 ? .green : .red
231+
)
232+
metricCard(
233+
icon: "percent",
234+
label: "Growth Rate",
235+
value: String(format: "%.1f%%", velocity),
236+
color: velocity > 0 ? .green : .red
237+
)
238+
}
239+
}
240+
.padding()
241+
.background(Color(.systemGray6))
242+
.clipShape(RoundedRectangle(cornerRadius: 12))
243+
}
244+
245+
private func momentumBadge(velocity: Double, delta: Double) -> some View {
246+
HStack(spacing: 4) {
247+
Image(systemName: velocity > 0 ? "arrow.up.right" : "arrow.down.right")
248+
.font(.system(size: 10))
249+
Text(delta > 0 ? "+\(formatDelta(delta))" : formatDelta(delta))
250+
.font(.caption).fontWeight(.semibold)
251+
}
252+
.padding(.horizontal, 8).padding(.vertical, 4)
253+
.background((velocity > 0 ? Color.green : Color.red).opacity(0.15))
254+
.foregroundStyle(velocity > 0 ? .green : .red)
255+
.clipShape(Capsule())
256+
}
257+
258+
private func metricCard(icon: String, label: String, value: String, color: Color) -> some View {
259+
VStack(alignment: .leading, spacing: 4) {
260+
HStack(spacing: 4) {
261+
Image(systemName: icon)
262+
.font(.system(size: 14))
263+
Text(label)
264+
.font(.caption2)
265+
}
266+
.foregroundStyle(.secondary)
267+
268+
Text(value)
269+
.font(.title3).fontWeight(.bold)
270+
.foregroundStyle(color)
271+
}
272+
.frame(maxWidth: .infinity, alignment: .leading)
273+
.padding()
274+
.background(Color(.systemBackground))
275+
.clipShape(RoundedRectangle(cornerRadius: 8))
276+
}
277+
278+
private func formatDelta(_ amount: Double) -> String {
279+
let absAmount = abs(amount)
280+
if absAmount >= 1_000_000 {
281+
return String(format: "$%.1fM", amount / 1_000_000)
282+
} else if absAmount >= 1_000 {
283+
return String(format: "$%.0fK", amount / 1_000)
284+
}
285+
return "$\(Int(amount))"
286+
}
287+
288+
private func loadHistory() async {
289+
isLoadingHistory = true
290+
defer { isLoadingHistory = false }
291+
292+
do {
293+
let response = try await APIClient.shared.fetchCampaignHistory(pid: campaign.pid, days: 14)
294+
historyData = response.history
295+
} catch {
296+
print("Failed to load history: \(error)")
297+
}
298+
}
299+
}
300+
301+
struct ExpandableBlurbView: View {
302+
let blurb: String
303+
@State private var isExpanded = false
304+
305+
private var shouldShowButton: Bool {
306+
blurb.count > 150
307+
}
308+
309+
var body: some View {
310+
VStack(alignment: .leading, spacing: 8) {
311+
Text("About this project")
312+
.font(.subheadline).fontWeight(.semibold)
313+
314+
Text(blurb)
315+
.font(.subheadline)
316+
.foregroundStyle(.secondary)
317+
.lineLimit(isExpanded ? nil : 3)
318+
319+
if shouldShowButton {
320+
Button {
321+
withAnimation(.easeInOut(duration: 0.2)) {
322+
isExpanded.toggle()
323+
}
324+
} label: {
325+
HStack(spacing: 4) {
326+
Text(isExpanded ? "Show less" : "Read more")
327+
Image(systemName: isExpanded ? "chevron.up" : "chevron.down")
328+
.font(.system(size: 10))
329+
}
330+
.font(.caption).fontWeight(.medium)
331+
.foregroundStyle(.accentColor)
332+
}
333+
}
334+
}
335+
.padding()
336+
.background(Color(.systemGray6))
337+
.clipShape(RoundedRectangle(cornerRadius: 12))
338+
}
159339
}

0 commit comments

Comments
 (0)