Skip to content

Commit 54da118

Browse files
authored
Merge pull request #61 from normalengineering/fix/usage-limit-reset
fix: usage limit-reset
2 parents 4d5aab6 + 2b33a69 commit 54da118

5 files changed

Lines changed: 73 additions & 3 deletions

File tree

‎Normal/Services/UsageLimitService.swift‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,10 @@ final class UsageLimitService {
4949
logger.error("Dropped \(limits.count - dtos.count, privacy: .public) limit(s) that failed to encode")
5050
}
5151
sharedStore.saveUsageLimits(dtos)
52+
let previous = sharedStore.loadUsageLimitConfig()
53+
if previous.resetMinutes != config.resetMinutes {
54+
sharedStore.moveUsageDay(from: previous.period, to: config.period, on: date)
55+
}
5256
sharedStore.saveUsageLimitConfig(config)
5357
syncShields(on: date)
5458

‎NormalTests/Services/UsageLimitServiceTests.swift‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,22 @@ struct UsageLimitServiceTests {
230230
#expect(h.shield.shieldedLimitIDs.isEmpty)
231231
}
232232

233+
@Test func movingTheResetPastTheCurrentTimeLiftsASpentLimitAtTheNewReset() {
234+
let h = makeService()
235+
let limit = UsageLimit(minutesPerDay: 30)
236+
let midnight = Calendar.current.startOfDay(for: day)
237+
let spentAt = midnight + .hours(15)
238+
h.service.registerAll([limit], config: config, on: spentAt)
239+
h.store.recordUsageState(.reached, for: limit.id, on: spentAt)
240+
241+
let later = UsageLimitConfig(resetMinutes: 16 * 60)
242+
h.service.registerAll([limit], config: later, on: spentAt + .minutes(30))
243+
#expect(h.shield.shieldedLimitIDs == [limit.id])
244+
245+
h.service.registerAll([limit], config: later, on: midnight + .hours(16) + .minutes(1))
246+
#expect(h.shield.shieldedLimitIDs.isEmpty)
247+
}
248+
233249
@Test func deletingASpentLimitLiftsItsShield() {
234250
let h = makeService()
235251
let limit = UsageLimit(minutesPerDay: 30)

‎NormalTests/Shared/UsageDTOTests.swift‎

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -233,15 +233,53 @@ struct UsageDTOTests {
233233
#expect(state.state(for: id, on: date("2026-03-15T05:00:00-04:00"), period: later) == .under)
234234
}
235235

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

243-
#expect(state.state(for: id, on: date("2026-03-15T01:00:00-04:00"), period: nyPeriod) == .reached)
244-
#expect(state.state(for: id, on: date("2026-03-15T09:00:00-04:00"), period: nyPeriod) == .under)
244+
#expect(state.state(for: id, on: date("2026-03-14T23:00:00-04:00"), period: nyPeriod) == .reached)
245+
#expect(state.state(for: id, on: date("2026-03-15T00:30:00-04:00"), period: nyPeriod) == .under)
246+
}
247+
248+
@Test func movingTheResetPastTheCurrentTimeReleasesASpentRecordAtTheNewReset() {
249+
let spentAt = date("2026-03-14T15:00:00-04:00")
250+
let later = newYorkPeriod(resetAt: 16)
251+
let state = UsageDayStateDTO
252+
.fresh(on: spentAt, period: nyPeriod)
253+
.recording(.reached, for: id, on: spentAt, period: nyPeriod)
254+
.moving(from: nyPeriod, to: later, on: date("2026-03-14T15:30:00-04:00"))
255+
256+
#expect(state.state(for: id, on: date("2026-03-14T15:59:00-04:00"), period: later) == .reached)
257+
#expect(state.state(for: id, on: date("2026-03-14T16:01:00-04:00"), period: later) == .under)
258+
}
259+
260+
@Test func movingTheResetDoesNotReviveAnExpiredRecord() {
261+
let yesterday = date("2026-03-13T12:00:00-04:00")
262+
let now = date("2026-03-14T15:30:00-04:00")
263+
let later = newYorkPeriod(resetAt: 16)
264+
let state = UsageDayStateDTO
265+
.fresh(on: yesterday, period: nyPeriod)
266+
.recording(.reached, for: id, on: yesterday, period: nyPeriod)
267+
.moving(from: nyPeriod, to: later, on: now)
268+
269+
#expect(state.state(for: id, on: now, period: later) == .under)
270+
}
271+
272+
@Test func movingTheResetKeepsTheOverride() {
273+
let noon = date("2026-03-14T12:00:00-04:00")
274+
let later = newYorkPeriod(resetAt: 16)
275+
let state = UsageDayStateDTO
276+
.fresh(on: noon, period: nyPeriod)
277+
.overriding(on: noon, period: nyPeriod)
278+
.moving(from: nyPeriod, to: later, on: noon)
279+
280+
#expect(state.isOverridden)
281+
#expect(state.isStale(on: date("2026-03-14T15:59:00-04:00"), period: later) == false)
282+
#expect(state.isStale(on: date("2026-03-14T16:01:00-04:00"), period: later))
245283
}
246284

247285
@Test func aReportClaimingMoreUseThanTimeSinceTheResetIsImplausible() {

‎Shared/SharedStoreProviding.swift‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,10 @@ extension SharedStoreProviding {
7878
saveUsageDayState(.fresh(on: date, period: period))
7979
}
8080

81+
func moveUsageDay(from old: UsagePeriod, to new: UsagePeriod, on date: Date = .now) {
82+
mutateUsageDayState { $0.moving(from: old, to: new, on: date) }
83+
}
84+
8185
func overrideUsageDay(on date: Date = .now) {
8286
saveUsageDayState(loadUsageDayState().overriding(on: date, period: usagePeriod))
8387
}

‎Shared/UsageDTO.swift‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,14 @@ nonisolated struct UsageDayStateDTO: Codable, Sendable, Equatable {
117117
return updated
118118
}
119119

120+
func moving(from old: UsagePeriod, to new: UsagePeriod, on date: Date) -> Self {
121+
guard !isStale(on: date, period: old) else { return .fresh(on: date, period: new) }
122+
var updated = self
123+
updated.dayKey = new.key(for: date)
124+
updated.encodedExpiresAt = new.nextReset(after: date)
125+
return updated
126+
}
127+
120128
func overriding(on date: Date, period: UsagePeriod = UsagePeriod()) -> Self {
121129
var updated = Self.fresh(on: date, period: period)
122130
updated.encodedIsOverridden = true

0 commit comments

Comments
 (0)