diff --git a/KisaniCal.xcodeproj/project.pbxproj b/KisaniCal.xcodeproj/project.pbxproj index 9bec8be..5b6b056 100644 --- a/KisaniCal.xcodeproj/project.pbxproj +++ b/KisaniCal.xcodeproj/project.pbxproj @@ -405,6 +405,7 @@ CODE_SIGN_ENTITLEMENTS = KisaniCal/KisaniCal.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 3; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_CFBundleDisplayName = "Kisani Cal"; @@ -416,8 +417,8 @@ INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; INFOPLIST_KEY_UIStatusBarStyle = UIStatusBarStyleDefault; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -498,6 +499,7 @@ APPLICATION_EXTENSION_API_ONLY = YES; CODE_SIGN_ENTITLEMENTS = KisaniCalWidgets/KisaniCalWidgets.entitlements; CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 3; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = KisaniCalWidgets/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 18.0; @@ -588,6 +590,7 @@ APPLICATION_EXTENSION_API_ONLY = YES; CODE_SIGN_ENTITLEMENTS = KisaniCalWidgets/KisaniCalWidgets.entitlements; CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 3; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = KisaniCalWidgets/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 18.0; @@ -612,6 +615,7 @@ CODE_SIGN_ENTITLEMENTS = KisaniCal/KisaniCal.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 3; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_CFBundleDisplayName = "Kisani Cal"; @@ -623,8 +627,8 @@ INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; INFOPLIST_KEY_UIStatusBarStyle = UIStatusBarStyleDefault; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", diff --git a/KisaniCal/ISSUES.md b/KisaniCal/ISSUES.md index 39e2f62..ebba150 100644 --- a/KisaniCal/ISSUES.md +++ b/KisaniCal/ISSUES.md @@ -339,3 +339,59 @@ leaked onto all of those dates. finished. Future occurrences are never auto-completed. Files: `ExerciseModels.swift`, `CalendarView.swift`, `TodayView.swift`. + +--- + +## KC-11 — Calendar appeared capped at 2026 (no year shown) + +**Status:** Resolved +**Reported by:** User +**Area:** Calendar + +### Description +Users couldn't navigate/see dates beyond 2026. + +### Root cause +Navigation was actually unbounded, but every header showed only the month name +(`"MMMM"`) — the Year view had no year label at all — so crossing a year boundary +was invisible and future months felt unreachable (compounded by empty future +months from the missing recurrence engine, KC-12). + +### Fix +Show the year: main header `"Month Year"`, date-picker grid `"MMMM yyyy"`, and a +`◀ year ▶` navigator + label in the Year view (`navigateYear`). +Files: `CalendarView.swift`, `TodayView.swift`. Committed in `7b2b61a`. + +--- + +## KC-12 — Recurring tasks don't appear on future dates + +**Status:** In Progress (core engine done & build-verified; "repeat until" UI pending) +**Reported by:** User +**Area:** Tasks / Calendar + +### Description +A task set to repeat (Daily/Weekly/Monthly/Yearly/Every-Weekday) only ever showed +on its single original `dueDate`. No future occurrences, no per-occurrence +completion. + +### Root cause +There was no recurrence engine — tasks carry `isRecurring` + `recurrenceLabel` but +a single `dueDate`, and every surface matched by `isDate(dueDate, inSameDayAs:)`. + +### Fix (core) +- Model: `recurrenceEnd` + `completedOccurrences` (optional → safe decode of old data). +- `TaskViewModel` engine: `isOccurrence(_:on:)`, `occurrenceComplete`, + `toggleOccurrence`, `occurrenceCopy`, `nextOccurrence`, `occurrences(on:)`. +- `toggle(_:)` routes recurring rows to per-occurrence completion (one day never + affects another). +- Date filters (`today`/`next3`/`upcoming`/`completedToday`) exclude recurring + masters and re-inject per-date occurrence copies; recurring never goes "overdue". +- Calendar dots + day detail use `occurrences(on:)`. +- Works across year boundaries; respects `recurrenceEnd` when set. + +Files: `TaskItem.swift`, `CalendarView.swift`. + +### Pending +- "Repeat until" date UI in the task date picker (thread `recurrenceEnd` through the + add/edit sheets + `addTask`/`updateTask`). Engine already honors it. diff --git a/KisaniCal/Models/TaskItem.swift b/KisaniCal/Models/TaskItem.swift index c24cb1a..5934ee7 100644 --- a/KisaniCal/Models/TaskItem.swift +++ b/KisaniCal/Models/TaskItem.swift @@ -20,6 +20,9 @@ struct TaskItem: Identifiable, Codable, Equatable { var priority: Priority = .none var reminderDate: Date? = nil var constantReminder: Bool = false + // Recurrence: optional so existing saved tasks decode safely (missing key → nil). + var recurrenceEnd: Date? = nil // "repeat until" (nil = open-ended) + var completedOccurrences: [String]? = nil // "yyyy-MM-dd" of completed occurrences } enum Priority: String, Codable, CaseIterable, Identifiable { @@ -163,23 +166,33 @@ class TaskViewModel: ObservableObject { } } + // Recurring masters are excluded from the date filters and re-injected as + // per-date occurrence copies, so each scheduled day is independent. var overdueTasks: [TaskItem] { let startOfDay = Calendar.current.startOfDay(for: Date()) - return tasks.filter { !$0.isComplete && ($0.dueDate ?? .distantFuture) < startOfDay } + // Non-recurring only — a recurring task is never "overdue"; each day stands alone. + return tasks.filter { !recurs($0) && !$0.isComplete && ($0.dueDate ?? .distantFuture) < startOfDay } } var todayTasks: [TaskItem] { - let start = Calendar.current.startOfDay(for: Date()) - let end = Calendar.current.date(byAdding: .day, value: 1, to: start)! - return tasks - .filter { !$0.isComplete && ($0.dueDate ?? .distantFuture) >= start && ($0.dueDate ?? .distantFuture) < end } - .sorted { ($0.dueDate ?? .distantFuture) < ($1.dueDate ?? .distantFuture) } + let cal = Calendar.current + let start = cal.startOfDay(for: Date()) + let end = cal.date(byAdding: .day, value: 1, to: start)! + let nonRec = tasks.filter { !recurs($0) && !$0.isComplete && ($0.dueDate ?? .distantFuture) >= start && ($0.dueDate ?? .distantFuture) < end } + let rec = tasks.filter { recurs($0) && isOccurrence($0, on: start) && !occurrenceComplete($0, on: start) } + .map { occurrenceCopy($0, on: start) } + return (nonRec + rec).sorted { ($0.dueDate ?? .distantFuture) < ($1.dueDate ?? .distantFuture) } } var upcomingTasks: [TaskItem] { let cal = Calendar.current let fourDays = cal.date(byAdding: .day, value: 4, to: cal.startOfDay(for: Date()))! - return tasks - .filter { !$0.isComplete && ($0.dueDate ?? .distantFuture) >= fourDays } - .sorted { ($0.dueDate ?? .distantFuture) < ($1.dueDate ?? .distantFuture) } + let nonRec = tasks.filter { !recurs($0) && !$0.isComplete && ($0.dueDate ?? .distantFuture) >= fourDays } + // Each recurring task contributes its next occurrence from 4 days out. + let rec = tasks.compactMap { t -> TaskItem? in + guard recurs(t), let next = nextOccurrence(t, onOrAfter: fourDays), + !occurrenceComplete(t, on: next) else { return nil } + return occurrenceCopy(t, on: next) + } + return (nonRec + rec).sorted { ($0.dueDate ?? .distantFuture) < ($1.dueDate ?? .distantFuture) } } var next3DaysTasks: [(date: Date, tasks: [TaskItem])] { let cal = Calendar.current @@ -187,19 +200,24 @@ class TaskViewModel: ObservableObject { return (1...3).compactMap { offset -> (Date, [TaskItem])? in guard let day = cal.date(byAdding: .day, value: offset, to: today), let nextDay = cal.date(byAdding: .day, value: 1, to: day) else { return nil } - let dayTasks = tasks.filter { - !$0.isComplete && + let nonRec = tasks.filter { + !recurs($0) && !$0.isComplete && ($0.dueDate ?? .distantFuture) >= day && ($0.dueDate ?? .distantFuture) < nextDay - }.sorted { ($0.dueDate ?? .distantFuture) < ($1.dueDate ?? .distantFuture) } + } + let rec = tasks.filter { recurs($0) && isOccurrence($0, on: day) && !occurrenceComplete($0, on: day) } + .map { occurrenceCopy($0, on: day) } + let dayTasks = (nonRec + rec).sorted { ($0.dueDate ?? .distantFuture) < ($1.dueDate ?? .distantFuture) } return dayTasks.isEmpty ? nil : (day, dayTasks) } } var completedTodayTasks: [TaskItem] { - let startOfDay = Calendar.current.startOfDay(for: Date()) - return tasks - .filter { $0.isComplete && ($0.completedAt ?? .distantPast) >= startOfDay } - .sorted { ($0.completedAt ?? .distantPast) > ($1.completedAt ?? .distantPast) } + let cal = Calendar.current + let startOfDay = cal.startOfDay(for: Date()) + let nonRec = tasks.filter { !recurs($0) && $0.isComplete && ($0.completedAt ?? .distantPast) >= startOfDay } + let rec = tasks.filter { recurs($0) && isOccurrence($0, on: startOfDay) && occurrenceComplete($0, on: startOfDay) } + .map { occurrenceCopy($0, on: startOfDay) } + return (nonRec + rec).sorted { ($0.completedAt ?? .distantPast) > ($1.completedAt ?? .distantPast) } } /// All completed tasks, most-recent first (for the Completed archive section). var completedTasks: [TaskItem] { @@ -209,6 +227,11 @@ class TaskViewModel: ObservableObject { } func toggle(_ task: TaskItem) { + // A recurring row carries its occurrence date in dueDate — toggle just that day. + if recurs(task), let d = task.dueDate { + toggleOccurrence(task, on: d) + return + } guard let idx = tasks.firstIndex(where: { $0.id == task.id }) else { return } tasks[idx].isComplete.toggle() tasks[idx].completedAt = tasks[idx].isComplete ? Date() : nil @@ -220,6 +243,96 @@ class TaskViewModel: ObservableObject { .sorted { $0.isPinned && !$1.isPinned } } + // MARK: - Recurrence engine + + private static let occFmt: DateFormatter = { + let f = DateFormatter(); f.dateFormat = "yyyy-MM-dd"; return f + }() + func occurrenceKey(_ date: Date) -> String { Self.occFmt.string(from: date) } + + /// Whether a task repeats on a schedule. + func recurs(_ t: TaskItem) -> Bool { + t.isRecurring && (t.recurrenceLabel ?? "None") != "None" && t.dueDate != nil + } + + /// Whether a recurring task has an occurrence on `date` — aligned to its rule, + /// on/after its start, and on/before its "repeat until" (if set). + func isOccurrence(_ t: TaskItem, on date: Date) -> Bool { + guard recurs(t), let start = t.dueDate else { return false } + let cal = Calendar.current + let d0 = cal.startOfDay(for: date), s0 = cal.startOfDay(for: start) + guard d0 >= s0 else { return false } + if let end = t.recurrenceEnd, d0 > cal.startOfDay(for: end) { return false } + switch t.recurrenceLabel { + case "Daily": return true + case "Every Weekday": + let wd = cal.component(.weekday, from: d0) // 1=Sun … 7=Sat + return wd >= 2 && wd <= 6 + case "Weekly": return ((cal.dateComponents([.day], from: s0, to: d0).day ?? 0) % 7) == 0 + case "Monthly": return cal.component(.day, from: s0) == cal.component(.day, from: d0) + case "Yearly": return cal.component(.day, from: s0) == cal.component(.day, from: d0) + && cal.component(.month, from: s0) == cal.component(.month, from: d0) + default: return false + } + } + + func occurrenceComplete(_ t: TaskItem, on date: Date) -> Bool { + t.completedOccurrences?.contains(occurrenceKey(date)) ?? false + } + + /// Toggle one occurrence's completion — never affects other dates. + func toggleOccurrence(_ t: TaskItem, on date: Date) { + guard let idx = tasks.firstIndex(where: { $0.id == t.id }) else { return } + let key = occurrenceKey(date) + var set = Set(tasks[idx].completedOccurrences ?? []) + if set.contains(key) { set.remove(key) } else { set.insert(key) } + tasks[idx].completedOccurrences = Array(set) + save() + } + + /// A display copy of a recurring task pinned to one occurrence date, with that + /// occurrence's completion baked into `isComplete` (so existing rows just work). + func occurrenceCopy(_ t: TaskItem, on date: Date) -> TaskItem { + var c = t + let cal = Calendar.current + if let due = t.dueDate { + let hm = cal.dateComponents([.hour, .minute], from: due) + c.dueDate = cal.date(bySettingHour: hm.hour ?? 0, minute: hm.minute ?? 0, second: 0, of: date) ?? date + } else { c.dueDate = date } + let done = occurrenceComplete(t, on: date) + c.isComplete = done + c.completedAt = done ? c.dueDate : nil + return c + } + + /// Next occurrence on/after `date`, within the repeat-until. + func nextOccurrence(_ t: TaskItem, onOrAfter date: Date) -> Date? { + guard recurs(t), let start = t.dueDate else { return nil } + let cal = Calendar.current + var cur = max(cal.startOfDay(for: start), cal.startOfDay(for: date)) + for _ in 0..<4000 { + if let end = t.recurrenceEnd, cur > cal.startOfDay(for: end) { return nil } + if isOccurrence(t, on: cur) { return cur } + cur = cal.date(byAdding: .day, value: 1, to: cur) ?? cur + } + return nil + } + + /// All task instances on a given calendar day: non-recurring tasks due that day + /// plus a copy of each recurring task that occurs that day. Used by the calendar. + func occurrences(on date: Date) -> [TaskItem] { + let cal = Calendar.current + var out: [TaskItem] = [] + for t in tasks { + if recurs(t) { + if isOccurrence(t, on: date) { out.append(occurrenceCopy(t, on: date)) } + } else if let d = t.dueDate, cal.isDate(d, inSameDayAs: date) { + out.append(t) + } + } + return out + } + func pin(_ task: TaskItem) { guard let idx = tasks.firstIndex(where: { $0.id == task.id }) else { return } tasks[idx].isPinned.toggle() diff --git a/KisaniCal/Views/CalendarView.swift b/KisaniCal/Views/CalendarView.swift index 1df50ff..d06bef2 100644 --- a/KisaniCal/Views/CalendarView.swift +++ b/KisaniCal/Views/CalendarView.swift @@ -463,9 +463,9 @@ struct CalendarView: View { dots.append(calColor) } } - let taskDots = taskVM.tasks + // Includes recurring occurrences that land on this date. + let taskDots = taskVM.occurrences(on: date) .filter { !$0.isComplete } - .filter { guard let d = $0.dueDate else { return false }; return cal.isDate(d, inSameDayAs: date) } .prefix(max(0, 3 - dots.count)) .map { $0.quadrant.color } dots.append(contentsOf: taskDots) @@ -581,10 +581,7 @@ struct CalendarView: View { ScrollView(showsIndicators: false) { DayTimelineView( date: selectedDate, - tasks: taskVM.tasks.filter { t in - guard let d = t.dueDate else { return false } - return cal.isDate(d, inSameDayAs: selectedDate) - }, + tasks: taskVM.occurrences(on: selectedDate), calEvents: calStore.isAuthorized ? calStore.events(for: selectedDate) : [], workout: workoutVM.program(for: selectedDate), workoutDone: workoutVM.isWorkoutComplete(on: selectedDate),