Recurring tasks: occurrence engine on calendar + lists (KC-12)
Recurring tasks only ever showed on their single dueDate. Add a recurrence engine that expands occurrences per-date with independent per-occurrence completion. - TaskItem: recurrenceEnd + completedOccurrences (optional, safe decode). - TaskViewModel: isOccurrence/occurrenceComplete/toggleOccurrence/ occurrenceCopy/nextOccurrence/occurrences(on:); Daily/Weekly/Monthly/ Yearly/Every-Weekday; respects recurrenceEnd; works across years. - toggle() routes recurring rows to per-occurrence completion. - Date filters re-inject per-date occurrence copies; recurring excluded from overdue. Calendar dots + day detail use occurrences(on:). Also log KC-11 (year display) + KC-12. Pending: "repeat until" date UI (engine already honors recurrenceEnd). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -405,6 +405,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = KisaniCal/KisaniCal.entitlements;
|
CODE_SIGN_ENTITLEMENTS = KisaniCal/KisaniCal.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
CURRENT_PROJECT_VERSION = 3;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = "Kisani Cal";
|
INFOPLIST_KEY_CFBundleDisplayName = "Kisani Cal";
|
||||||
@@ -416,8 +417,8 @@
|
|||||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||||
INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen;
|
INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen;
|
||||||
INFOPLIST_KEY_UIStatusBarStyle = UIStatusBarStyleDefault;
|
INFOPLIST_KEY_UIStatusBarStyle = UIStatusBarStyleDefault;
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
@@ -498,6 +499,7 @@
|
|||||||
APPLICATION_EXTENSION_API_ONLY = YES;
|
APPLICATION_EXTENSION_API_ONLY = YES;
|
||||||
CODE_SIGN_ENTITLEMENTS = KisaniCalWidgets/KisaniCalWidgets.entitlements;
|
CODE_SIGN_ENTITLEMENTS = KisaniCalWidgets/KisaniCalWidgets.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
CURRENT_PROJECT_VERSION = 3;
|
||||||
GENERATE_INFOPLIST_FILE = NO;
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
INFOPLIST_FILE = KisaniCalWidgets/Info.plist;
|
INFOPLIST_FILE = KisaniCalWidgets/Info.plist;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
|
||||||
@@ -588,6 +590,7 @@
|
|||||||
APPLICATION_EXTENSION_API_ONLY = YES;
|
APPLICATION_EXTENSION_API_ONLY = YES;
|
||||||
CODE_SIGN_ENTITLEMENTS = KisaniCalWidgets/KisaniCalWidgets.entitlements;
|
CODE_SIGN_ENTITLEMENTS = KisaniCalWidgets/KisaniCalWidgets.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
CURRENT_PROJECT_VERSION = 3;
|
||||||
GENERATE_INFOPLIST_FILE = NO;
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
INFOPLIST_FILE = KisaniCalWidgets/Info.plist;
|
INFOPLIST_FILE = KisaniCalWidgets/Info.plist;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
|
||||||
@@ -612,6 +615,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = KisaniCal/KisaniCal.entitlements;
|
CODE_SIGN_ENTITLEMENTS = KisaniCal/KisaniCal.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
CURRENT_PROJECT_VERSION = 3;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = "Kisani Cal";
|
INFOPLIST_KEY_CFBundleDisplayName = "Kisani Cal";
|
||||||
@@ -623,8 +627,8 @@
|
|||||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||||
INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen;
|
INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen;
|
||||||
INFOPLIST_KEY_UIStatusBarStyle = UIStatusBarStyleDefault;
|
INFOPLIST_KEY_UIStatusBarStyle = UIStatusBarStyleDefault;
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
|
|||||||
@@ -339,3 +339,59 @@ leaked onto all of those dates.
|
|||||||
finished. Future occurrences are never auto-completed.
|
finished. Future occurrences are never auto-completed.
|
||||||
|
|
||||||
Files: `ExerciseModels.swift`, `CalendarView.swift`, `TodayView.swift`.
|
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.
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ struct TaskItem: Identifiable, Codable, Equatable {
|
|||||||
var priority: Priority = .none
|
var priority: Priority = .none
|
||||||
var reminderDate: Date? = nil
|
var reminderDate: Date? = nil
|
||||||
var constantReminder: Bool = false
|
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 {
|
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] {
|
var overdueTasks: [TaskItem] {
|
||||||
let startOfDay = Calendar.current.startOfDay(for: Date())
|
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] {
|
var todayTasks: [TaskItem] {
|
||||||
let start = Calendar.current.startOfDay(for: Date())
|
let cal = Calendar.current
|
||||||
let end = Calendar.current.date(byAdding: .day, value: 1, to: start)!
|
let start = cal.startOfDay(for: Date())
|
||||||
return tasks
|
let end = cal.date(byAdding: .day, value: 1, to: start)!
|
||||||
.filter { !$0.isComplete && ($0.dueDate ?? .distantFuture) >= start && ($0.dueDate ?? .distantFuture) < end }
|
let nonRec = tasks.filter { !recurs($0) && !$0.isComplete && ($0.dueDate ?? .distantFuture) >= start && ($0.dueDate ?? .distantFuture) < end }
|
||||||
.sorted { ($0.dueDate ?? .distantFuture) < ($1.dueDate ?? .distantFuture) }
|
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] {
|
var upcomingTasks: [TaskItem] {
|
||||||
let cal = Calendar.current
|
let cal = Calendar.current
|
||||||
let fourDays = cal.date(byAdding: .day, value: 4, to: cal.startOfDay(for: Date()))!
|
let fourDays = cal.date(byAdding: .day, value: 4, to: cal.startOfDay(for: Date()))!
|
||||||
return tasks
|
let nonRec = tasks.filter { !recurs($0) && !$0.isComplete && ($0.dueDate ?? .distantFuture) >= fourDays }
|
||||||
.filter { !$0.isComplete && ($0.dueDate ?? .distantFuture) >= fourDays }
|
// Each recurring task contributes its next occurrence from 4 days out.
|
||||||
.sorted { ($0.dueDate ?? .distantFuture) < ($1.dueDate ?? .distantFuture) }
|
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])] {
|
var next3DaysTasks: [(date: Date, tasks: [TaskItem])] {
|
||||||
let cal = Calendar.current
|
let cal = Calendar.current
|
||||||
@@ -187,19 +200,24 @@ class TaskViewModel: ObservableObject {
|
|||||||
return (1...3).compactMap { offset -> (Date, [TaskItem])? in
|
return (1...3).compactMap { offset -> (Date, [TaskItem])? in
|
||||||
guard let day = cal.date(byAdding: .day, value: offset, to: today),
|
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 nextDay = cal.date(byAdding: .day, value: 1, to: day) else { return nil }
|
||||||
let dayTasks = tasks.filter {
|
let nonRec = tasks.filter {
|
||||||
!$0.isComplete &&
|
!recurs($0) && !$0.isComplete &&
|
||||||
($0.dueDate ?? .distantFuture) >= day &&
|
($0.dueDate ?? .distantFuture) >= day &&
|
||||||
($0.dueDate ?? .distantFuture) < nextDay
|
($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)
|
return dayTasks.isEmpty ? nil : (day, dayTasks)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
var completedTodayTasks: [TaskItem] {
|
var completedTodayTasks: [TaskItem] {
|
||||||
let startOfDay = Calendar.current.startOfDay(for: Date())
|
let cal = Calendar.current
|
||||||
return tasks
|
let startOfDay = cal.startOfDay(for: Date())
|
||||||
.filter { $0.isComplete && ($0.completedAt ?? .distantPast) >= startOfDay }
|
let nonRec = tasks.filter { !recurs($0) && $0.isComplete && ($0.completedAt ?? .distantPast) >= startOfDay }
|
||||||
.sorted { ($0.completedAt ?? .distantPast) > ($1.completedAt ?? .distantPast) }
|
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).
|
/// All completed tasks, most-recent first (for the Completed archive section).
|
||||||
var completedTasks: [TaskItem] {
|
var completedTasks: [TaskItem] {
|
||||||
@@ -209,6 +227,11 @@ class TaskViewModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func toggle(_ task: TaskItem) {
|
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 }
|
guard let idx = tasks.firstIndex(where: { $0.id == task.id }) else { return }
|
||||||
tasks[idx].isComplete.toggle()
|
tasks[idx].isComplete.toggle()
|
||||||
tasks[idx].completedAt = tasks[idx].isComplete ? Date() : nil
|
tasks[idx].completedAt = tasks[idx].isComplete ? Date() : nil
|
||||||
@@ -220,6 +243,96 @@ class TaskViewModel: ObservableObject {
|
|||||||
.sorted { $0.isPinned && !$1.isPinned }
|
.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) {
|
func pin(_ task: TaskItem) {
|
||||||
guard let idx = tasks.firstIndex(where: { $0.id == task.id }) else { return }
|
guard let idx = tasks.firstIndex(where: { $0.id == task.id }) else { return }
|
||||||
tasks[idx].isPinned.toggle()
|
tasks[idx].isPinned.toggle()
|
||||||
|
|||||||
@@ -463,9 +463,9 @@ struct CalendarView: View {
|
|||||||
dots.append(calColor)
|
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 { !$0.isComplete }
|
||||||
.filter { guard let d = $0.dueDate else { return false }; return cal.isDate(d, inSameDayAs: date) }
|
|
||||||
.prefix(max(0, 3 - dots.count))
|
.prefix(max(0, 3 - dots.count))
|
||||||
.map { $0.quadrant.color }
|
.map { $0.quadrant.color }
|
||||||
dots.append(contentsOf: taskDots)
|
dots.append(contentsOf: taskDots)
|
||||||
@@ -581,10 +581,7 @@ struct CalendarView: View {
|
|||||||
ScrollView(showsIndicators: false) {
|
ScrollView(showsIndicators: false) {
|
||||||
DayTimelineView(
|
DayTimelineView(
|
||||||
date: selectedDate,
|
date: selectedDate,
|
||||||
tasks: taskVM.tasks.filter { t in
|
tasks: taskVM.occurrences(on: selectedDate),
|
||||||
guard let d = t.dueDate else { return false }
|
|
||||||
return cal.isDate(d, inSameDayAs: selectedDate)
|
|
||||||
},
|
|
||||||
calEvents: calStore.isAuthorized ? calStore.events(for: selectedDate) : [],
|
calEvents: calStore.isAuthorized ? calStore.events(for: selectedDate) : [],
|
||||||
workout: workoutVM.program(for: selectedDate),
|
workout: workoutVM.program(for: selectedDate),
|
||||||
workoutDone: workoutVM.isWorkoutComplete(on: selectedDate),
|
workoutDone: workoutVM.isWorkoutComplete(on: selectedDate),
|
||||||
|
|||||||
Reference in New Issue
Block a user