Skip to content

Commit d7ffac8

Browse files
authored
fix: use local calendar days for stats streaks (#168)
1 parent e85eb9c commit d7ffac8

2 files changed

Lines changed: 103 additions & 18 deletions

File tree

Sources/VocaMac/Services/StatsManager.swift

Lines changed: 21 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -18,14 +18,6 @@ class StatsManager: StatsManaging, ObservableObject {
1818
private let statsFileURL: URL
1919
private let calendar: Calendar
2020

21-
private static let dateFormatter: DateFormatter = {
22-
let formatter = DateFormatter()
23-
formatter.dateFormat = "yyyy-MM-dd"
24-
formatter.locale = Locale(identifier: "en_US_POSIX")
25-
formatter.timeZone = TimeZone(secondsFromGMT: 0)
26-
return formatter
27-
}()
28-
2921
init(statsFileURL: URL? = nil, calendar: Calendar = .current) {
3022
if let statsFileURL {
3123
self.statsFileURL = statsFileURL
@@ -76,7 +68,7 @@ class StatsManager: StatsManaging, ObservableObject {
7668
.filter { !$0.isEmpty }
7769
.count
7870

79-
let dateKey = Self.dateFormatter.string(from: transcription.timestamp)
71+
let dateKey = dayKey(for: transcription.timestamp)
8072

8173
// Update basic counts
8274
stats.totalWords += words
@@ -100,6 +92,14 @@ class StatsManager: StatsManaging, ObservableObject {
10092
saveStats()
10193
}
10294

95+
private func dayKey(for date: Date) -> String {
96+
let components = calendar.dateComponents([.year, .month, .day], from: date)
97+
guard let year = components.year, let month = components.month, let day = components.day else {
98+
return "unknown"
99+
}
100+
return String(format: "%04d-%02d-%02d", year, month, day)
101+
}
102+
103103
private func updateStreaks(currentDate: Date) {
104104
guard let lastDate = stats.lastUsageDate else {
105105
// First time usage
@@ -108,16 +108,19 @@ class StatsManager: StatsManaging, ObservableObject {
108108
return
109109
}
110110

111-
// Check if last usage was yesterday
112-
if calendar.isDateInYesterday(lastDate) {
111+
let lastDay = calendar.startOfDay(for: lastDate)
112+
let currentDay = calendar.startOfDay(for: currentDate)
113+
let daysBetween = calendar.dateComponents([.day], from: lastDay, to: currentDay).day
114+
115+
switch daysBetween {
116+
case 0:
117+
// Already used on this calendar day, streak remains same
118+
break
119+
case 1:
113120
// Continuation of streak
114-
if !calendar.isDate(lastDate, inSameDayAs: currentDate) {
115-
stats.currentStreak += 1
116-
}
117-
} else if calendar.isDate(lastDate, inSameDayAs: currentDate) {
118-
// Already used today, streak remains same
119-
} else {
120-
// Streak broken
121+
stats.currentStreak += 1
122+
default:
123+
// Streak broken or older transcription recorded out of order
121124
stats.currentStreak = 1
122125
}
123126

Tests/VocaMacTests/StatsManagerTests.swift

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,88 @@ final class StatsManagerTests: XCTestCase {
128128
XCTAssertEqual(statsManager.stats.currentStreak, 1, "Streak should reset after a gap")
129129
}
130130

131+
@MainActor
132+
func testDailyBucketsUseInjectedCalendarTimeZone() {
133+
var calendar = Calendar(identifier: .gregorian)
134+
calendar.timeZone = TimeZone(secondsFromGMT: 2 * 60 * 60)!
135+
statsManager = StatsManager(statsFileURL: tempFileURL, calendar: calendar)
136+
137+
let nearMidnightUTC = Date(timeIntervalSince1970: 1_704_060_000) // 2023-12-31 22:00:00 UTC, 2024-01-01 in GMT+2
138+
let transcription = VocaTranscription(
139+
text: "local day",
140+
duration: 1.0,
141+
detectedLanguage: "en",
142+
audioLengthSeconds: 1.0,
143+
modelUsed: .tiny,
144+
timestamp: nearMidnightUTC
145+
)
146+
147+
statsManager.recordTranscription(transcription)
148+
149+
XCTAssertEqual(statsManager.stats.dailyWordCounts["2024-01-01"], 2)
150+
XCTAssertNil(statsManager.stats.dailyWordCounts["2023-12-31"])
151+
}
152+
153+
@MainActor
154+
func testStreakUsesTranscriptionDateRatherThanCurrentDate() {
155+
var calendar = Calendar(identifier: .gregorian)
156+
calendar.timeZone = TimeZone(secondsFromGMT: 0)!
157+
statsManager = StatsManager(statsFileURL: tempFileURL, calendar: calendar)
158+
159+
let firstDay = Date(timeIntervalSince1970: 946_684_800) // 2000-01-01 00:00:00 UTC
160+
let secondDay = Date(timeIntervalSince1970: 946_771_200) // 2000-01-02 00:00:00 UTC
161+
162+
statsManager.recordTranscription(VocaTranscription(
163+
text: "first day",
164+
duration: 1.0,
165+
detectedLanguage: "en",
166+
audioLengthSeconds: 1.0,
167+
modelUsed: .tiny,
168+
timestamp: firstDay
169+
))
170+
statsManager.recordTranscription(VocaTranscription(
171+
text: "second day",
172+
duration: 1.0,
173+
detectedLanguage: "en",
174+
audioLengthSeconds: 1.0,
175+
modelUsed: .tiny,
176+
timestamp: secondDay
177+
))
178+
179+
XCTAssertEqual(statsManager.stats.currentStreak, 2)
180+
XCTAssertEqual(statsManager.stats.bestStreak, 2)
181+
}
182+
183+
@MainActor
184+
func testSameDayTranscriptionsDoNotIncrementStreak() {
185+
var calendar = Calendar(identifier: .gregorian)
186+
calendar.timeZone = TimeZone(secondsFromGMT: 0)!
187+
statsManager = StatsManager(statsFileURL: tempFileURL, calendar: calendar)
188+
189+
let first = Date(timeIntervalSince1970: 946_684_800) // 2000-01-01 00:00:00 UTC
190+
let second = Date(timeIntervalSince1970: 946_728_000) // 2000-01-01 12:00:00 UTC
191+
192+
statsManager.recordTranscription(VocaTranscription(
193+
text: "morning words",
194+
duration: 1.0,
195+
detectedLanguage: "en",
196+
audioLengthSeconds: 1.0,
197+
modelUsed: .tiny,
198+
timestamp: first
199+
))
200+
statsManager.recordTranscription(VocaTranscription(
201+
text: "afternoon words",
202+
duration: 1.0,
203+
detectedLanguage: "en",
204+
audioLengthSeconds: 1.0,
205+
modelUsed: .tiny,
206+
timestamp: second
207+
))
208+
209+
XCTAssertEqual(statsManager.stats.currentStreak, 1)
210+
XCTAssertEqual(statsManager.stats.bestStreak, 1)
211+
}
212+
131213
@MainActor
132214
func testResetStats() {
133215
let transcription = VocaTranscription(text: "Test", duration: 1.0, detectedLanguage: "en", audioLengthSeconds: 1.0, modelUsed: .tiny)

0 commit comments

Comments
 (0)