Fix streak: el mes en curso pendiente ya no rompe la racha (build 88) #47

La tarjeta Momentum & Streaks mostraba "Streak 0x" casi todo el mes
aunque la racha estuviera intacta: stats() empezaba a contar en el mes
en curso y cortaba en el primer mes sin check-in on-time. El check-in
del mes en curso está dentro de plazo hasta fin de mes (y con
graceDays = 20 se hace normalmente ya entrado el mes siguiente), así
que tenerlo pendiente no debe leerse como racha rota.

Ahora el recuento arranca en el mes en curso si ya está hecho y, si no,
en el anterior. Un mes anterior sin check-in sí rompe la racha, porque
su plazo ya venció.

Tests: 3 casos nuevos en MonthlyCheckInStoreTests (mes en curso
pendiente, mes en curso completado, mes anterior perdido).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YcDn5ccuRFV83q7xWWokBT
This commit is contained in:
alexandrev-tibco
2026-09-12 16:19:46 +02:00
parent d88cc59627
commit 167b736aad
2 changed files with 59 additions and 0 deletions
@@ -183,8 +183,17 @@ enum MonthlyCheckInStore {
let totalCheckIns = completions.count let totalCheckIns = completions.count
let onTimeCount = onTimeMonths.count let onTimeCount = onTimeMonths.count
// The current month's check-in is still within its deadline (end of the
// month), so having it pending must NOT read as a broken streak that
// made the card show 0x for most of every month. Start counting at the
// current month when it is already done, otherwise at the previous one.
// Any earlier month that is missing has passed its deadline and does
// break the streak, so the walk backwards stays honest.
var currentStreak = 0 var currentStreak = 0
var cursor = referenceDate.startOfMonth var cursor = referenceDate.startOfMonth
if !onTimeMonths.contains(cursor) {
cursor = cursor.adding(months: -1).startOfMonth
}
while onTimeMonths.contains(cursor) { while onTimeMonths.contains(cursor) {
currentStreak += 1 currentStreak += 1
cursor = cursor.adding(months: -1).startOfMonth cursor = cursor.adding(months: -1).startOfMonth
@@ -50,6 +50,56 @@ final class MonthlyCheckInStoreTests: XCTestCase {
XCTAssertNotNil(januaryEntry?.completionDate) XCTAssertNotNil(januaryEntry?.completionDate)
} }
// MARK: - Current streak
/// Completing JuneAugust and looking at the card on 12 September (the
/// September check-in is still within its deadline) must show a live streak,
/// not 0x.
func testCurrentStreakCountsWhenCurrentMonthIsStillPending() {
let calendar = Calendar(identifier: .gregorian)
for month in 6...8 {
let monthDate = calendar.date(from: DateComponents(year: 2026, month: month, day: 15))!
let completion = calendar.date(from: DateComponents(year: 2026, month: month, day: 28))!
MonthlyCheckInStore.setCompletionDate(completion, for: monthDate)
}
let referenceDate = calendar.date(from: DateComponents(year: 2026, month: 9, day: 12))!
let stats = MonthlyCheckInStore.stats(referenceDate: referenceDate)
XCTAssertEqual(stats.currentStreak, 3)
}
/// The current month being pending is forgiven; an earlier month whose
/// deadline already passed is not.
func testCurrentStreakBreaksWhenPreviousMonthWasMissed() {
let calendar = Calendar(identifier: .gregorian)
for month in 6...8 {
let monthDate = calendar.date(from: DateComponents(year: 2026, month: month, day: 15))!
let completion = calendar.date(from: DateComponents(year: 2026, month: month, day: 28))!
MonthlyCheckInStore.setCompletionDate(completion, for: monthDate)
}
// September was never checked in and October is under way.
let referenceDate = calendar.date(from: DateComponents(year: 2026, month: 10, day: 12))!
let stats = MonthlyCheckInStore.stats(referenceDate: referenceDate)
XCTAssertEqual(stats.currentStreak, 0)
}
func testCurrentStreakCountsCurrentMonthWhenAlreadyCompleted() {
let calendar = Calendar(identifier: .gregorian)
for month in 7...9 {
let monthDate = calendar.date(from: DateComponents(year: 2026, month: month, day: 15))!
let completion = calendar.date(from: DateComponents(year: 2026, month: month, day: 28))!
MonthlyCheckInStore.setCompletionDate(completion, for: monthDate)
}
let referenceDate = calendar.date(from: DateComponents(year: 2026, month: 9, day: 29))!
let stats = MonthlyCheckInStore.stats(referenceDate: referenceDate)
XCTAssertEqual(stats.currentStreak, 3)
}
func testSetCompletionDateForVeryOldMonthStillCompletesEntry() { func testSetCompletionDateForVeryOldMonthStillCompletesEntry() {
let calendar = Calendar(identifier: .gregorian) let calendar = Calendar(identifier: .gregorian)
let oldMonthDate = calendar.date(from: DateComponents(year: 2024, month: 1, day: 10))! let oldMonthDate = calendar.date(from: DateComponents(year: 2024, month: 1, day: 10))!