import SwiftUI private let weeksBack = 104 private let weeksAhead = 104 private let weekdayHeaderHeight: CGFloat = 28 private let dayNumberRowHeight: CGFloat = 22 private let laneHeight: CGFloat = 16 private let laneSpacing: CGFloat = 2 private let maxLanesPerWeek = 5 private enum DividerEdge { case none, topHighlight, bottomHighlight } struct MonthView: View { let store: CalendarStore let onDayTap: (Date) -> Void let onEventTap: (CalEvent) -> Void let onCreateEvent: (Date) -> Void let onShowWeek: (Date) -> Void let onShowDay: (Date) -> Void @Binding var visibleMonth: Date @AppStorage("appLanguage") private var appLang = "system" @AppStorage("monthDividerColor") private var dividerHex = "#7090c0" @AppStorage("monthLabelColor") private var labelHex = "#7090c0" @AppStorage("textColor") private var textHex = "#FFFFFF" @AppStorage("lineColor") private var lineHex = "#3A3A3C" @State private var scrolledWeek: Date? = nil @State private var didInitialScroll = false private var cal: Calendar { store.userCalendar } private var weekStarts: [Date] { let today = cal.startOfDay(for: .now) let thisWeek = cal.date(from: cal.dateComponents([.yearForWeekOfYear, .weekOfYear], from: today))! return (-weeksBack...weeksAhead).compactMap { cal.date(byAdding: .weekOfYear, value: $0, to: thisWeek) } } private var weekdayHeaders: [String] { let fmt = DateFormatter(); fmt.locale = L10n.locale(appLang) let symbols = fmt.shortWeekdaySymbols ?? cal.shortWeekdaySymbols let start = cal.firstWeekday - 1 return (0..<7).map { i in String(symbols[(start + i) % 7].prefix(2)) } } var body: some View { VStack(spacing: 0) { headerRow Divider() ScrollView { LazyVStack(spacing: 0) { ForEach(weekStarts, id: \.self) { ws in WeekRow(weekStart: ws, store: store, dividerColor: Color(hex: dividerHex), labelColor: Color(hex: labelHex), textColor: Color(hex: textHex), lineColor: Color(hex: lineHex), language: appLang, onDayTap: onDayTap, onEventTap: onEventTap, onCreateEvent: onCreateEvent, onShowWeek: onShowWeek, onShowDay: onShowDay) .id(ws) } } .scrollTargetLayout() } .scrollPosition(id: $scrolledWeek, anchor: .top) .onAppear { if !didInitialScroll { didInitialScroll = true scrolledWeek = weekStart(for: store.currentDate) publishVisibleMonth(from: scrolledWeek) } } .onChange(of: store.currentDate) { _, newDate in let target = weekStart(for: newDate) if scrolledWeek != target { withAnimation(.easeInOut(duration: 0.25)) { scrolledWeek = target } } } .onChange(of: scrolledWeek) { _, newWeek in publishVisibleMonth(from: newWeek) } } } private var headerRow: some View { HStack(spacing: 0) { ForEach(weekdayHeaders, id: \.self) { d in Text(d) .font(.caption2.weight(.semibold)) .foregroundStyle(Color(hex: textHex).opacity(0.7)) .frame(maxWidth: .infinity, minHeight: weekdayHeaderHeight) } } } private func weekStart(for date: Date) -> Date { cal.date(from: cal.dateComponents([.yearForWeekOfYear, .weekOfYear], from: date))! } /// Treat the visible month as the one that "owns" Thursday of the current week — /// matches ISO week-month conventions and avoids flicker on month boundaries. private func publishVisibleMonth(from week: Date?) { guard let w = week else { return } let thursday = cal.date(byAdding: .day, value: 3, to: w) ?? w let m = cal.date(from: cal.dateComponents([.year, .month], from: thursday)) ?? thursday if visibleMonth != m { visibleMonth = m } } } // MARK: – Week Row private struct WeekRow: View { let weekStart: Date let store: CalendarStore let dividerColor: Color let labelColor: Color let textColor: Color let lineColor: Color let language: String let onDayTap: (Date) -> Void let onEventTap: (CalEvent) -> Void let onCreateEvent: (Date) -> Void let onShowWeek: (Date) -> Void let onShowDay: (Date) -> Void private var cal: Calendar { store.userCalendar } private var days: [Date] { (0..<7).compactMap { cal.date(byAdding: .day, value: $0, to: weekStart) } } private var weekNumber: Int { cal.component(.weekOfYear, from: weekStart) } private func columnRange(for ev: CalEvent) -> (startCol: Int, span: Int) { let weekEnd = cal.date(byAdding: .day, value: 7, to: weekStart)! let evStart = max(cal.startOfDay(for: ev.startDate), weekStart) // All-day end is already exclusive; timed end-of-day-on-same-day shouldn't add a column. let rawEnd: Date if ev.isAllDay { rawEnd = ev.endDate } else { // Treat timed events as occupying days from start up to and including the day of end. rawEnd = cal.date(byAdding: .day, value: 1, to: cal.startOfDay(for: ev.endDate))! } let evEnd = min(rawEnd, weekEnd) let sc = max(0, cal.dateComponents([.day], from: weekStart, to: evStart).day ?? 0) let lastIncl = (cal.dateComponents([.day], from: weekStart, to: evEnd).day ?? 0) - 1 let ec = min(6, lastIncl) return (sc, max(1, ec - sc + 1)) } /// Greedy lane packing for events overlapping this week. private func packEvents() -> (placed: [(event: CalEvent, lane: Int, startCol: Int, span: Int)], extraPerCol: [Int]) { let weekEndExclusive = cal.date(byAdding: .day, value: 7, to: weekStart)! let evs = store.events(in: weekStart, end: weekEndExclusive) .sorted { a, b in if a.startDate != b.startDate { return a.startDate < b.startDate } return a.endDate > b.endDate } var laneLastEnd: [Int] = [] var placed: [(CalEvent, Int, Int, Int)] = [] var overflowPerCol = [Int](repeating: 0, count: 7) for ev in evs { let (sc, sp) = columnRange(for: ev) var assigned: Int? = nil for laneIdx in 0.. Void let onCreateEvent: () -> Void let onShowWeek: () -> Void let onShowDay: () -> Void private var cal: Calendar { Calendar.current } private var dayNum: Int { cal.component(.day, from: date) } private var isFirstOfMonth: Bool { dayNum == 1 } private var monthAbbrev: String { let f = DateFormatter() f.locale = L10n.locale(language) f.dateFormat = "LLL" return f.string(from: date).uppercased() } var body: some View { VStack(alignment: .leading, spacing: 0) { Button(action: onTap) { HStack(spacing: 4) { Text("\(dayNum)") .font(.system(size: 13, weight: isToday ? .bold : .regular)) .foregroundStyle(isToday ? Color.white : textColor) .frame(width: 22, height: 22) .background(isToday ? Color.accentColor : Color.clear) .clipShape(Circle()) if isFirstOfMonth { Text(monthAbbrev) .font(.system(size: 10, weight: .semibold)) .foregroundStyle(monthLabelColor) .lineLimit(1) .fixedSize() } Spacer(minLength: 0) } .padding(.leading, 4) .padding(.top, 2) } .buttonStyle(.plain) Spacer(minLength: 0) HStack(spacing: 0) { if extraCount > 0 { Text("+\(extraCount)") .font(.system(size: 9, weight: .medium)) .foregroundStyle(textColor.opacity(0.6)) .padding(.leading, 4) } Spacer(minLength: 0) if let wn = weekNumber { Text("\(cwLabel) \(wn)") .font(.system(size: 9, weight: .medium)) .foregroundStyle(textColor.opacity(0.6)) .padding(.trailing, 4) } } .padding(.bottom, 1) } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .overlay(alignment: .trailing) { Rectangle().fill(lineColor.opacity(0.4)).frame(width: 0.5) } .overlay(alignment: .top) { Rectangle() .fill(edge == .topHighlight ? dividerColor : lineColor.opacity(0.3)) .frame(height: edge == .topHighlight ? 1.5 : 0.5) } .overlay(alignment: .bottom) { if edge == .bottomHighlight { Rectangle().fill(dividerColor).frame(height: 1.5) } } .contentShape(Rectangle()) .contextMenu { Button { onCreateEvent() } label: { Label(L10n.t("cal.new_event", language), systemImage: "plus") } Button { onShowWeek() } label: { Label(L10n.t("cal.show_in_week_view", language), systemImage: "calendar.day.timeline.leading") } Button { onShowDay() } label: { Label(L10n.t("cal.show_in_day_view", language), systemImage: "sun.max") } } } } // MARK: – Event Bar private struct EventBar: View { let event: CalEvent var body: some View { HStack(spacing: 3) { Text(event.title) .font(.system(size: 10, weight: .medium)) .lineLimit(1) .foregroundStyle(.white) .padding(.leading, 4) Spacer(minLength: 0) } .frame(maxWidth: .infinity, alignment: .leading) .background(Color(hex: event.effectiveColor)) .clipShape(RoundedRectangle(cornerRadius: 3)) } }