Skip to content
This repository was archived by the owner on Nov 16, 2025. It is now read-only.

Commit 46f24d7

Browse files
committed
feat: fetch all historical data with efficient caching
- Remove 30-day limit for Claude log scanning to get complete history - Fetch 12 months of Cursor invoice data instead of just current month - Add CursorInvoiceCache to cache historical invoices in UserDefaults - Claude already has permanent cache for old log files (only parses once) - Significantly improves performance on subsequent runs by avoiding re-fetching/parsing historical data This ensures users can see their complete usage history while maintaining good performance through intelligent caching strategies.
1 parent 3e145f3 commit 46f24d7

File tree

3 files changed

+195
-51
lines changed

3 files changed

+195
-51
lines changed

VibeMeter/Core/Services/BackgroundDataProcessor.swift

Lines changed: 92 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -62,30 +62,97 @@ actor BackgroundDataProcessor {
6262
teamInfo = ProviderTeamInfo(id: 0, name: "Individual Account", provider: provider)
6363
}
6464

65-
// Calculate current month for up-to-date spending data
65+
// Fetch all available months of data
6666
let calendar = Calendar.current
6767
let currentDate = Date()
68-
let calendarMonth = calendar.component(.month, from: currentDate) // 1-based (1-12)
69-
let month = calendarMonth - 1 // Convert to 0-based for API (0-11)
70-
let year = calendar.component(.year, from: currentDate)
71-
72-
logger
73-
.info(
74-
"""
75-
Requesting invoice data for current month \(month)/\(year) \
76-
(Calendar month \(calendarMonth) -> API month \(month))
77-
""")
78-
79-
// Fetch invoice and usage data concurrently
80-
// Use team ID from team info (or 0 for fallback)
81-
async let invoiceTask = providerClient.fetchMonthlyInvoice(
82-
authToken: authToken,
83-
month: month,
84-
year: year,
85-
teamId: teamInfo.id == 0 ? nil : teamInfo.id) // Use nil for fallback team
68+
let currentYear = calendar.component(.year, from: currentDate)
69+
let currentMonth = calendar.component(.month, from: currentDate) // 1-based (1-12)
70+
71+
// Determine start date for historical data (e.g., 12 months back)
72+
let startDate = calendar.date(byAdding: .month, value: -12, to: currentDate) ?? currentDate
73+
let startYear = calendar.component(.year, from: startDate)
74+
let startMonth = calendar.component(.month, from: startDate) // 1-based (1-12)
75+
76+
logger.info("Fetching historical data from \(startMonth)/\(startYear) to \(currentMonth)/\(currentYear)")
77+
78+
// Collect all invoice tasks
79+
var invoiceTasks: [Task<ProviderMonthlyInvoice?, Never>] = []
80+
var yearMonth = (year: startYear, month: startMonth)
81+
82+
// Get invoice cache for Cursor provider
83+
let invoiceCache = if provider == .cursor {
84+
await CursorInvoiceCache.shared
85+
} else {
86+
nil as CursorInvoiceCache?
87+
}
88+
89+
while (yearMonth.year < currentYear) || (yearMonth.year == currentYear && yearMonth.month <= currentMonth) {
90+
let apiMonth = yearMonth.month - 1 // Convert to 0-based for API (0-11)
91+
let year = yearMonth.year
92+
let effectiveTeamId = teamInfo.id == 0 ? nil : teamInfo.id
93+
94+
let task = Task { () -> ProviderMonthlyInvoice? in
95+
// Check cache first for Cursor provider
96+
if let cache = invoiceCache,
97+
let cachedInvoice = await cache.getCachedInvoice(month: apiMonth, year: year, teamId: effectiveTeamId) {
98+
logger.info("Using cached invoice for \(yearMonth.month)/\(year): \(cachedInvoice.totalSpendingCents) cents")
99+
return cachedInvoice
100+
}
101+
102+
// Fetch from API if not cached
103+
do {
104+
let invoice = try await providerClient.fetchMonthlyInvoice(
105+
authToken: authToken,
106+
month: apiMonth,
107+
year: year,
108+
teamId: effectiveTeamId)
109+
logger.info("Fetched invoice for \(yearMonth.month)/\(year): \(invoice.totalSpendingCents) cents")
110+
111+
// Cache the result for Cursor provider
112+
if let cache = invoiceCache {
113+
await cache.cacheInvoice(invoice, month: apiMonth, year: year, teamId: effectiveTeamId)
114+
}
115+
116+
return invoice
117+
} catch {
118+
logger.warning("Failed to fetch invoice for \(yearMonth.month)/\(year): \(error.localizedDescription)")
119+
return nil
120+
}
121+
}
122+
invoiceTasks.append(task)
123+
124+
// Move to next month
125+
if yearMonth.month == 12 {
126+
yearMonth = (year: yearMonth.year + 1, month: 1)
127+
} else {
128+
yearMonth = (year: yearMonth.year, month: yearMonth.month + 1)
129+
}
130+
}
131+
132+
// Also fetch usage data concurrently
86133
async let usageTask = providerClient.fetchUsageData(authToken: authToken)
87-
88-
let invoice = try await invoiceTask
134+
135+
// Wait for all invoice tasks to complete
136+
var invoices: [ProviderMonthlyInvoice] = []
137+
for task in invoiceTasks {
138+
if let invoice = await task.value {
139+
invoices.append(invoice)
140+
}
141+
}
142+
143+
// Combine all invoices into a single invoice with all items
144+
let allItems = invoices.flatMap { $0.items }
145+
146+
// Use the most recent invoice's pricing description
147+
let latestInvoice = invoices.last(where: { $0.totalSpendingCents > 0 }) ?? invoices.last
148+
149+
let combinedInvoice = ProviderMonthlyInvoice(
150+
items: allItems,
151+
pricingDescription: latestInvoice?.pricingDescription,
152+
provider: provider,
153+
month: currentMonth - 1, // API month (0-based)
154+
year: currentYear
155+
)
89156

