From 532bd578662af00f772d4802c0bdb52e7bc70af3 Mon Sep 17 00:00:00 2001 From: kutesir Date: Tue, 7 Jul 2026 01:35:25 +0300 Subject: [PATCH] Calendar: force timeline fill (dead space) + long-press menu on blocks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit KC-49: week/3-day dead space — the outer .frame only allowed fill; force the timeline ScrollView greedy (.frame maxHeight .infinity) and pin each timeline root VStack top, so the header pins to the top instead of floating mid-screen. KC-50: timeline blocks now have a long-press context menu — Complete, Snooze (15m/1h/tomorrow), and the shared TaskMenuItems (reschedule/move/priority/ category/edit/delete). Events get no menu. CVTimeBlock takes @ViewBuilder menu. Logs KC-49 (supersedes KC-48) and KC-50 in ISSUES.md. Co-Authored-By: Claude Opus 4.8 --- KisaniCal/ISSUES.md | 44 ++++++++++++++++++++ KisaniCal/Views/CalendarView.swift | 66 ++++++++++++++++++++++++------ 2 files changed, 97 insertions(+), 13 deletions(-) diff --git a/KisaniCal/ISSUES.md b/KisaniCal/ISSUES.md index f6795ae..868ba85 100644 --- a/KisaniCal/ISSUES.md +++ b/KisaniCal/ISSUES.md @@ -1407,3 +1407,47 @@ Pinned the top-level content `VStack` with `.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)`. Files: `CalendarView.swift`. + +--- + +## KC-49 — Week/3-day dead space persisted after KC-48 + +**Status:** Fixed (not build-verified) — supersedes KC-48 +**Reported by:** User ("DEAD SPACE STILL EXISTS") +**Area:** Calendar / Layout + +### Root cause (refined) +Pinning only the outer container (`.frame(maxHeight: .infinity)`, KC-48) merely +*allows* fill — it doesn't *force* it. The timeline `ScrollView` returned its +content size instead of expanding, so the week/3-day root stayed short and the +`.bottomTrailing` ZStack floated it down, leaving the day header stranded mid- +screen. Day view happened to fill, so it never showed. + +### Fix +Force the timeline `ScrollView` to be greedy with +`.frame(maxWidth: .infinity, maxHeight: .infinity)`, and pin each timeline +view's root `VStack` to fill + top-align. Applied to `weekView` and +`threeDayView`. + +Files: `CalendarView.swift`. + +--- + +## KC-50 — Long-press on a timeline event has no actions + +**Status:** Implemented (not build-verified) +**Reported by:** User +**Area:** Calendar / Tasks + +### Description +Long-pressing a task block on the day/week/3-day timeline did nothing — it +should offer the same actions as elsewhere (Complete, Snooze, reschedule, etc.). + +### Change +`CVTimeBlock` now takes `@ViewBuilder menu` content and shows it via +`.contextMenu`. `cvBlockMenu(for:)` builds it for task-backed items: a Complete +toggle, a Snooze submenu (15 min / 1 hr / tomorrow), then the shared +`TaskMenuItems` (pin, reschedule, move, priority, category, edit, delete). +Events (no `taskID`) get no menu. + +Files: `CalendarView.swift`. diff --git a/KisaniCal/Views/CalendarView.swift b/KisaniCal/Views/CalendarView.swift index b4ce8d9..3d90e8e 100644 --- a/KisaniCal/Views/CalendarView.swift +++ b/KisaniCal/Views/CalendarView.swift @@ -828,8 +828,10 @@ struct CalendarView: View { ScrollView(showsIndicators: false) { cvTimeGrid(days: days).padding(.bottom, 40) } + .frame(maxWidth: .infinity, maxHeight: .infinity) } } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) } // Grid layout: the 7 days as cards (2 columns), each listing its schedule. @@ -909,7 +911,9 @@ struct CalendarView: View { ScrollView(showsIndicators: false) { cvTimeGrid(days: days).padding(.bottom, 40) } + .frame(maxWidth: .infinity, maxHeight: .infinity) } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) } // MARK: - Day @@ -1043,13 +1047,45 @@ struct CalendarView: View { draggable: item.taskID != nil && !item.recurring, onReschedule: { newStartMin in rescheduleTask(id: item.taskID, on: date, toStartMin: newStartMin) - } + }, + menu: { cvBlockMenu(for: item) } ) } } .frame(maxWidth: .infinity) } + /// Long-press menu for a timeline block. Task items get the full shared menu + /// (Complete / Snooze / date / move / edit / delete); events get nothing. + @ViewBuilder + private func cvBlockMenu(for item: CVTimeItem) -> some View { + if let id = item.taskID, let task = taskVM.tasks.first(where: { $0.id == id }) { + Button { + withAnimation(KisaniSpring.snappy) { taskVM.toggle(task) } + } label: { + Label(task.isComplete ? "Mark Incomplete" : "Complete", systemImage: "checkmark.circle") + } + Menu { + Button { taskVM.postpone(task, minutes: 15) } label: { Label("15 minutes", systemImage: "clock") } + Button { taskVM.postpone(task, minutes: 60) } label: { Label("1 hour", systemImage: "clock") } + Button { taskVM.postpone(task, days: 1) } label: { Label("Tomorrow", systemImage: "sun.max") } + } label: { + Label("Snooze", systemImage: "clock.arrow.circlepath") + } + TaskMenuItems( + task: task, + onPin: { taskVM.pin($0) }, + onSetDate: { taskVM.setDate($0, date: $1) }, + onPostponeMinutes: { taskVM.postpone($0, minutes: $1) }, + onMove: { taskVM.moveToQuadrant(taskID: $0.id, quadrant: $1) }, + onSetPriority: { taskVM.setPriority($0, priority: $1) }, + onSetCategory: { taskVM.setCategory($0, category: $1) }, + onEdit: { editingTask = $0 }, + onDelete: { taskVM.delete($0) } + ) + } + } + /// Move a timed task to a new start minute on the same day (drag-to-reschedule). private func rescheduleTask(id: UUID?, on date: Date, toStartMin newStartMin: Int) { guard let id, let task = taskVM.tasks.first(where: { $0.id == id }) else { return } @@ -1161,13 +1197,14 @@ struct CVTimeItem: Identifiable { // A single timed block on the week/day timeline. Task-backed blocks can be // dragged vertically to reschedule (snapped to 15-minute steps); events and // recurring tasks render fixed. -private struct CVTimeBlock: View { +private struct CVTimeBlock: View { let item: CVTimeItem let top: CGFloat let height: CGFloat let hourH: CGFloat let draggable: Bool let onReschedule: (Int) -> Void + @ViewBuilder let menu: () -> MenuContent @GestureState private var dragY: CGFloat = 0 private var block: some View { @@ -1190,18 +1227,21 @@ private struct CVTimeBlock: View { } var body: some View { - if draggable { - block.gesture( - DragGesture(minimumDistance: 6) - .updating($dragY) { value, state, _ in state = value.translation.height } - .onEnded { value in - let deltaMin = Int((Double(value.translation.height) / Double(hourH) * 60).rounded() / 15) * 15 - onReschedule(item.startMin + deltaMin) - } - ) - } else { - block + Group { + if draggable { + block.gesture( + DragGesture(minimumDistance: 6) + .updating($dragY) { value, state, _ in state = value.translation.height } + .onEnded { value in + let deltaMin = Int((Double(value.translation.height) / Double(hourH) * 60).rounded() / 15) * 15 + onReschedule(item.startMin + deltaMin) + } + ) + } else { + block + } } + .contextMenu { menu() } // empty for events → no menu; task actions otherwise } }