Calendar: force timeline fill (dead space) + long-press menu on blocks
Some checks failed
CI / build-and-test (push) Has been cancelled

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 <noreply@anthropic.com>
This commit is contained in:
kutesir
2026-07-07 01:35:25 +03:00
parent 543a1c3ee0
commit 532bd57866
2 changed files with 97 additions and 13 deletions

View File

@@ -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`.

View File

@@ -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<MenuContent: View>: 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
}
}