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:
kutesir
2026-06-24 19:24:49 +03:00
parent eeb60dc51b
commit 38db257c4a
7 changed files with 346 additions and 22 deletions

View File

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

View 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] }
}
}

View File

@@ -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)
}
}