diff --git a/KisaniCal.xcodeproj/project.pbxproj b/KisaniCal.xcodeproj/project.pbxproj index 3429f50..f290556 100644 --- a/KisaniCal.xcodeproj/project.pbxproj +++ b/KisaniCal.xcodeproj/project.pbxproj @@ -26,6 +26,7 @@ 5EF4A5B6CE91ADA0CCF72D0D /* ProfileSetupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C96EC00F6B021DBA3CC1A79 /* ProfileSetupView.swift */; }; 67CE1747E5DB3FDA79D0FDFD /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 106EEF572C6F8990408329F0 /* OnboardingView.swift */; }; 6921CB73A3257502FF778381 /* SplashView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE2D50B4B4AC227BD21F4B60 /* SplashView.swift */; }; + 6BCBF3902E38FBAA7348840D /* CalendarGridTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74FAA2B41FCEBC7E3F156F0F /* CalendarGridTests.swift */; }; 703E068364256D6F3F867961 /* ISSUES.md in Resources */ = {isa = PBXBuildFile; fileRef = 92824ED40ECD41EFD4F78BEC /* ISSUES.md */; }; 754D3DE3CEB998E36E585A61 /* LiveActivityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0506183945D16EC443A69651 /* LiveActivityManager.swift */; }; 79FC1DF6762C6F02D01AB643 /* KisaniCalWidgets.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 0D6D6740721F01A74E404EB9 /* KisaniCalWidgets.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; @@ -38,6 +39,7 @@ 9070521B1D36A5551976C275 /* CalendarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADF6CCD95A587E26E30F5712 /* CalendarView.swift */; }; 9E3F966F6B6AF2E4F8109E51 /* WorkoutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20DAD771EFCC8B3B1DBD4DD6 /* WorkoutView.swift */; }; A1DBC4D2F09C02B8CEE6449E /* AddExerciseSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 768D4E314252E2A90A06CCF2 /* AddExerciseSheet.swift */; }; + A292B0492135BA40F6B6A951 /* CalendarGrid.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FCB2077D9509CB5C0978E58 /* CalendarGrid.swift */; }; A3B2D6622A2EE35C8D5A3C9B /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72308FEE0226F45414C04DDD /* SettingsView.swift */; }; A9FF93259AE8FF0ABF69D71A /* DesignTokens.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD4A35C0E1270E2E15C03F23 /* DesignTokens.swift */; }; AC0D814DA54D5EF5E25CEB99 /* WidgetData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61BFF3BA28331A16D0ADE9D0 /* WidgetData.swift */; }; @@ -53,6 +55,13 @@ /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ + BF401FDD105729FB67DF02D6 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = AF6DE7A812408E3742522E90 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 297C0C2BFBA10AB52D5D49CE; + remoteInfo = KisaniCal; + }; D04FCEE0BDF30AEFD1C969B6 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = AF6DE7A812408E3742522E90 /* Project object */; @@ -83,10 +92,12 @@ 0513EB8C7F6B6853B9E93E09 /* TaskLiveActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskLiveActivity.swift; sourceTree = ""; }; 0C96EC00F6B021DBA3CC1A79 /* ProfileSetupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileSetupView.swift; sourceTree = ""; }; 0D6D6740721F01A74E404EB9 /* KisaniCalWidgets.appex */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = "wrapper.app-extension"; path = KisaniCalWidgets.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + 0FCB2077D9509CB5C0978E58 /* CalendarGrid.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarGrid.swift; sourceTree = ""; }; 106EEF572C6F8990408329F0 /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; sourceTree = ""; }; 1AA7498EFED7692022F3E7E1 /* KisaniCal.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = KisaniCal.entitlements; sourceTree = ""; }; 208F82348DEBD9FF7B0DCF17 /* MyTasksWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MyTasksWidget.swift; sourceTree = ""; }; 20DAD771EFCC8B3B1DBD4DD6 /* WorkoutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkoutView.swift; sourceTree = ""; }; + 20E29A9264577A89891DAEC1 /* KisaniCalTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = KisaniCalTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 23A4491BFA50721082024756 /* SharedComponents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedComponents.swift; sourceTree = ""; }; 326B77A7B317A7DB44E13EA5 /* ShortcutHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShortcutHandler.swift; sourceTree = ""; }; 35FF274EFF2361B7F3D2D227 /* WenzaWatch.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = WenzaWatch.entitlements; sourceTree = ""; }; @@ -100,6 +111,7 @@ 670A8C6F8243EC973A1BC431 /* TaskContextMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskContextMenu.swift; sourceTree = ""; }; 72308FEE0226F45414C04DDD /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; 72FDF9C8DD37134576356B89 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 74FAA2B41FCEBC7E3F156F0F /* CalendarGridTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarGridTests.swift; sourceTree = ""; }; 768D4E314252E2A90A06CCF2 /* AddExerciseSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddExerciseSheet.swift; sourceTree = ""; }; 7A9D47B284FD6A217AEF813B /* WenzaWatch.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = WenzaWatch.app; sourceTree = BUILT_PRODUCTS_DIR; }; 89550F2CD19B950CCC6AD37F /* AuthManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthManager.swift; sourceTree = ""; }; @@ -128,6 +140,14 @@ /* End PBXFileReference section */ /* Begin PBXGroup section */ + 068B77B2F01C399C7A430292 /* KisaniCalTests */ = { + isa = PBXGroup; + children = ( + 74FAA2B41FCEBC7E3F156F0F /* CalendarGridTests.swift */, + ); + path = KisaniCalTests; + sourceTree = ""; + }; 21B93C269F283F11B415B18C /* Components */ = { isa = PBXGroup; children = ( @@ -140,6 +160,7 @@ 23CBCF100C5EF55E737379CA /* Models */ = { isa = PBXGroup; children = ( + 0FCB2077D9509CB5C0978E58 /* CalendarGrid.swift */, 5722CC4B59E3939724142710 /* ExerciseModels.swift */, DCC2AFB4FA765383740767CB /* TaskItem.swift */, ); @@ -160,6 +181,7 @@ isa = PBXGroup; children = ( F70DA4746C68CD405435DAB6 /* KisaniCal */, + 068B77B2F01C399C7A430292 /* KisaniCalTests */, E8D727EEA0C3A4B8006FB087 /* KisaniCalWidgets */, 48146B56E740528496663D47 /* WenzaWatch */, 51BD1B5DEDE9FAD9CA2FF6DA /* Products */, @@ -170,6 +192,7 @@ isa = PBXGroup; children = ( 9BD1F84B4F45B12CB9B9F1B2 /* KisaniCal.app */, + 20E29A9264577A89891DAEC1 /* KisaniCalTests.xctest */, 0D6D6740721F01A74E404EB9 /* KisaniCalWidgets.appex */, 7A9D47B284FD6A217AEF813B /* WenzaWatch.app */, ); @@ -319,6 +342,24 @@ productReference = 7A9D47B284FD6A217AEF813B /* WenzaWatch.app */; productType = "com.apple.product-type.application"; }; + 75535A64153951A3C35F75FC /* KisaniCalTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = A0CF5E0496CC1077B2417541 /* Build configuration list for PBXNativeTarget "KisaniCalTests" */; + buildPhases = ( + 474D88E5A3251407875B5C24 /* Sources */, + ); + buildRules = ( + ); + dependencies = ( + EEF70F5652B35E046A79A5A3 /* PBXTargetDependency */, + ); + name = KisaniCalTests; + packageProductDependencies = ( + ); + productName = KisaniCalTests; + productReference = 20E29A9264577A89891DAEC1 /* KisaniCalTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -340,6 +381,9 @@ DevelopmentTeam = K8BLMMR883; ProvisioningStyle = Automatic; }; + 75535A64153951A3C35F75FC = { + DevelopmentTeam = K8BLMMR883; + }; }; }; buildConfigurationList = EF1C4F3BDA4AA7E008FC2CDE /* Build configuration list for PBXProject "KisaniCal" */; @@ -357,6 +401,7 @@ projectRoot = ""; targets = ( 297C0C2BFBA10AB52D5D49CE /* KisaniCal */, + 75535A64153951A3C35F75FC /* KisaniCalTests */, 28B43516AD88946E21D9BFE0 /* KisaniCalWidgets */, 403D989BDC6268361CFFB479 /* WenzaWatch */, ); @@ -378,6 +423,14 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ + 474D88E5A3251407875B5C24 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 6BCBF3902E38FBAA7348840D /* CalendarGridTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 6DEFD550940764CA28D993CE /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -394,6 +447,7 @@ 2CD2313EDC4BFCE6E89C6787 /* AppGroup.swift in Sources */, 3B57EA3600AFE975850DF39A /* AuthManager.swift in Sources */, ECEAA5CF7309E5993D12B571 /* AuthView.swift in Sources */, + A292B0492135BA40F6B6A951 /* CalendarGrid.swift in Sources */, 9070521B1D36A5551976C275 /* CalendarView.swift in Sources */, 06CA0F336E64D9F6D56F7472 /* CloudSyncManager.swift in Sources */, 13E4B9854F595394FC9D5912 /* ContentView.swift in Sources */, @@ -445,6 +499,11 @@ target = 28B43516AD88946E21D9BFE0 /* KisaniCalWidgets */; targetProxy = D04FCEE0BDF30AEFD1C969B6 /* PBXContainerItemProxy */; }; + EEF70F5652B35E046A79A5A3 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 297C0C2BFBA10AB52D5D49CE /* KisaniCal */; + targetProxy = BF401FDD105729FB67DF02D6 /* PBXContainerItemProxy */; + }; /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ @@ -481,6 +540,25 @@ }; name = Release; }; + 1092C43DF580318C5090D54B /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.kutesir.KisaniCalTests; + SDKROOT = iphoneos; + SWIFT_VERSION = 5.9; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/KisaniCal.app/KisaniCal"; + }; + name = Release; + }; 20E23A89279CC26778E21856 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { @@ -539,6 +617,25 @@ }; name = Release; }; + 4F022C5E53C268A49F093011 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.kutesir.KisaniCalTests; + SDKROOT = iphoneos; + SWIFT_VERSION = 5.9; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/KisaniCal.app/KisaniCal"; + }; + name = Debug; + }; B60AA89AB378D4EA773989AA /* Release */ = { isa = XCBuildConfiguration; buildSettings = { @@ -728,6 +825,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Debug; }; + A0CF5E0496CC1077B2417541 /* Build configuration list for PBXNativeTarget "KisaniCalTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 4F022C5E53C268A49F093011 /* Debug */, + 1092C43DF580318C5090D54B /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; B60FDD2C88D1D33CF1EC02B3 /* Build configuration list for PBXNativeTarget "KisaniCal" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/KisaniCal.xcodeproj/xcshareddata/xcschemes/KisaniCal.xcscheme b/KisaniCal.xcodeproj/xcshareddata/xcschemes/KisaniCal.xcscheme index 9db359c..4071af8 100644 --- a/KisaniCal.xcodeproj/xcshareddata/xcschemes/KisaniCal.xcscheme +++ b/KisaniCal.xcodeproj/xcshareddata/xcschemes/KisaniCal.xcscheme @@ -39,6 +39,17 @@ + + + + diff --git a/KisaniCal/ContentView.swift b/KisaniCal/ContentView.swift index fac3fe7..7b4f03b 100644 --- a/KisaniCal/ContentView.swift +++ b/KisaniCal/ContentView.swift @@ -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 diff --git a/KisaniCal/Models/CalendarGrid.swift b/KisaniCal/Models/CalendarGrid.swift new file mode 100644 index 0000000..6160248 --- /dev/null +++ b/KisaniCal/Models/CalendarGrid.swift @@ -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] } + } +} diff --git a/KisaniCal/Views/CalendarView.swift b/KisaniCal/Views/CalendarView.swift index 4e3868d..59a3f0e 100644 --- a/KisaniCal/Views/CalendarView.swift +++ b/KisaniCal/Views/CalendarView.swift @@ -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) } } diff --git a/KisaniCalTests/CalendarGridTests.swift b/KisaniCalTests/CalendarGridTests.swift new file mode 100644 index 0000000..0d1c02b --- /dev/null +++ b/KisaniCalTests/CalendarGridTests.swift @@ -0,0 +1,158 @@ +import XCTest +@testable import KisaniCal + +/// Calendar correctness tests. These treat the month grid as a data-integrity concern: +/// every cell must be a real date, columns must respect firstWeekday, and the weekday +/// header must match the grid for any firstWeekday and timezone. +final class CalendarGridTests: XCTestCase { + + private func makeCalendar(firstWeekday: Int, tz: String) -> Calendar { + var c = Calendar(identifier: .gregorian) + c.timeZone = TimeZone(identifier: tz)! + c.firstWeekday = firstWeekday + return c + } + + private func iso(_ date: Date, _ cal: Calendar) -> String { + let c = cal.dateComponents([.year, .month, .day], from: date) + return String(format: "%04d-%02d-%02d", c.year!, c.month!, c.day!) + } + + private func date(_ y: Int, _ m: Int, _ d: Int, _ cal: Calendar) -> Date { + cal.date(from: DateComponents(year: y, month: m, day: d))! + } + + private let timezones = ["America/New_York", "Europe/London", "Pacific/Kiritimati", "UTC"] + + // MARK: - Ground-truth weekday facts + + func testJune2026WeekdayFacts() { + for tz in timezones { + let cal = makeCalendar(firstWeekday: 1, tz: tz) + let f = DateFormatter(); f.calendar = cal; f.timeZone = cal.timeZone; f.dateFormat = "EEEE" + XCTAssertEqual(f.string(from: date(2026, 6, 1, cal)), "Monday", tz) + XCTAssertEqual(f.string(from: date(2026, 6, 7, cal)), "Sunday", tz) + XCTAssertEqual(f.string(from: date(2026, 6, 24, cal)), "Wednesday", tz) + XCTAssertEqual(f.string(from: date(2026, 6, 27, cal)), "Saturday", tz) + XCTAssertEqual(f.string(from: date(2026, 6, 30, cal)), "Tuesday", tz) + XCTAssertEqual(f.string(from: date(2026, 7, 1, cal)), "Wednesday", tz) + } + } + + // MARK: - Sunday-first June 2026 grid matches the exact spec + + func testSundayFirstJune2026GridMatchesSpec() { + let expected = [ + "2026-05-31","2026-06-01","2026-06-02","2026-06-03","2026-06-04","2026-06-05","2026-06-06", + "2026-06-07","2026-06-08","2026-06-09","2026-06-10","2026-06-11","2026-06-12","2026-06-13", + "2026-06-14","2026-06-15","2026-06-16","2026-06-17","2026-06-18","2026-06-19","2026-06-20", + "2026-06-21","2026-06-22","2026-06-23","2026-06-24","2026-06-25","2026-06-26","2026-06-27", + "2026-06-28","2026-06-29","2026-06-30","2026-07-01","2026-07-02","2026-07-03","2026-07-04", + ] + for tz in timezones { + let cal = makeCalendar(firstWeekday: 1, tz: tz) + let grid = CalendarGrid.monthGrid(for: date(2026, 6, 15, cal), calendar: cal) + XCTAssertEqual(grid.map { iso($0, cal) }, expected, "Sunday-first grid wrong in \(tz)") + XCTAssertEqual(grid.count % 7, 0, "grid must be whole weeks") + } + } + + // MARK: - Header always matches grid (the actual bug) + + func testHeaderMatchesGridFirstColumnForAnyFirstWeekday() { + for tz in timezones { + for fw in 1...7 { + let cal = makeCalendar(firstWeekday: fw, tz: tz) + let grid = CalendarGrid.monthGrid(for: date(2026, 6, 15, cal), calendar: cal) + let header = CalendarGrid.weekdaySymbols(calendar: cal) + let letters = ["S","M","T","W","T","F","S"] // weekday 1...7 + for col in 0..<7 { + let weekday = cal.component(.weekday, from: grid[col]) // 1=Sun..7=Sat + XCTAssertEqual(header[col], letters[weekday - 1], + "col \(col) header \(header[col]) != grid weekday \(weekday) (fw=\(fw), tz=\(tz))") + } + } + } + } + + func testHeaderRotations() { + let sun = makeCalendar(firstWeekday: 1, tz: "UTC") + let mon = makeCalendar(firstWeekday: 2, tz: "UTC") + XCTAssertEqual(CalendarGrid.weekdaySymbols(calendar: sun), ["S","M","T","W","T","F","S"]) + XCTAssertEqual(CalendarGrid.weekdaySymbols(calendar: mon), ["M","T","W","T","F","S","S"]) + } + + // MARK: - Cell date identity + + func testCellIdentityIsRealDate() { + let cal = makeCalendar(firstWeekday: 1, tz: "America/New_York") + let grid = CalendarGrid.monthGrid(for: date(2026, 6, 1, cal), calendar: cal) + // June 24 must be a Wednesday and the 25th element (index 24) in the spec grid. + let june24 = grid.first { cal.isDate($0, inSameDayAs: date(2026, 6, 24, cal)) } + XCTAssertNotNil(june24) + XCTAssertEqual(cal.component(.weekday, from: june24!), 4) // Wednesday + // No duplicate dates, strictly increasing by one day. + for i in 1.. trails into Jan 2027 + let dec = CalendarGrid.monthGrid(for: date(2026, 12, 10, cal), calendar: cal).map { iso($0, cal) } + XCTAssertTrue(dec.contains("2027-01-01")) + XCTAssertEqual(dec.first, "2026-11-29") + // Feb 2026 (non-leap): contains Feb 28, not Feb 29 + let feb26 = CalendarGrid.monthGrid(for: date(2026, 2, 10, cal), calendar: cal).map { iso($0, cal) } + XCTAssertTrue(feb26.contains("2026-02-28")) + XCTAssertFalse(feb26.contains("2026-02-29")) + // Feb 2028 (leap): contains Feb 29 + let feb28 = CalendarGrid.monthGrid(for: date(2028, 2, 10, cal), calendar: cal).map { iso($0, cal) } + XCTAssertTrue(feb28.contains("2028-02-29")) + // May & July 2026 endpoints + let may = CalendarGrid.monthGrid(for: date(2026, 5, 10, cal), calendar: cal).map { iso($0, cal) } + XCTAssertTrue(may.contains("2026-05-01") && may.contains("2026-05-31")) + let jul = CalendarGrid.monthGrid(for: date(2026, 7, 10, cal), calendar: cal).map { iso($0, cal) } + XCTAssertTrue(jul.contains("2026-07-01") && jul.contains("2026-07-31")) + } + + // MARK: - Event-day bucketing (same logic CalendarStore.events(for:) uses) + + /// Mirrors CalendarStore.events(for:) overlap test: s < endOfDay && e > startOfDay. + private func overlaps(start: Date, end: Date, day: Date, cal: Calendar) -> Bool { + let dayStart = cal.startOfDay(for: day) + let dayEnd = cal.date(byAdding: .day, value: 1, to: dayStart)! + return start < dayEnd && end > dayStart + } + + func testAllDayEventMapsToSingleLocalDay() { + let cal = makeCalendar(firstWeekday: 1, tz: "America/New_York") + // All-day June 24: [Jun24 00:00, Jun25 00:00) + let s = cal.startOfDay(for: date(2026, 6, 24, cal)) + let e = cal.date(byAdding: .day, value: 1, to: s)! + XCTAssertTrue(overlaps(start: s, end: e, day: date(2026, 6, 24, cal), cal: cal)) + XCTAssertFalse(overlaps(start: s, end: e, day: date(2026, 6, 23, cal), cal: cal)) + XCTAssertFalse(overlaps(start: s, end: e, day: date(2026, 6, 25, cal), cal: cal)) + } + + func testLateNightTimedEventStaysOnLocalDay() { + let cal = makeCalendar(firstWeekday: 1, tz: "America/New_York") + // 11:30pm–11:45pm local on June 24 — must not bleed into the 25th. + let s = cal.date(from: DateComponents(year: 2026, month: 6, day: 24, hour: 23, minute: 30))! + let e = cal.date(from: DateComponents(year: 2026, month: 6, day: 24, hour: 23, minute: 45))! + XCTAssertTrue(overlaps(start: s, end: e, day: date(2026, 6, 24, cal), cal: cal)) + XCTAssertFalse(overlaps(start: s, end: e, day: date(2026, 6, 25, cal), cal: cal)) + } + + func testEventCrossingMidnightHitsBothDays() { + let cal = makeCalendar(firstWeekday: 1, tz: "America/New_York") + let s = cal.date(from: DateComponents(year: 2026, month: 6, day: 24, hour: 23, minute: 30))! + let e = cal.date(from: DateComponents(year: 2026, month: 6, day: 25, hour: 0, minute: 30))! + XCTAssertTrue(overlaps(start: s, end: e, day: date(2026, 6, 24, cal), cal: cal)) + XCTAssertTrue(overlaps(start: s, end: e, day: date(2026, 6, 25, cal), cal: cal)) + XCTAssertFalse(overlaps(start: s, end: e, day: date(2026, 6, 23, cal), cal: cal)) + } +} diff --git a/project.yml b/project.yml index 8af96b3..e5e4a26 100644 --- a/project.yml +++ b/project.yml @@ -21,6 +21,8 @@ schemes: config: Debug test: config: Debug + targets: + - KisaniCalTests profile: config: Release analyze: @@ -65,6 +67,19 @@ targets: INFOPLIST_KEY_NSHealthShareUsageDescription: "Wenza reads your steps, energy, heart rate, and workouts to show health stats on your dashboard." INFOPLIST_KEY_NSHealthUpdateUsageDescription: "Wenza saves workouts you complete to the Health app." + KisaniCalTests: + type: bundle.unit-test + platform: iOS + deploymentTarget: "16.0" + sources: + - path: KisaniCalTests + dependencies: + - target: KisaniCal + settings: + base: + SWIFT_VERSION: 5.9 + GENERATE_INFOPLIST_FILE: YES + KisaniCalWidgets: type: app-extension platform: iOS