Calendar: fix weekday-header/week-number alignment to firstWeekday + tests
Root cause: a data-integrity bug, not visual. The month grid is built from
.weekOfMonth (respects Calendar.current.firstWeekday), but the weekday header
was hardcoded Sunday-first ("S M T W T F S") and the week-number label was gated
on isSunday (weekday == 1). On any non-Sunday-first locale (or when the user sets
"Start Week On" = Monday) every column was mislabeled by one and the W-number
landed on the wrong column — e.g. June 1 2026 (a Monday) appearing under "S".
Fix:
- Extract pure, testable date math into CalendarGrid (monthGrid + weekdaySymbols).
- Header now derived from firstWeekday, so it always matches the grid.
- Week-number label gated on isWeekStart (weekday == firstWeekday), not Sunday.
- Document that week numbers are locale weekOfYear (consistent with the layout),
not ISO.
- gridDays() and the year-view mini-month dedupe to CalendarGrid.
Verification:
- New KisaniCalTests target with 9 tests, all passing on simulator: exact
Sunday-first June 2026 grid, header↔grid alignment for firstWeekday 1...7,
cell date identity, month navigation (May/Jul/Feb 2026 + Feb 2028 leap +
Dec 2026→Jan 2027), and event-day bucketing (all-day, late-night, midnight-
crossing) across 4 timezones.
- Verified in the running app: header S M T W T F S, May 31 in the Sunday
column, June 1 under Monday, June 24 (Wednesday) selected.
Adds a DEBUG-only KISANI_INITIAL_TAB launch env (compiled out of Release) used
to screenshot the calendar past the sign-in gate.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -11,7 +11,12 @@ struct ContentView: View {
|
||||
@AppStorage("showWorkoutTab") private var showWorkoutTab = true
|
||||
@AppStorage("hasOnboarded") private var hasOnboarded = false
|
||||
@State private var showOnboarding = false
|
||||
@State private var selectedTab = 0
|
||||
@State private var selectedTab = {
|
||||
#if DEBUG
|
||||
if let t = ProcessInfo.processInfo.environment["KISANI_INITIAL_TAB"], let i = Int(t) { return i }
|
||||
#endif
|
||||
return 0
|
||||
}()
|
||||
@State private var showQuickAddTask = false
|
||||
@StateObject private var tabState = FloatingTabState()
|
||||
@ObservedObject private var shortcuts = ShortcutHandler.shared
|
||||
|
||||
37
KisaniCal/Models/CalendarGrid.swift
Normal file
37
KisaniCal/Models/CalendarGrid.swift
Normal file
@@ -0,0 +1,37 @@
|
||||
import Foundation
|
||||
|
||||
/// Pure, testable calendar-grid math. Kept free of SwiftUI/state so the month-grid
|
||||
/// generation and weekday header can be unit-tested directly.
|
||||
///
|
||||
/// Correctness contract: every cell is a real `Date`, columns respect the supplied
|
||||
/// calendar's `firstWeekday`, and the weekday header is derived from the SAME
|
||||
/// `firstWeekday` so the two can never drift out of alignment.
|
||||
enum CalendarGrid {
|
||||
|
||||
/// The days to render for the month containing `date`, including the leading days
|
||||
/// from the previous month and trailing days from the next month needed to fill
|
||||
/// complete weeks. Column placement follows `calendar.firstWeekday`.
|
||||
static func monthGrid(for date: Date, calendar: Calendar) -> [Date] {
|
||||
guard
|
||||
let interval = calendar.dateInterval(of: .month, for: date),
|
||||
let firstWeek = calendar.dateInterval(of: .weekOfMonth, for: interval.start),
|
||||
let lastWeek = calendar.dateInterval(of: .weekOfMonth, for: interval.end - 1)
|
||||
else { return [] }
|
||||
var days: [Date] = []
|
||||
var cur = firstWeek.start
|
||||
while cur < lastWeek.end {
|
||||
days.append(cur)
|
||||
guard let next = calendar.date(byAdding: .day, value: 1, to: cur) else { break }
|
||||
cur = next
|
||||
}
|
||||
return days
|
||||
}
|
||||
|
||||
/// Single-letter weekday header rotated to the calendar's `firstWeekday`.
|
||||
/// Sunday-first → ["S","M","T","W","T","F","S"]; Monday-first → ["M","T","W","T","F","S","S"].
|
||||
static func weekdaySymbols(calendar: Calendar) -> [String] {
|
||||
let syms = ["S", "M", "T", "W", "T", "F", "S"] // index 0 = Sunday
|
||||
let first = calendar.firstWeekday - 1 // firstWeekday is 1...7 (1 = Sunday)
|
||||
return (0..<7).map { syms[(first + $0) % 7] }
|
||||
}
|
||||
}
|
||||
@@ -301,7 +301,11 @@ struct CalendarView: View {
|
||||
@State private var editingTask: TaskItem? = nil
|
||||
|
||||
private let cal = Calendar.current
|
||||
private let weekdays = ["S","M","T","W","T","F","S"]
|
||||
/// Weekday header derived from the calendar's `firstWeekday` so it ALWAYS matches
|
||||
/// the grid (which is built via `.weekOfMonth`, also respecting `firstWeekday`).
|
||||
/// A hardcoded Sunday-first header mislabels every column on Monday-first locales
|
||||
/// (e.g. June 1 2026, a Monday, would appear under "S"). See CalendarGrid.
|
||||
private var weekdays: [String] { CalendarGrid.weekdaySymbols(calendar: cal) }
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .bottomTrailing) {
|
||||
@@ -447,17 +451,7 @@ struct CalendarView: View {
|
||||
}
|
||||
|
||||
private func gridDays() -> [Date] {
|
||||
guard
|
||||
let interval = cal.dateInterval(of: .month, for: displayMonth),
|
||||
let firstWeek = cal.dateInterval(of: .weekOfMonth, for: interval.start),
|
||||
let lastWeek = cal.dateInterval(of: .weekOfMonth, for: interval.end - 1)
|
||||
else { return [] }
|
||||
var days: [Date] = []; var cur = firstWeek.start
|
||||
while cur < lastWeek.end {
|
||||
days.append(cur)
|
||||
cur = cal.date(byAdding: .day, value: 1, to: cur)!
|
||||
}
|
||||
return days
|
||||
CalendarGrid.monthGrid(for: displayMonth, calendar: cal)
|
||||
}
|
||||
|
||||
private func eventDots(for date: Date) -> [Color] {
|
||||
@@ -746,13 +740,7 @@ struct CalendarView: View {
|
||||
}
|
||||
|
||||
private func cvMiniGridDays(_ month: Date) -> [Date] {
|
||||
guard let interval = cal.dateInterval(of: .month, for: month),
|
||||
let firstWeek = cal.dateInterval(of: .weekOfMonth, for: interval.start),
|
||||
let lastWeek = cal.dateInterval(of: .weekOfMonth, for: interval.end - 1)
|
||||
else { return [] }
|
||||
var days: [Date] = []; var cur = firstWeek.start
|
||||
while cur < lastWeek.end { days.append(cur); cur = cal.date(byAdding: .day, value: 1, to: cur)! }
|
||||
return days
|
||||
CalendarGrid.monthGrid(for: month, calendar: cal)
|
||||
}
|
||||
|
||||
// MARK: - Week
|
||||
@@ -1586,8 +1574,12 @@ struct DayCell: View {
|
||||
let date: Date; let isCurrentMonth: Bool; let isToday: Bool; let isSelected: Bool; let dots: [Color]
|
||||
private let cal = Calendar.current
|
||||
var dayNum: String { "\(cal.component(.day, from: date))" }
|
||||
/// Locale week-of-year (NOT ISO), intentionally matching the locale-driven grid
|
||||
/// layout/`firstWeekday`. Each row shares one weekOfYear, so this is stable per row.
|
||||
var weekNum: Int { cal.component(.weekOfYear, from: date) }
|
||||
var isSunday: Bool { cal.component(.weekday, from: date) == 1 }
|
||||
/// True on the first column of the week for the *current* locale, so the week-number
|
||||
/// label lands on the leading cell regardless of firstWeekday (not hardcoded Sunday).
|
||||
var isWeekStart: Bool { cal.component(.weekday, from: date) == cal.firstWeekday }
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 2) {
|
||||
@@ -1600,7 +1592,7 @@ struct DayCell: View {
|
||||
}
|
||||
.frame(width: 26, height: 26)
|
||||
.overlay(alignment: .topLeading) {
|
||||
if isSunday {
|
||||
if isWeekStart {
|
||||
Text("W\(weekNum)").font(AppFonts.mono(6.5)).foregroundColor(AppColors.text3(cs)).offset(x: -2, y: -10)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user