Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions Normal/Services/UsageLimitService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,10 @@ final class UsageLimitService {
logger.error("Dropped \(limits.count - dtos.count, privacy: .public) limit(s) that failed to encode")
}
sharedStore.saveUsageLimits(dtos)
let previous = sharedStore.loadUsageLimitConfig()
if previous.resetMinutes != config.resetMinutes {
sharedStore.moveUsageDay(from: previous.period, to: config.period, on: date)
}
sharedStore.saveUsageLimitConfig(config)
syncShields(on: date)

Expand Down
16 changes: 16 additions & 0 deletions NormalTests/Services/UsageLimitServiceTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,22 @@ struct UsageLimitServiceTests {
#expect(h.shield.shieldedLimitIDs.isEmpty)
}

@Test func movingTheResetPastTheCurrentTimeLiftsASpentLimitAtTheNewReset() {
let h = makeService()
let limit = UsageLimit(minutesPerDay: 30)
let midnight = Calendar.current.startOfDay(for: day)
let spentAt = midnight + .hours(15)
h.service.registerAll([limit], config: config, on: spentAt)
h.store.recordUsageState(.reached, for: limit.id, on: spentAt)

let later = UsageLimitConfig(resetMinutes: 16 * 60)
h.service.registerAll([limit], config: later, on: spentAt + .minutes(30))
#expect(h.shield.shieldedLimitIDs == [limit.id])

h.service.registerAll([limit], config: later, on: midnight + .hours(16) + .minutes(1))
#expect(h.shield.shieldedLimitIDs.isEmpty)
}

@Test func deletingASpentLimitLiftsItsShield() {
let h = makeService()
let limit = UsageLimit(minutesPerDay: 30)
Expand Down
44 changes: 41 additions & 3 deletions NormalTests/Shared/UsageDTOTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -233,15 +233,53 @@ struct UsageDTOTests {
#expect(state.state(for: id, on: date("2026-03-15T05:00:00-04:00"), period: later) == .under)
}

@Test func movingTheResetEarlierCannotRefundASpentRecord() {
@Test func movingTheResetEarlierReleasesASpentRecordAtTheNewReset() {
let noon = date("2026-03-14T12:00:00-04:00")
let later = newYorkPeriod(resetAt: 4)
let state = UsageDayStateDTO
.fresh(on: noon, period: later)
.recording(.reached, for: id, on: noon, period: later)
.moving(from: later, to: nyPeriod, on: noon + .hours(1))

#expect(state.state(for: id, on: date("2026-03-15T01:00:00-04:00"), period: nyPeriod) == .reached)
#expect(state.state(for: id, on: date("2026-03-15T09:00:00-04:00"), period: nyPeriod) == .under)
#expect(state.state(for: id, on: date("2026-03-14T23:00:00-04:00"), period: nyPeriod) == .reached)
#expect(state.state(for: id, on: date("2026-03-15T00:30:00-04:00"), period: nyPeriod) == .under)
}

@Test func movingTheResetPastTheCurrentTimeReleasesASpentRecordAtTheNewReset() {
let spentAt = date("2026-03-14T15:00:00-04:00")
let later = newYorkPeriod(resetAt: 16)
let state = UsageDayStateDTO
.fresh(on: spentAt, period: nyPeriod)
.recording(.reached, for: id, on: spentAt, period: nyPeriod)
.moving(from: nyPeriod, to: later, on: date("2026-03-14T15:30:00-04:00"))

#expect(state.state(for: id, on: date("2026-03-14T15:59:00-04:00"), period: later) == .reached)
#expect(state.state(for: id, on: date("2026-03-14T16:01:00-04:00"), period: later) == .under)
}

@Test func movingTheResetDoesNotReviveAnExpiredRecord() {
let yesterday = date("2026-03-13T12:00:00-04:00")
let now = date("2026-03-14T15:30:00-04:00")
let later = newYorkPeriod(resetAt: 16)
let state = UsageDayStateDTO
.fresh(on: yesterday, period: nyPeriod)
.recording(.reached, for: id, on: yesterday, period: nyPeriod)
.moving(from: nyPeriod, to: later, on: now)

#expect(state.state(for: id, on: now, period: later) == .under)
}

@Test func movingTheResetKeepsTheOverride() {
let noon = date("2026-03-14T12:00:00-04:00")
let later = newYorkPeriod(resetAt: 16)
let state = UsageDayStateDTO
.fresh(on: noon, period: nyPeriod)
.overriding(on: noon, period: nyPeriod)
.moving(from: nyPeriod, to: later, on: noon)

#expect(state.isOverridden)
#expect(state.isStale(on: date("2026-03-14T15:59:00-04:00"), period: later) == false)
#expect(state.isStale(on: date("2026-03-14T16:01:00-04:00"), period: later))
}

@Test func aReportClaimingMoreUseThanTimeSinceTheResetIsImplausible() {
Expand Down
4 changes: 4 additions & 0 deletions Shared/SharedStoreProviding.swift
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,10 @@ extension SharedStoreProviding {
saveUsageDayState(.fresh(on: date, period: period))
}

func moveUsageDay(from old: UsagePeriod, to new: UsagePeriod, on date: Date = .now) {
mutateUsageDayState { $0.moving(from: old, to: new, on: date) }
}

func overrideUsageDay(on date: Date = .now) {
saveUsageDayState(loadUsageDayState().overriding(on: date, period: usagePeriod))
}
Expand Down
8 changes: 8 additions & 0 deletions Shared/UsageDTO.swift
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,14 @@ nonisolated struct UsageDayStateDTO: Codable, Sendable, Equatable {
return updated
}

func moving(from old: UsagePeriod, to new: UsagePeriod, on date: Date) -> Self {
guard !isStale(on: date, period: old) else { return .fresh(on: date, period: new) }
var updated = self
updated.dayKey = new.key(for: date)
updated.encodedExpiresAt = new.nextReset(after: date)
return updated
}

func overriding(on date: Date, period: UsagePeriod = UsagePeriod()) -> Self {
var updated = Self.fresh(on: date, period: period)
updated.encodedIsOverridden = true
Expand Down
Loading