From 13fab5f49e29da0b95760ecd77f43b185b3c9046 Mon Sep 17 00:00:00 2001 From: Yilin Jing Date: Fri, 27 Feb 2026 21:09:21 +0800 Subject: [PATCH 1/2] feat(#28): sparkline chart for campaign funding momentum - Backend: add snapshot_date + backers_count to CampaignSnapshot, unique index on (campaign_pid, snapshot_date) for one-row-per-day dedup - Backend: fix storeSnapshots() to upsert (ON CONFLICT) instead of blind insert - Backend: add GET /api/campaigns/:pid/history endpoint - iOS: add CampaignSnapshotDTO + APIClient.fetchCampaignHistory() - iOS: SparklineView using Swift Charts (line + area, green/orange by trend) - iOS: integrate SparklineView into CampaignRowView (right column above heart) --- backend/cmd/api/main.go | 1 + backend/internal/handler/campaigns.go | 18 +++++ backend/internal/model/model.go | 9 ++- backend/internal/service/cron.go | 20 ++++- .../Sources/Services/APIClient.swift | 12 +++ .../Sources/Views/CampaignRowView.swift | 9 ++- .../Sources/Views/SparklineView.swift | 75 +++++++++++++++++++ 7 files changed, 136 insertions(+), 8 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..17969f6 100644 --- a/backend/internal/handler/campaigns.go +++ b/backend/internal/handler/campaigns.go @@ -115,6 +115,24 @@ func GetCampaign(c *gin.Context) { c.JSON(http.StatusOK, campaign) } +// GetCampaignHistory returns daily pledge snapshots for a campaign, oldest first. +func GetCampaignHistory(c *gin.Context) { + pid := c.Param("pid") + if !db.IsEnabled() { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "database not available"}) + return + } + var snapshots []model.CampaignSnapshot + if err := db.DB. + Where("campaign_pid = ?", pid). + Order("snapshot_date ASC"). + Find(&snapshots).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, snapshots) +} + func ListCategories(client *service.KickstarterScrapingService) gin.HandlerFunc { return func(c *gin.Context) { if db.IsEnabled() { diff --git a/backend/internal/model/model.go b/backend/internal/model/model.go index 3a0d532..fa02ec0 100644 --- a/backend/internal/model/model.go +++ b/backend/internal/model/model.go @@ -31,11 +31,14 @@ type Campaign struct { } type CampaignSnapshot struct { - ID uuid.UUID `gorm:"type:uuid;primaryKey" json:"id"` - CampaignPID string `gorm:"index;not null" json:"campaign_pid"` + ID uuid.UUID `gorm:"type:uuid;primaryKey" json:"id"` + CampaignPID string `gorm:"not null;uniqueIndex:idx_campaign_snapshot_date" json:"campaign_pid"` + // SnapshotDate is the UTC calendar day; one row per (campaign, day). + SnapshotDate time.Time `gorm:"type:date;not null;uniqueIndex:idx_campaign_snapshot_date" json:"snapshot_date"` PledgedAmount float64 `json:"pledged_amount"` PercentFunded float64 `json:"percent_funded"` - SnapshotAt time.Time `gorm:"index;not null;default:now()" json:"snapshot_at"` + BackersCount int `gorm:"default:0" json:"backers_count"` + SnapshotAt time.Time `gorm:"not null;default:now()" json:"snapshot_at"` } func (s *CampaignSnapshot) BeforeCreate(tx *gorm.DB) error { diff --git a/backend/internal/service/cron.go b/backend/internal/service/cron.go index 286e381..51a63ac 100644 --- a/backend/internal/service/cron.go +++ b/backend/internal/service/cron.go @@ -233,18 +233,32 @@ func (s *CronService) RunBackfill() error { } func (s *CronService) storeSnapshots(campaigns []model.Campaign) { + now := time.Now().UTC() + // Truncate to midnight UTC — one row per (campaign, calendar day). + today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC) + snapshots := make([]model.CampaignSnapshot, 0, len(campaigns)) - now := time.Now() for _, c := range campaigns { snapshots = append(snapshots, model.CampaignSnapshot{ CampaignPID: c.PID, + SnapshotDate: today, PledgedAmount: c.PledgedAmount, PercentFunded: c.PercentFunded, + BackersCount: c.BackersCount, SnapshotAt: now, }) } - if err := s.db.Create(&snapshots).Error; err != nil { - log.Printf("Cron: snapshot insert error: %v", err) + // ON CONFLICT (campaign_pid, snapshot_date): update to latest values from this crawl. + result := s.db.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "campaign_pid"}, {Name: "snapshot_date"}}, + DoUpdates: clause.AssignmentColumns([]string{ + "pledged_amount", "percent_funded", "backers_count", "snapshot_at", + }), + }).Create(&snapshots) + if result.Error != nil { + log.Printf("Cron: snapshot upsert error: %v", result.Error) + } else { + log.Printf("Cron: upserted %d snapshots for %s", len(snapshots), today.Format("2006-01-02")) } } diff --git a/ios/KickWatch/Sources/Services/APIClient.swift b/ios/KickWatch/Sources/Services/APIClient.swift index 8ee9b46..ba090c4 100644 --- a/ios/KickWatch/Sources/Services/APIClient.swift +++ b/ios/KickWatch/Sources/Services/APIClient.swift @@ -38,6 +38,14 @@ struct SearchResponse: Codable { let next_cursor: String? } +struct CampaignSnapshotDTO: Codable { + let campaign_pid: String + let snapshot_date: String + let pledged_amount: Double + let percent_funded: Double + let backers_count: Int? +} + struct RegisterDeviceRequest: Codable { let device_token: String } @@ -155,6 +163,10 @@ actor APIClient { } } + func fetchCampaignHistory(pid: String) async throws -> [CampaignSnapshotDTO] { + return try await get(url: URL(string: baseURL + "/api/campaigns/\(pid)/history")!) + } + func fetchAlertMatches(alertID: String) async throws -> [CampaignDTO] { let url = URL(string: baseURL + "/api/alerts/\(alertID)/matches")! return try await get(url: url) diff --git a/ios/KickWatch/Sources/Views/CampaignRowView.swift b/ios/KickWatch/Sources/Views/CampaignRowView.swift index 96d3828..3e90b66 100644 --- a/ios/KickWatch/Sources/Views/CampaignRowView.swift +++ b/ios/KickWatch/Sources/Views/CampaignRowView.swift @@ -13,8 +13,12 @@ struct CampaignRowView: View { HStack(spacing: 12) { thumbnail info - Spacer() - watchButton + Spacer(minLength: 4) + VStack(spacing: 6) { + SparklineView(pid: campaign.pid) + watchButton + } + .padding(.trailing, 16) } .padding(.vertical, 10) .padding(.leading, 16) @@ -90,6 +94,7 @@ struct CampaignRowView: View { toggleWatch() } label: { Image(systemName: isWatched ? "heart.fill" : "heart") + .font(.system(size: 14)) .foregroundStyle(isWatched ? .red : .secondary) } .buttonStyle(.plain) diff --git a/ios/KickWatch/Sources/Views/SparklineView.swift b/ios/KickWatch/Sources/Views/SparklineView.swift new file mode 100644 index 0000000..39c5802 --- /dev/null +++ b/ios/KickWatch/Sources/Views/SparklineView.swift @@ -0,0 +1,75 @@ +import SwiftUI +import Charts + +struct SparklineView: View { + let pid: String + + @State private var snapshots: [CampaignSnapshotDTO] = [] + @State private var isLoading = true + + private static let dateFormatter: ISO8601DateFormatter = { + let f = ISO8601DateFormatter() + f.formatOptions = [.withFullDate] + return f + }() + + var body: some View { + Group { + if snapshots.count >= 2 { + chart + } else if isLoading { + Color.clear + } else { + Color.clear + } + } + .frame(width: 64, height: 28) + .task(id: pid) { + isLoading = true + snapshots = (try? await APIClient.shared.fetchCampaignHistory(pid: pid)) ?? [] + isLoading = false + } + } + + private var chart: some View { + Chart(indexedSnapshots, id: \.index) { item in + LineMark( + x: .value("Day", item.index), + y: .value("Pledged", item.snapshot.pledged_amount) + ) + .foregroundStyle(lineColor) + .lineStyle(StrokeStyle(lineWidth: 1.5)) + + AreaMark( + x: .value("Day", item.index), + yStart: .value("Base", minValue), + yEnd: .value("Pledged", item.snapshot.pledged_amount) + ) + .foregroundStyle(lineColor.opacity(0.15)) + } + .chartXAxis(.hidden) + .chartYAxis(.hidden) + .chartXScale(domain: 0...(indexedSnapshots.count - 1)) + .chartYScale(domain: minValue...maxValue) + } + + private var indexedSnapshots: [(index: Int, snapshot: CampaignSnapshotDTO)] { + snapshots.enumerated().map { (index: $0.offset, snapshot: $0.element) } + } + + private var minValue: Double { + (snapshots.map(\.pledged_amount).min() ?? 0) * 0.95 + } + + private var maxValue: Double { + let max = snapshots.map(\.pledged_amount).max() ?? 1 + return max * 1.05 + } + + private var lineColor: Color { + guard snapshots.count >= 2 else { return .accentColor } + let first = snapshots.first!.pledged_amount + let last = snapshots.last!.pledged_amount + return last >= first ? Color.green : Color.orange + } +} From 49d7d7aebe1ee94d3bfb10c3eebad47e2bb798b7 Mon Sep 17 00:00:00 2001 From: Yilin Jing Date: Fri, 27 Feb 2026 21:17:21 +0800 Subject: [PATCH 2/2] fix(#28): safe campaign_snapshots migration + history cache to fix Codex review issues MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - [High] Add pre-AutoMigrate DO block that adds snapshot_date as nullable, backfills DATE(snapshot_at), deduplicates existing rows, then sets NOT NULL — prevents ADD COLUMN NOT NULL failure on existing prod table - [Medium] Add 5-min in-memory history cache in APIClient (actor-isolated) so SparklineView scroll reuse no longer fires N duplicate /history requests --- backend/internal/db/db.go | 43 +++++++++++++++++++ .../Sources/Services/APIClient.swift | 11 ++++- 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/backend/internal/db/db.go b/backend/internal/db/db.go index 0d20e4f..783e8e4 100644 --- a/backend/internal/db/db.go +++ b/backend/internal/db/db.go @@ -35,6 +35,49 @@ func Init(cfg *config.Config) error { sqlDB.SetMaxOpenConns(20) sqlDB.SetConnMaxLifetime(time.Hour) + // Pre-migration: make campaign_snapshots safe for AutoMigrate. + // AutoMigrate would fail trying to ADD COLUMN snapshot_date NOT NULL on an + // existing table with rows. We handle it here before AutoMigrate runs. + if err := DB.Exec(` + DO $$ + BEGIN + -- Only needed when the table already exists (subsequent deploys). + IF EXISTS ( + SELECT 1 FROM information_schema.tables + WHERE table_name = 'campaign_snapshots' + ) THEN + -- Add snapshot_date as nullable if missing, backfill from snapshot_at. + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'campaign_snapshots' AND column_name = 'snapshot_date' + ) THEN + ALTER TABLE campaign_snapshots ADD COLUMN snapshot_date date; + UPDATE campaign_snapshots SET snapshot_date = DATE(snapshot_at); + END IF; + + -- Add backers_count if missing (added alongside snapshot_date). + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'campaign_snapshots' AND column_name = 'backers_count' + ) THEN + ALTER TABLE campaign_snapshots ADD COLUMN backers_count int DEFAULT 0; + END IF; + + -- Deduplicate: keep only the latest snapshot per (campaign_pid, snapshot_date). + DELETE FROM campaign_snapshots a USING campaign_snapshots b + WHERE a.campaign_pid = b.campaign_pid + AND a.snapshot_date = b.snapshot_date + AND a.snapshot_at < b.snapshot_at; + + -- Set NOT NULL now that all rows have a value. + ALTER TABLE campaign_snapshots ALTER COLUMN snapshot_date SET NOT NULL; + END IF; + END + $$; + `).Error; err != nil { + return fmt.Errorf("pre-migrate campaign_snapshots: %w", err) + } + if err := DB.AutoMigrate( &model.Campaign{}, &model.CampaignSnapshot{}, diff --git a/ios/KickWatch/Sources/Services/APIClient.swift b/ios/KickWatch/Sources/Services/APIClient.swift index ba090c4..3799b82 100644 --- a/ios/KickWatch/Sources/Services/APIClient.swift +++ b/ios/KickWatch/Sources/Services/APIClient.swift @@ -103,6 +103,10 @@ actor APIClient { private let baseURL: String private let session: URLSession + // History cache: pid → (snapshots, fetchedAt). Actor isolation makes this thread-safe. + private var historyCache: [String: (data: [CampaignSnapshotDTO], fetchedAt: Date)] = [:] + private let historyCacheTTL: TimeInterval = 300 // 5 minutes + init(baseURL: String? = nil) { #if DEBUG self.baseURL = baseURL ?? "https://api-dev.kickwatch.rescience.com" @@ -164,7 +168,12 @@ actor APIClient { } func fetchCampaignHistory(pid: String) async throws -> [CampaignSnapshotDTO] { - return try await get(url: URL(string: baseURL + "/api/campaigns/\(pid)/history")!) + if let cached = historyCache[pid], Date().timeIntervalSince(cached.fetchedAt) < historyCacheTTL { + return cached.data + } + let result: [CampaignSnapshotDTO] = try await get(url: URL(string: baseURL + "/api/campaigns/\(pid)/history")!) + historyCache[pid] = (data: result, fetchedAt: Date()) + return result } func fetchAlertMatches(alertID: String) async throws -> [CampaignDTO] {