- 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
265 lines
8.4 KiB
Swift
265 lines
8.4 KiB
Swift
// ── 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 (Mon–Fri)"
|
||
case .weekend: return "Weekends (Sat–Sun)"
|
||
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)"
|
||
}
|
||
}
|