diff --git a/PortfolioJournal/Utilities/MonthlyCheckInStore.swift b/PortfolioJournal/Utilities/MonthlyCheckInStore.swift index 353a6fb..9ca81ef 100644 --- a/PortfolioJournal/Utilities/MonthlyCheckInStore.swift +++ b/PortfolioJournal/Utilities/MonthlyCheckInStore.swift @@ -183,8 +183,17 @@ enum MonthlyCheckInStore { let totalCheckIns = completions.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 cursor = referenceDate.startOfMonth + if !onTimeMonths.contains(cursor) { + cursor = cursor.adding(months: -1).startOfMonth + } while onTimeMonths.contains(cursor) { currentStreak += 1 cursor = cursor.adding(months: -1).startOfMonth diff --git a/PortfolioJournalTests/Utilities/MonthlyCheckInStoreTests.swift b/PortfolioJournalTests/Utilities/MonthlyCheckInStoreTests.swift index a37462b..ec55490 100644 --- a/PortfolioJournalTests/Utilities/MonthlyCheckInStoreTests.swift +++ b/PortfolioJournalTests/Utilities/MonthlyCheckInStoreTests.swift @@ -50,6 +50,56 @@ final class MonthlyCheckInStoreTests: XCTestCase { XCTAssertNotNil(januaryEntry?.completionDate) } + // MARK: - Current streak + + /// Completing June–August 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() { let calendar = Calendar(identifier: .gregorian) let oldMonthDate = calendar.date(from: DateComponents(year: 2024, month: 1, day: 10))!