90157
// Try to fetch usage data, but don't fail if it's unavailable
91158
let usage: ProviderUsageData
@@ -105,18 +172,18 @@ actor BackgroundDataProcessor {
105172
}
106173

107174
if provider == .claude {
108-
logger.info("Claude: Invoice items: \(invoice.items.count), total: \(invoice.totalSpendingCents) cents")
175+
logger.info("Claude: Total invoice items across all months: \(combinedInvoice.items.count), total: \(combinedInvoice.totalSpendingCents) cents")
109176
logger.info("Claude: Usage data - current: \(usage.currentRequests), max: \(usage.maxRequests ?? 0)")
110-
if let pricing = invoice.pricingDescription {
177+
if let pricing = combinedInvoice.pricingDescription {
111178
logger.info("Claude: Pricing description: \(pricing.description)")
112179
}
113180
}
114181

115-
logger.info("Completed background processing for \(provider.displayName)")
182+
logger.info("Completed background processing for \(provider.displayName) - fetched \(invoices.count) months of data")
116183
return ProviderDataResult(
117184
userInfo: userInfo,
118185
teamInfo: teamInfo,
119-
invoice: invoice,
186+
invoice: combinedInvoice,
120187
usage: usage)
121188
}
122189
}

VibeMeter/Core/Services/Claude/ClaudeLogFileScanner.swift

