Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions backend/cmd/api/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
30 changes: 30 additions & 0 deletions backend/internal/handler/campaigns.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package handler
import (
"net/http"
"strconv"
"time"

"github.com/gin-gonic/gin"
"github.com/kickwatch/backend/internal/db"
Expand Down Expand Up @@ -136,3 +137,32 @@ 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, err := strconv.Atoi(days)
if err != nil || daysInt < 1 {
daysInt = 14
}
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})
}
32 changes: 32 additions & 0 deletions ios/KickWatch/Sources/Services/APIClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand All @@ -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
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<R: Decodable>(url: URL) async throws -> R {
let (data, response) = try await session.data(from: url)
guard let http = response as? HTTPURLResponse else { throw APIError.invalidResponse }
Expand Down
202 changes: 191 additions & 11 deletions ios/KickWatch/Sources/Views/CampaignDetailView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand All @@ -28,6 +30,9 @@ struct CampaignDetailView: View {
}
.navigationBarTitleDisplayMode(.inline)
.toolbar { toolbarItems }
.task {
await loadHistory()
}
}

private var heroImage: some View {
Expand All @@ -53,6 +58,16 @@ 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,
velocity > 0 || delta != 0 {
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")
Expand All @@ -70,14 +85,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))
Expand All @@ -100,14 +131,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 ?? "")
Expand Down Expand Up @@ -156,4 +202,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))
}
}
Loading