learning_ai_clock/ios/ChronoMind/Shared/TimerEngine/Recurrence.swift
saravanakumardb1 11e50295ea feat: fix web build, add repo infra, port iOS engine modules, add routine screens
- fix(web): cast window through unknown in platform-sync.ts (TS2352)
- docs: add AGENTS.md, README.md, CLAUDE.md, .windsurfrules, .cursorrules, env.example
- feat(ios): port Recurrence.swift from web/src/lib/recurrence.ts
- feat(ios): port NLParser.swift from web/src/lib/nl-parser.ts
- feat(ios): port ContextMessages.swift from web/src/lib/context-messages.ts
- feat(ios): add CMRoutine model + Routines.swift engine with state machine + templates
- feat(ios): add RoutineListView, RoutineRunnerView, RoutineEditorView
- feat(android): add RoutineScreen.kt with list, runner, templates, step controls

Web: 373 tests passing, build succeeds with --webpack flag
2026-02-28 01:50:35 -08:00

265 lines
8.4 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Recurring Timer Engine
// Recurrence rules, next-occurrence calculation, skip/pause logic
// Ported from web/src/lib/recurrence.ts
import Foundation
// MARK: - Recurrence Frequency
enum RecurrenceFrequency: String, Codable, CaseIterable, Identifiable {
case daily
case weekday
case weekend
case weekly
case biweekly
case monthly
case custom
var id: String { rawValue }
var label: String {
switch self {
case .daily: return "Every day"
case .weekday: return "Weekdays (MonFri)"
case .weekend: return "Weekends (SatSun)"
case .weekly: return "Every week"
case .biweekly: return "Every 2 weeks"
case .monthly: return "Every month"
case .custom: return "Custom days"
}
}
}
// MARK: - Recurrence Rule
struct RecurrenceRule: Codable, Equatable {
let frequency: RecurrenceFrequency
var daysOfWeek: [Int]? // 1=Sun, 2=Mon, ..., 7=Sat (Calendar weekday)
var interval: Int // Every N periods (default 1)
var endDate: Date? // Stop recurring after this
var timeOfDay: Int // Minutes since midnight (e.g., 540 = 9:00 AM)
init(
frequency: RecurrenceFrequency,
daysOfWeek: [Int]? = nil,
interval: Int = 1,
endDate: Date? = nil,
timeOfDay: Int
) {
self.frequency = frequency
self.daysOfWeek = daysOfWeek
self.interval = interval
self.endDate = endDate
self.timeOfDay = timeOfDay
}
}
// MARK: - Recurring Timer
struct RecurringTimer: Codable, Identifiable, Equatable {
let id: String
var recurrence: RecurrenceRule
var paused: Bool
var skipNext: Bool
var lastOccurrence: Date?
}
// MARK: - Next Occurrence Calculation
private let daySeconds: TimeInterval = 24 * 60 * 60
/// Calculate the next occurrence of a recurring timer after `afterDate`.
/// Returns the date of next occurrence, or nil if no more occurrences.
func getNextOccurrence(
rule: RecurrenceRule,
afterDate: Date,
maxLookaheadDays: Int = 366
) -> Date? {
let calendar = Calendar.current
let interval = max(rule.interval, 1)
// Start from the beginning of the afterDate day
let startOfDay = calendar.startOfDay(for: afterDate)
// Helper: set time-of-day on a given date
func setTimeOnDate(_ date: Date) -> Date {
let hours = rule.timeOfDay / 60
let minutes = rule.timeOfDay % 60
return calendar.date(bySettingHour: hours, minute: minutes, second: 0, of: date) ?? date
}
// Check same day first
let sameDayCandidate = setTimeOnDate(startOfDay)
var candidates: [Date] = []
if sameDayCandidate > afterDate {
candidates.append(sameDayCandidate)
}
// Generate candidates going forward
for dayOffset in 1...maxLookaheadDays {
guard let futureDay = calendar.date(byAdding: .day, value: dayOffset, to: startOfDay) else { continue }
candidates.append(setTimeOnDate(futureDay))
}
for candidate in candidates {
// Check end date
if let endDate = rule.endDate, candidate > endDate {
return nil
}
if matchesFrequency(date: candidate, rule: rule, afterDate: afterDate, interval: interval) {
return candidate
}
}
return nil
}
/// Check if a given date matches the recurrence frequency rule.
private func matchesFrequency(
date: Date,
rule: RecurrenceRule,
afterDate: Date,
interval: Int
) -> Bool {
let calendar = Calendar.current
let weekday = calendar.component(.weekday, from: date) // 1=Sun, 2=Mon, ..., 7=Sat
switch rule.frequency {
case .daily:
return true
case .weekday:
return weekday >= 2 && weekday <= 6 // Mon-Fri
case .weekend:
return weekday == 1 || weekday == 7 // Sun or Sat
case .weekly:
let refWeekday = calendar.component(.weekday, from: afterDate)
if weekday != refWeekday { return false }
if interval <= 1 { return true }
let refStart = calendar.startOfDay(for: afterDate)
let diffDays = calendar.dateComponents([.day], from: refStart, to: date).day ?? 0
let mod = diffDays % (interval * 7)
return mod == 0 || mod == 7
case .biweekly:
let refWeekday = calendar.component(.weekday, from: afterDate)
if weekday != refWeekday { return false }
let refStart = calendar.startOfDay(for: afterDate)
let diffDays = calendar.dateComponents([.day], from: refStart, to: date).day ?? 0
return diffDays >= 0 && diffDays % 14 < 7
case .monthly:
let refDayOfMonth = calendar.component(.day, from: afterDate)
let daysInMonth = calendar.range(of: .day, in: .month, for: date)?.count ?? 28
let targetDay = min(refDayOfMonth, daysInMonth)
return calendar.component(.day, from: date) == targetDay
case .custom:
guard let days = rule.daysOfWeek else { return false }
return days.contains(weekday)
}
}
// MARK: - Bulk Helpers
/// Get the next N occurrences of a recurring timer.
func getNextNOccurrences(
rule: RecurrenceRule,
afterDate: Date,
count: Int
) -> [Date] {
var occurrences: [Date] = []
var cursor = afterDate
for _ in 0..<count {
guard let next = getNextOccurrence(rule: rule, afterDate: cursor) else { break }
occurrences.append(next)
cursor = next
}
return occurrences
}
/// Apply "skip next" get the occurrence after the next one.
func getOccurrenceAfterSkip(
rule: RecurrenceRule,
afterDate: Date
) -> Date? {
guard let next = getNextOccurrence(rule: rule, afterDate: afterDate) else { return nil }
return getNextOccurrence(rule: rule, afterDate: next)
}
// MARK: - Rule Builders
func createDailyRule(timeOfDayMinutes: Int, endDate: Date? = nil) -> RecurrenceRule {
RecurrenceRule(frequency: .daily, endDate: endDate, timeOfDay: timeOfDayMinutes)
}
func createWeekdayRule(timeOfDayMinutes: Int, endDate: Date? = nil) -> RecurrenceRule {
RecurrenceRule(frequency: .weekday, endDate: endDate, timeOfDay: timeOfDayMinutes)
}
func createWeekendRule(timeOfDayMinutes: Int, endDate: Date? = nil) -> RecurrenceRule {
RecurrenceRule(frequency: .weekend, endDate: endDate, timeOfDay: timeOfDayMinutes)
}
func createWeeklyRule(timeOfDayMinutes: Int, endDate: Date? = nil) -> RecurrenceRule {
RecurrenceRule(frequency: .weekly, interval: 1, endDate: endDate, timeOfDay: timeOfDayMinutes)
}
func createBiweeklyRule(timeOfDayMinutes: Int, endDate: Date? = nil) -> RecurrenceRule {
RecurrenceRule(frequency: .biweekly, endDate: endDate, timeOfDay: timeOfDayMinutes)
}
func createMonthlyRule(timeOfDayMinutes: Int, endDate: Date? = nil) -> RecurrenceRule {
RecurrenceRule(frequency: .monthly, endDate: endDate, timeOfDay: timeOfDayMinutes)
}
func createCustomRule(daysOfWeek: [Int], timeOfDayMinutes: Int, endDate: Date? = nil) -> RecurrenceRule {
RecurrenceRule(frequency: .custom, daysOfWeek: daysOfWeek, endDate: endDate, timeOfDay: timeOfDayMinutes)
}
// MARK: - Display Helpers
/// Format time-of-day minutes as "HH:MM AM/PM"
func formatTimeOfDay(minutes: Int) -> String {
let h = minutes / 60
let m = minutes % 60
let period = h >= 12 ? "PM" : "AM"
let h12 = h == 0 ? 12 : (h > 12 ? h - 12 : h)
return "\(h12):\(String(format: "%02d", m)) \(period)"
}
/// Get a human-readable description of a recurrence rule.
func describeRecurrence(rule: RecurrenceRule) -> String {
let time = formatTimeOfDay(minutes: rule.timeOfDay)
let dayNames = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]
switch rule.frequency {
case .daily:
return "Every day at \(time)"
case .weekday:
return "Weekdays at \(time)"
case .weekend:
return "Weekends at \(time)"
case .weekly:
return "Every week at \(time)"
case .biweekly:
return "Every 2 weeks at \(time)"
case .monthly:
return "Monthly at \(time)"
case .custom:
guard let dows = rule.daysOfWeek, !dows.isEmpty else { return "Custom at \(time)" }
// Convert Calendar weekday (1=Sun) to dayNames index (0=Sun)
let days = dows.sorted().compactMap { d -> String? in
let idx = d - 1
guard idx >= 0 && idx < dayNames.count else { return nil }
return dayNames[idx]
}.joined(separator: ", ")
return "\(days) at \(time)"
}
}