Lines changed: 2 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,7 @@ final class ClaudeLogFileScanner: @unchecked Sendable {
1111
/// Find all JSONL files in the Claude logs directory
1212
func findJSONLFiles(in directory: URL) -> [URL] {
1313
var jsonlFiles: [URL] = []
14-
let cutoffDate = Date().addingTimeInterval(-30 * 24 * 60 * 60) // 30 days ago
15-
16-
// Date formatter for parsing filenames (optimization #9)
17-
let dateFormatter = DateFormatter()
18-
dateFormatter.dateFormat = "yyyy-MM-dd"
14+
// Removed 30-day limit to get all historical data
1915

2016
logger.debug("Searching for JSONL files in: \(directory.path)")
2117

@@ -24,26 +20,6 @@ final class ClaudeLogFileScanner: @unchecked Sendable {
2420
includingPropertiesForKeys: [.isRegularFileKey, .contentModificationDateKey, .fileSizeKey],
2521
options: [.skipsHiddenFiles, .skipsPackageDescendants]) {
2622
for case let fileURL as URL in enumerator where fileURL.pathExtension == "jsonl" {
27-
let filename = fileURL.lastPathComponent
28-
29-
// Try to extract date from filename first (optimization #9)
30-
if let dateRange = filename.range(of: #"\d{4}-\d{2}-\d{2}"#, options: .regularExpression) {
31-
let dateString = String(filename[dateRange])
32-
if let fileDate = dateFormatter.date(from: dateString),
33-
fileDate < cutoffDate {
34-
logger.trace("Skipping old file based on filename: \(filename)")
35-
continue
36-
}
37-
} else {
38-
// Fall back to modification date if no date in filename
39-
if let attributes = try? fileManager.attributesOfItem(atPath: fileURL.path),
40-
let modificationDate = attributes[.modificationDate] as? Date,
41-
modificationDate < cutoffDate {
42-
logger.trace("Skipping old file: \(fileURL.lastPathComponent)")
43-
continue
44-
}
45-
}
46-
4723
jsonlFiles.append(fileURL)
4824
logger.debug("Found JSONL file: \(fileURL.path)")
4925
}
@@ -60,7 +36,7 @@ final class ClaudeLogFileScanner: @unchecked Sendable {
6036
return date1 > date2
6137
}
6238

63-
logger.info("Found \(jsonlFiles.count) JSONL files (excluding old files)")
39+
logger.info("Found \(jsonlFiles.count) JSONL files (all available history)")
6440
return jsonlFiles
6541
}
6642

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import Foundation
2+
import os.log
3+
4+
/// Manages caching of historical Cursor invoice data
5+
@MainActor
6+
public final class CursorInvoiceCache: @unchecked Sendable {
7+
private let logger = Logger.vibeMeter(category: "CursorInvoiceCache")
8+
private let userDefaults: UserDefaults
9+
10+
// Cache keys
11+
private let cacheKey = "com.vibemeter.cursorInvoiceCache"
12+
private let cacheVersionKey = "com.vibemeter.cursorInvoiceCacheVersion"
13+
14+
// Cache version - increment when format changes
15+
private let currentCacheVersion = 1
16+
17+
// Singleton
18+
public static let shared = CursorInvoiceCache()
19+
20+
private init(userDefaults: UserDefaults = .standard) {
21+
self.userDefaults = userDefaults
22+
23+
// Check cache version
24+
let storedVersion = userDefaults.integer(forKey: cacheVersionKey)
25+
if storedVersion < currentCacheVersion {
26+
logger.info("Cache version outdated, clearing cache")
27+
clearCache()
28+
userDefaults.set(currentCacheVersion, forKey: cacheVersionKey)
29+
}
30+
}
31+
32+
/// Cache entry for a single month's invoice
33+
private struct CacheEntry: Codable {
34+
let invoice: ProviderMonthlyInvoice
35+
let cachedAt: Date
36+
let teamId: Int?
37+
}
38+
39+
/// Get cached invoice for a specific month
40+
public func getCachedInvoice(month: Int, year: Int, teamId: Int?) -> ProviderMonthlyInvoice? {
41+
let key = cacheKey(for: month, year: year, teamId: teamId)
42+
43+
guard let data = userDefaults.data(forKey: key),
44+
let entry = try? JSONDecoder().decode(CacheEntry.self, from: data) else {
45+
return nil
46+
}
47+
48+
// Check if this is the current month - don't use cache for current month
49+
let calendar = Calendar.current
50+
let now = Date()
51+
let currentMonth = calendar.component(.month, from: now) - 1 // 0-based
52+
let currentYear = calendar.component(.year, from: now)
53+
54+
if month == currentMonth && year == currentYear {
55+
logger.debug("Not using cache for current month \(month)/\(year)")
56+
return nil
57+
}
58+
59+
logger.debug("Retrieved cached invoice for \(month + 1)/\(year)")
60+
return entry.invoice
61+
}
62+
63+
/// Cache an invoice for a specific month
64+
public func cacheInvoice(_ invoice: ProviderMonthlyInvoice, month: Int, year: Int, teamId: Int?) {
65+
// Don't cache current month or empty invoices
66+
let calendar = Calendar.current
67+
let now = Date()
68+
let currentMonth = calendar.component(.month, from: now) - 1 // 0-based
69+
let currentYear = calendar.component(.year, from: now)
70+
71+
if (month == currentMonth && year == currentYear) || invoice.totalSpendingCents == 0 {
72+
return
73+
}
74+
75+
let entry = CacheEntry(invoice: invoice, cachedAt: Date(), teamId: teamId)
76+
77+
if let data = try? JSONEncoder().encode(entry) {
78+
let key = cacheKey(for: month, year: year, teamId: teamId)
79+
userDefaults.set(data, forKey: key)
80+
logger.debug("Cached invoice for \(month + 1)/\(year) with \(invoice.items.count) items")
81+
}
82+
}
83+
84+
/// Clear all cached invoices
85+
public func clearCache() {
86+
let keys = userDefaults.dictionaryRepresentation().keys
87+
for key in keys where key.hasPrefix(cacheKey) {
88+
userDefaults.removeObject(forKey: key)
89+
}
90+
logger.info("Cleared all cached invoices")
91+
}
92+
93+
/// Generate cache key for a specific month
94+
private func cacheKey(for month: Int, year: Int, teamId: Int?) -> String {
95+
if let teamId = teamId {
96+
return "\(cacheKey).\(year).\(month).team\(teamId)"
97+
} else {
98+
return "\(cacheKey).\(year).\(month).noteam"
99+
}
100+
}
101+
}

0 commit comments

Comments
 (0)