From b4e36db76342f40c8a262b32a84487934d072d47 Mon Sep 17 00:00:00 2001 From: kutesir Date: Tue, 9 Jun 2026 19:36:10 +0300 Subject: [PATCH] Tasks: single-occurrence recurrence + Today/Tomorrow/Next 7/Later + icons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switch lists from calendar-expansion (every occurrence in every section) to the TickTick model: one task contributes ONE next-active occurrence. - VM: nextActiveOccurrence + activeRows; replace todayTasks/next3/upcoming with todayTasks/tomorrowTasks/next7DaysTasks/laterTasks buckets. A recurring task shows a single row; completing it advances to the next. - TodayView: sections are now Today / Tomorrow / Next 7 Days / Later (UpcomingSection gained a title param). - TaskRowView: add โฐ (reminder) and ๐Ÿ” (recurring) indicators on the right; fix the hardcoded "Annual" label to show the real frequency. Calendar grid keeps per-date occurrences (it's a date grid, not a list). Matrix dynamic promotion (Problem 4) handled separately. Co-Authored-By: Claude Opus 4.8 --- KisaniCal/Models/TaskItem.swift | 144 +++++++++++++++++++++++--------- KisaniCal/Views/TodayView.swift | 57 ++++++++++--- 2 files changed, 150 insertions(+), 51 deletions(-) diff --git a/KisaniCal/Models/TaskItem.swift b/KisaniCal/Models/TaskItem.swift index 1b21dcd..5f173ad 100644 --- a/KisaniCal/Models/TaskItem.swift +++ b/KisaniCal/Models/TaskItem.swift @@ -166,51 +166,65 @@ 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()) - // 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] { + // MARK: - Active list rows (single occurrence per task โ€” TickTick model) + // + // A recurring task contributes exactly ONE row: its next active (incomplete) + // occurrence. Completing it advances to the next occurrence โ€” there are never + // multiple future copies of the same task across sections. (The calendar grid + // still shows every occurrence per-date; that's a date grid, not a list.) + + /// Earliest occurrence on/after `from` not yet completed โ€” the task's one active + /// occurrence. Daily โ†’ today (or tomorrow once today's done); Yearly โ†’ the next + /// uncompleted yearly date; etc. + func nextActiveOccurrence(_ t: TaskItem, from: Date = Date()) -> Date? { + guard recurs(t), let start = t.dueDate else { return nil } 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()))! - 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) + var cur = max(cal.startOfDay(for: start), cal.startOfDay(for: from)) + for _ in 0..<4000 { + if let end = t.recurrenceEnd, cur > cal.startOfDay(for: end) { return nil } + if isOccurrence(t, on: cur) && !occurrenceComplete(t, on: cur) { return cur } + cur = cal.date(byAdding: .day, value: 1, to: cur) ?? cur } - return (nonRec + rec).sorted { ($0.dueDate ?? .distantFuture) < ($1.dueDate ?? .distantFuture) } + return nil } - var next3DaysTasks: [(date: Date, tasks: [TaskItem])] { - let cal = Calendar.current - let today = cal.startOfDay(for: Date()) - 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 nonRec = tasks.filter { - !recurs($0) && !$0.isComplete && - ($0.dueDate ?? .distantFuture) >= day && - ($0.dueDate ?? .distantFuture) < nextDay + + /// One representative active row per task: non-recurring incomplete tasks, plus a + /// single next-active occurrence copy for each recurring task. + private func activeRows() -> [TaskItem] { + let today = Calendar.current.startOfDay(for: Date()) + var out: [TaskItem] = [] + for t in tasks { + if recurs(t) { + if let occ = nextActiveOccurrence(t, from: today) { out.append(occurrenceCopy(t, on: occ)) } + } else if !t.isComplete { + out.append(t) } - 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 out } + + /// Active rows whose due date falls in [today+lo, today+hi) days (hi nil = open). + private func bucket(from lo: Int, to hi: Int?) -> [TaskItem] { + let cal = Calendar.current; let base = cal.startOfDay(for: Date()) + let start = cal.date(byAdding: .day, value: lo, to: base)! + let end = hi.flatMap { cal.date(byAdding: .day, value: $0, to: base) } + return activeRows().filter { + let d = $0.dueDate ?? .distantFuture + return d >= start && (end == nil || d < end!) + }.sorted { ($0.dueDate ?? .distantFuture) < ($1.dueDate ?? .distantFuture) } + } + + var overdueTasks: [TaskItem] { + let today = Calendar.current.startOfDay(for: Date()) + // Non-recurring past-due only โ€” recurring surfaces its next occurrence instead. + return tasks.filter { !recurs($0) && !$0.isComplete && ($0.dueDate ?? .distantFuture) < today } + .sorted { ($0.dueDate ?? .distantFuture) < ($1.dueDate ?? .distantFuture) } + } + var todayTasks: [TaskItem] { bucket(from: 0, to: 1) } // due today + var tomorrowTasks: [TaskItem] { bucket(from: 1, to: 2) } // due tomorrow + var next7DaysTasks: [TaskItem] { bucket(from: 2, to: 8) } // due in 2โ€“7 days + var laterTasks: [TaskItem] { bucket(from: 8, to: nil) } // beyond 7 days + var completedTodayTasks: [TaskItem] { let cal = Calendar.current let startOfDay = cal.startOfDay(for: Date()) @@ -318,6 +332,58 @@ class TaskViewModel: ObservableObject { return nil } + // MARK: - Eisenhower Matrix planning window + // + // The Matrix is a priority view, not a date-filtered list, so far-future + // recurring occurrences must be hidden deliberately. A recurring task surfaces + // exactly one "current actionable occurrence" and only within its planning + // window; completing it rolls forward and it leaves the quadrant until the next + // occurrence's window opens. Completed occurrences never move to another quadrant. + + private func matrixLeadDays(_ label: String?) -> Int { + switch label { + case "Daily", "Every Weekday": return 0 // today only + case "Weekly": return 6 // the days leading into the due weekday + case "Monthly": return 7 + case "Yearly": return 30 + default: return 0 + } + } + + /// The single occurrence a recurring task should show in the Matrix now, or nil + /// if it's outside its planning window or already done for the current period. + /// Deadlines (monthly/yearly) also keep surfacing a missed past occurrence until + /// it's completed; habits (daily/weekly) only consider the current window. + func matrixActiveOccurrence(_ t: TaskItem, on today: Date = Date()) -> Date? { + guard recurs(t) else { return nil } + let cal = Calendar.current + let t0 = cal.startOfDay(for: today) + let lead = matrixLeadDays(t.recurrenceLabel) + let isDeadline = (t.recurrenceLabel == "Monthly" || t.recurrenceLabel == "Yearly") + let back = isDeadline ? 400 : 0 + guard let windowEnd = cal.date(byAdding: .day, value: lead, to: t0) else { return nil } + var cur = cal.date(byAdding: .day, value: -back, to: t0) ?? t0 + while cur <= windowEnd { + if isOccurrence(t, on: cur) && !occurrenceComplete(t, on: cur) { return cur } + cur = cal.date(byAdding: .day, value: 1, to: cur) ?? cur + } + return nil + } + + /// Matrix rows for a quadrant: non-recurring tasks as-is, plus each recurring + /// task's current actionable occurrence (hidden once done for its period). + func matrixTasks(for quadrant: Quadrant, on today: Date = Date()) -> [TaskItem] { + var out: [TaskItem] = [] + for t in tasks where t.quadrant == quadrant { + if recurs(t) { + if let occ = matrixActiveOccurrence(t, on: today) { out.append(occurrenceCopy(t, on: occ)) } + } else { + out.append(t) + } + } + return out.sorted { $0.isPinned && !$1.isPinned } + } + /// 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] { diff --git a/KisaniCal/Views/TodayView.swift b/KisaniCal/Views/TodayView.swift index f01f31f..36ea0e0 100644 --- a/KisaniCal/Views/TodayView.swift +++ b/KisaniCal/Views/TodayView.swift @@ -124,16 +124,33 @@ struct TodayView: View { .padding(.top, 10) } - // โ”€โ”€ Next 3 Days โ”€โ”€ - let next3 = taskVM.next3DaysTasks - if !next3.isEmpty { - Next3DaysSection(tasksByDay: next3, onToggle: { taskVM.toggle($0) }) + // โ”€โ”€ Tomorrow โ”€โ”€ + if !taskVM.tomorrowTasks.isEmpty { + UpcomingSection( + title: "Tomorrow", + tasks: taskVM.tomorrowTasks, + onToggle: { taskVM.toggle($0) }, + onPostpone: { taskVM.postpone($0) }, + onDelete: { taskVM.delete($0) } + ) } - // โ”€โ”€ Upcoming (4+ days) โ”€โ”€ - if !taskVM.upcomingTasks.isEmpty { + // โ”€โ”€ Next 7 Days (2โ€“7 days out) โ”€โ”€ + if !taskVM.next7DaysTasks.isEmpty { UpcomingSection( - tasks: taskVM.upcomingTasks, + title: "Next 7 Days", + tasks: taskVM.next7DaysTasks, + onToggle: { taskVM.toggle($0) }, + onPostpone: { taskVM.postpone($0) }, + onDelete: { taskVM.delete($0) } + ) + } + + // โ”€โ”€ Later (beyond 7 days) โ”€โ”€ + if !taskVM.laterTasks.isEmpty { + UpcomingSection( + title: "Later", + tasks: taskVM.laterTasks, onToggle: { taskVM.toggle($0) }, onPostpone: { taskVM.postpone($0) }, onDelete: { taskVM.delete($0) } @@ -151,8 +168,9 @@ struct TodayView: View { } let hasAnything = !taskVM.overdueTasks.isEmpty || !taskVM.todayTasks.isEmpty - || !taskVM.upcomingTasks.isEmpty || !taskVM.completedTodayTasks.isEmpty - || !taskVM.next3DaysTasks.isEmpty || !todayCalEvents.isEmpty + || !taskVM.tomorrowTasks.isEmpty || !taskVM.next7DaysTasks.isEmpty + || !taskVM.laterTasks.isEmpty || !taskVM.completedTodayTasks.isEmpty + || !todayCalEvents.isEmpty || workoutVM.program(for: Date()) != nil if !hasAnything { VStack(spacing: 6) { @@ -889,12 +907,13 @@ private struct SessionSectionHeader: View { struct UpcomingSection: View { @Environment(\.colorScheme) private var cs + var title: String = "Upcoming" let tasks: [TaskItem] let onToggle: (TaskItem) -> Void let onPostpone: (TaskItem) -> Void let onDelete: (TaskItem) -> Void - @AppStorage("upcomingCollapsed") private var collapsed = false + @State private var collapsed = false @State private var showAll = false private let pageSize = 5 @@ -908,7 +927,7 @@ struct UpcomingSection: View { // โ”€โ”€ Header (tappable to collapse) โ”€โ”€ HStack(spacing: 6) { - Text("Upcoming") + Text(title) .font(AppFonts.sans(10.5, weight: .semibold)) .foregroundColor(AppColors.text3(cs)) @@ -1173,7 +1192,7 @@ struct TaskRowView: View { .foregroundColor(AppColors.text(cs)) if let d = task.dueDate, showDetails { HStack(spacing: 4) { - Text("\(dueFmt.string(from: d))\(task.isRecurring ? " ยท Annual" : "")") + Text("\(dueFmt.string(from: d))\(task.isRecurring ? " ยท \(task.recurrenceLabel ?? "Repeat")" : "")") .font(AppFonts.mono(9.5)) .foregroundColor(AppColors.text3(cs)) Text("ยท") @@ -1188,6 +1207,20 @@ struct TaskRowView: View { Spacer() + // โฐ reminder ยท ๐Ÿ” recurring indicators + HStack(spacing: 5) { + if task.reminderDate != nil || task.constantReminder { + Image(systemName: "alarm") + .font(.system(size: 11)) + .foregroundColor(AppColors.text3(cs)) + } + if task.isRecurring { + Image(systemName: "repeat") + .font(.system(size: 11)) + .foregroundColor(AppColors.text3(cs)) + } + } + TagChip( text: task.category.rawValue, color: task.quadrant.color,