codeburn/mac/Sources/CodeBurnMenubar/Views/PeriodSegmentedControl.swift
Resham Joshi 5800179da9
Add multi-day calendar selection with custom popover UI (#393)
* Add multi-day calendar selection with custom popover UI

Replace native DatePicker with a custom-themed CalendarPopover supporting
non-contiguous multi-day selection. Add --days CLI flag for comma-separated
date filtering that post-filters from the bounding range scan.

CLI: parseDaysFlag() parses and validates dates, filterProjectsByDays()
filters turns to only selected days. Verified that sum of individual --day
calls matches --days output across cost, calls, activities, models, and
providers.

Swift: DataClient passes --days for multi-day, with single-day fallback.
AppStore tracks selectedDays as Set<String> with PayloadCacheKey support.
CalendarPopover uses pending state committed only on Done tap.

* Clear + Done in calendar resets to current period

When user clears all selected days and taps Done, switch back to the
active period instead of silently keeping the old day selection.
2026-05-24 09:49:59 -07:00

304 lines
11 KiB
Swift

import SwiftUI
struct PeriodSegmentedControl: View {
@Environment(AppStore.self) private var store
@State private var showingCalendar = false
var body: some View {
HStack(spacing: 1) {
ForEach(Period.allCases) { period in
let isActive = !store.isDayMode && store.selectedPeriod == period
Button {
store.switchTo(period: period)
} label: {
Text(period.rawValue)
.font(.system(size: 11, weight: .medium))
.foregroundStyle(isActive ? AnyShapeStyle(.primary) : AnyShapeStyle(.secondary))
.frame(maxWidth: .infinity)
.padding(.vertical, 4)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.background(
RoundedRectangle(cornerRadius: 5)
.fill(isActive ? Color(NSColor.windowBackgroundColor).opacity(0.85) : .clear)
.shadow(color: .black.opacity(isActive ? 0.06 : 0), radius: 1, y: 0.5)
)
}
Button {
showingCalendar.toggle()
} label: {
Image(systemName: "calendar")
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(store.isDayMode ? Theme.brandAccent : .secondary)
.frame(width: 28)
.padding(.vertical, 4)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.background(
RoundedRectangle(cornerRadius: 5)
.fill(store.isDayMode ? Color(NSColor.windowBackgroundColor).opacity(0.85) : .clear)
.shadow(color: .black.opacity(store.isDayMode ? 0.06 : 0), radius: 1, y: 0.5)
)
.popover(isPresented: $showingCalendar, arrowEdge: .bottom) {
CalendarPopover(isPresented: $showingCalendar)
.environment(store)
}
}
.padding(2)
.background(
RoundedRectangle(cornerRadius: 7)
.fill(Color.secondary.opacity(0.08))
)
.padding(.horizontal, 12)
.padding(.top, 6)
.padding(.bottom, 10)
}
}
private struct CalendarPopover: View {
@Environment(AppStore.self) private var store
@Binding var isPresented: Bool
@State private var displayMonth = Date()
@State private var pending: Set<String> = []
private let calendar = Calendar.current
private let weekdays = ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"]
private let cellSize: CGFloat = 30
var body: some View {
VStack(spacing: 0) {
HStack {
Button { shiftMonth(-1) } label: {
Image(systemName: "chevron.left")
.font(.system(size: 10, weight: .bold))
.foregroundStyle(.secondary)
.frame(width: 24, height: 24)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
Spacer()
Text(monthYearLabel)
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(.primary)
Spacer()
Button { shiftMonth(1) } label: {
Image(systemName: "chevron.right")
.font(.system(size: 10, weight: .bold))
.foregroundStyle(canGoForward ? .secondary : Color.secondary.opacity(0.3))
.frame(width: 24, height: 24)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.disabled(!canGoForward)
}
.padding(.horizontal, 10)
.padding(.top, 10)
.padding(.bottom, 6)
HStack(spacing: 0) {
ForEach(weekdays, id: \.self) { day in
Text(day)
.font(.system(size: 9, weight: .medium))
.foregroundStyle(.tertiary)
.frame(width: cellSize, height: 16)
}
}
.padding(.bottom, 2)
LazyVGrid(columns: Array(repeating: GridItem(.fixed(cellSize), spacing: 0), count: 7), spacing: 2) {
ForEach(dayCells, id: \.id) { cell in
DayCellView(
cell: cell,
isSelected: pending.contains(cell.dateString),
isToday: cell.dateString == todayString,
isFuture: cell.dateString > todayString
) {
toggleDay(cell.dateString)
}
}
}
.padding(.horizontal, 6)
HStack(spacing: 8) {
if !pending.isEmpty {
Button("Clear") {
pending = []
}
.font(.system(size: 11, weight: .medium))
.foregroundStyle(.secondary)
.buttonStyle(.plain)
}
Spacer()
Text(selectionSummary)
.font(.system(size: 10))
.foregroundStyle(.tertiary)
.lineLimit(1)
Spacer()
Button {
if !pending.isEmpty {
store.switchTo(days: pending)
} else {
store.switchTo(period: store.selectedPeriod)
}
isPresented = false
} label: {
Text("Done")
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(.white)
.padding(.horizontal, 14)
.padding(.vertical, 5)
.background(
RoundedRectangle(cornerRadius: 5)
.fill(pending.isEmpty ? Color.secondary.opacity(0.3) : Theme.brandAccent)
)
}
.buttonStyle(.plain)
}
.padding(.horizontal, 10)
.padding(.top, 8)
.padding(.bottom, 10)
}
.frame(width: cellSize * 7 + 24)
.onAppear {
pending = store.selectedDays
if let first = store.selectedDays.sorted().first,
let date = AppStore.dayFormatter.date(from: first) {
displayMonth = date
}
}
}
private var todayString: String {
AppStore.dayString(from: Date())
}
private var monthYearLabel: String {
let f = DateFormatter()
f.dateFormat = "MMMM yyyy"
return f.string(from: displayMonth)
}
private var canGoForward: Bool {
let nextMonth = calendar.date(byAdding: .month, value: 1, to: displayMonth) ?? displayMonth
return calendar.startOfDay(for: nextMonth) <= calendar.startOfDay(for: Date())
}
private var selectionSummary: String {
if pending.isEmpty { return "Pick dates" }
if pending.count == 1 { return "1 day" }
return "\(pending.count) days"
}
private func shiftMonth(_ delta: Int) {
if let next = calendar.date(byAdding: .month, value: delta, to: displayMonth) {
displayMonth = next
}
}
private func toggleDay(_ day: String) {
guard day <= todayString else { return }
if pending.contains(day) {
pending.remove(day)
} else {
pending.insert(day)
}
}
var dayCells: [DayCell] {
let comps = calendar.dateComponents([.year, .month], from: displayMonth)
guard let firstOfMonth = calendar.date(from: comps),
let range = calendar.range(of: .day, in: .month, for: firstOfMonth) else { return [] }
var weekdayOfFirst = calendar.component(.weekday, from: firstOfMonth) - 2
if weekdayOfFirst < 0 { weekdayOfFirst += 7 }
var cells: [DayCell] = []
for offset in stride(from: -weekdayOfFirst, to: 0, by: 1) {
if let date = calendar.date(byAdding: .day, value: offset, to: firstOfMonth) {
let d = calendar.component(.day, from: date)
cells.append(DayCell(id: "prev-\(offset)", day: d, dateString: AppStore.dayString(from: date), isCurrentMonth: false))
}
}
for day in range {
if let date = calendar.date(byAdding: .day, value: day - 1, to: firstOfMonth) {
cells.append(DayCell(id: "cur-\(day)", day: day, dateString: AppStore.dayString(from: date), isCurrentMonth: true))
}
}
let remainder = (7 - cells.count % 7) % 7
if let lastOfMonth = calendar.date(byAdding: .day, value: range.count - 1, to: firstOfMonth) {
for i in 1...max(remainder, 1) {
if let date = calendar.date(byAdding: .day, value: i, to: lastOfMonth) {
let d = calendar.component(.day, from: date)
cells.append(DayCell(id: "next-\(i)", day: d, dateString: AppStore.dayString(from: date), isCurrentMonth: false))
}
}
}
if cells.count % 7 != 0 {
cells = Array(cells.prefix(cells.count - cells.count % 7))
}
return cells
}
}
private struct DayCell: Identifiable {
let id: String
let day: Int
let dateString: String
let isCurrentMonth: Bool
}
private struct DayCellView: View {
let cell: DayCell
let isSelected: Bool
let isToday: Bool
let isFuture: Bool
let action: () -> Void
var body: some View {
Button(action: action) {
Text("\(cell.day)")
.font(.system(size: 11, weight: isToday ? .bold : .regular))
.foregroundStyle(foregroundColor)
.frame(width: 28, height: 28)
.background(
RoundedRectangle(cornerRadius: 6)
.fill(backgroundColor)
)
.overlay(
RoundedRectangle(cornerRadius: 6)
.stroke(isToday && !isSelected ? Theme.brandAccent.opacity(0.5) : .clear, lineWidth: 1)
)
}
.buttonStyle(.plain)
.disabled(isFuture)
}
private var foregroundColor: Color {
if isFuture { return Color.secondary.opacity(0.25) }
if isSelected { return .white }
if !cell.isCurrentMonth { return Color.secondary.opacity(0.4) }
if isToday { return Theme.brandAccent }
return .primary
}
private var backgroundColor: Color {
if isSelected { return Theme.brandAccent }
return .clear
}
}