FutureCarbsCondition.swift 4.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105
  1. // LoopFollow
  2. // FutureCarbsCondition.swift
  3. import Foundation
  4. /// Fires once when a future-dated carb entry's scheduled time arrives.
  5. ///
  6. /// **How it works:**
  7. /// 1. Each alarm tick scans `recentCarbs` for entries whose `date` is in the future.
  8. /// New ones are added to a persistent "pending" list regardless of lookahead distance,
  9. /// capturing the moment they were first observed (`observedAt`).
  10. /// 2. When a pending entry's `carbDate` passes (i.e. `carbDate <= now`), verify the
  11. /// carb still exists in `recentCarbs` **and** that the original distance
  12. /// (`carbDate − observedAt`) was within the max lookahead window. If both hold,
  13. /// fire the alarm. Otherwise silently remove the entry.
  14. /// 3. Stale entries (observed > 2 hours ago) whose carb no longer exists in
  15. /// `recentCarbs` are cleaned up automatically.
  16. struct FutureCarbsCondition: AlarmCondition {
  17. static let type: AlarmType = .futureCarbs
  18. init() {}
  19. func evaluate(alarm: Alarm, data: AlarmData, now: Date) -> Bool {
  20. // ────────────────────────────────
  21. // 0. Pull settings
  22. // ────────────────────────────────
  23. let maxLookaheadMin = alarm.threshold ?? 45 // max lookahead in minutes
  24. let minGrams = alarm.delta ?? 5 // ignore carbs below this
  25. let nowTI = now.timeIntervalSince1970
  26. let maxLookaheadSec = maxLookaheadMin * 60
  27. var pending = Storage.shared.pendingFutureCarbs.value
  28. let tolerance: TimeInterval = 5 // seconds, for matching carb entries
  29. // ────────────────────────────────
  30. // 1. Scan for new future carbs
  31. // ────────────────────────────────
  32. for carb in data.recentCarbs {
  33. let carbTI = carb.date.timeIntervalSince1970
  34. // Must be in the future and meet the minimum grams threshold.
  35. // We track ALL future carbs (not just those within the lookahead
  36. // window) so that carbs originally outside the window cannot
  37. // drift in later with a fresh observedAt.
  38. guard carbTI > nowTI,
  39. carb.grams >= minGrams
  40. else { continue }
  41. // Already tracked?
  42. let alreadyTracked = pending.contains { entry in
  43. abs(entry.carbDate - carbTI) < tolerance && entry.grams == carb.grams
  44. }
  45. if !alreadyTracked {
  46. pending.append(PendingFutureCarb(
  47. carbDate: carbTI,
  48. grams: carb.grams,
  49. observedAt: nowTI
  50. ))
  51. }
  52. }
  53. // ────────────────────────────────
  54. // 2. Check if any pending entry is due
  55. // ────────────────────────────────
  56. var fired = false
  57. pending.removeAll { entry in
  58. let stillExists = data.recentCarbs.contains { carb in
  59. abs(carb.date.timeIntervalSince1970 - entry.carbDate) < tolerance
  60. && carb.grams == entry.grams
  61. }
  62. // Cleanup stale entries (observed > 2 hours ago) only if
  63. // the carb no longer exists — prevents eviction and
  64. // re-observation with a fresh observedAt.
  65. if nowTI - entry.observedAt > 7200, !stillExists {
  66. return true
  67. }
  68. // Not yet due
  69. guard entry.carbDate <= nowTI else { return false }
  70. // Carb was deleted — remove silently
  71. if !stillExists { return true }
  72. // Carb was originally outside the lookahead window — remove without firing
  73. if entry.carbDate - entry.observedAt > maxLookaheadSec { return true }
  74. // Fire (one per tick)
  75. if !fired {
  76. fired = true
  77. return true
  78. }
  79. return false
  80. }
  81. // ────────────────────────────────
  82. // 3. Persist and return
  83. // ────────────────────────────────
  84. Storage.shared.pendingFutureCarbs.value = pending
  85. return fired
  86. }
  87. }