From e5306c655d37f1bc4d00ec598bb838b334ec2e81 Mon Sep 17 00:00:00 2001 From: andrewmuratov Date: Sun, 13 Sep 2026 13:57:00 -0400 Subject: [PATCH 01/12] feat: establish native iOS application foundation --- .github/workflows/ios.yml | 45 ++ .swift-format | 11 + Gapwise.xcodeproj/project.pbxproj | 441 ++++++++++++++++++ .../xcshareddata/xcschemes/Gapwise.xcscheme | 102 ++++ Gapwise/App/AppDependencies.swift | 22 + Gapwise/App/AppModel.swift | 90 ++++ Gapwise/App/GapwiseApp.swift | 21 + Gapwise/App/RootView.swift | 55 +++ Gapwise/Core/Models/AcademicTerm.swift | 56 +++ Gapwise/Core/Models/Campus.swift | 25 + Gapwise/Core/Models/CourseMeeting.swift | 179 +++++++ Gapwise/Core/Models/LocalTime.swift | 51 ++ Gapwise/Core/Models/TimetableSnapshot.swift | 36 ++ Gapwise/Core/Models/Weekday.swift | 15 + .../InMemoryTimetableRepository.swift | 21 + .../Persistence/JSONTimetableRepository.swift | 63 +++ .../Persistence/PreferencesRepository.swift | 48 ++ .../Persistence/TimetableRepository.swift | 11 + .../Core/Utilities/ScheduleCalculator.swift | 126 +++++ Gapwise/DesignSystem/GapwiseFormatters.swift | 42 ++ Gapwise/DesignSystem/GapwiseTheme.swift | 32 ++ Gapwise/Development/PreviewData.swift | 114 +++++ Gapwise/Features/Campus/CampusView.swift | 69 +++ Gapwise/Features/Settings/SettingsView.swift | 136 ++++++ Gapwise/Features/Timetable/MeetingCard.swift | 71 +++ .../Features/Timetable/TimetableView.swift | 117 +++++ Gapwise/Features/Timetable/WeekdayStrip.swift | 49 ++ Gapwise/Features/Today/TodayView.swift | 138 ++++++ .../AccentColor.colorset/Contents.json | 38 ++ .../AppIcon.appiconset/AppIcon.png | Bin 0 -> 128675 bytes .../AppIcon.appiconset/Contents.json | 14 + .../Resources/Assets.xcassets/Contents.json | 6 + .../GapwiseBrand.imageset/Contents.json | 12 + .../GapwiseBrand.imageset/gapwise-ios.png | Bin 0 -> 58503 bytes GapwiseTests/Core/TimetableDomainTests.swift | 291 ++++++++++++ Package.swift | 25 + README.md | 46 +- 37 files changed, 2602 insertions(+), 16 deletions(-) create mode 100644 .github/workflows/ios.yml create mode 100644 .swift-format create mode 100644 Gapwise.xcodeproj/project.pbxproj create mode 100644 Gapwise.xcodeproj/xcshareddata/xcschemes/Gapwise.xcscheme create mode 100644 Gapwise/App/AppDependencies.swift create mode 100644 Gapwise/App/AppModel.swift create mode 100644 Gapwise/App/GapwiseApp.swift create mode 100644 Gapwise/App/RootView.swift create mode 100644 Gapwise/Core/Models/AcademicTerm.swift create mode 100644 Gapwise/Core/Models/Campus.swift create mode 100644 Gapwise/Core/Models/CourseMeeting.swift create mode 100644 Gapwise/Core/Models/LocalTime.swift create mode 100644 Gapwise/Core/Models/TimetableSnapshot.swift create mode 100644 Gapwise/Core/Models/Weekday.swift create mode 100644 Gapwise/Core/Persistence/InMemoryTimetableRepository.swift create mode 100644 Gapwise/Core/Persistence/JSONTimetableRepository.swift create mode 100644 Gapwise/Core/Persistence/PreferencesRepository.swift create mode 100644 Gapwise/Core/Persistence/TimetableRepository.swift create mode 100644 Gapwise/Core/Utilities/ScheduleCalculator.swift create mode 100644 Gapwise/DesignSystem/GapwiseFormatters.swift create mode 100644 Gapwise/DesignSystem/GapwiseTheme.swift create mode 100644 Gapwise/Development/PreviewData.swift create mode 100644 Gapwise/Features/Campus/CampusView.swift create mode 100644 Gapwise/Features/Settings/SettingsView.swift create mode 100644 Gapwise/Features/Timetable/MeetingCard.swift create mode 100644 Gapwise/Features/Timetable/TimetableView.swift create mode 100644 Gapwise/Features/Timetable/WeekdayStrip.swift create mode 100644 Gapwise/Features/Today/TodayView.swift create mode 100644 Gapwise/Resources/Assets.xcassets/AccentColor.colorset/Contents.json create mode 100644 Gapwise/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon.png create mode 100644 Gapwise/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 Gapwise/Resources/Assets.xcassets/Contents.json create mode 100644 Gapwise/Resources/Assets.xcassets/GapwiseBrand.imageset/Contents.json create mode 100644 Gapwise/Resources/Assets.xcassets/GapwiseBrand.imageset/gapwise-ios.png create mode 100644 GapwiseTests/Core/TimetableDomainTests.swift create mode 100644 Package.swift diff --git a/.github/workflows/ios.yml b/.github/workflows/ios.yml new file mode 100644 index 0000000..aa0c47a --- /dev/null +++ b/.github/workflows/ios.yml @@ -0,0 +1,45 @@ +name: iOS + +on: + push: + branches: [main] + pull_request: + +concurrency: + group: ios-${{ github.ref }} + cancel-in-progress: true + +jobs: + portable-core: + name: Portable core tests + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + - name: Show Swift version + run: swift --version + - name: Test core + run: swift test + + ios: + name: iOS build and tests + runs-on: macos-15 + steps: + - uses: actions/checkout@v4 + - name: Show toolchain + run: xcodebuild -version + - name: Inspect project + run: xcodebuild -project Gapwise.xcodeproj -list + - name: Select an available iPhone simulator + run: | + destination_id=$(xcrun simctl list devices available --json | ruby -rjson -e 'devices = JSON.parse(STDIN.read).fetch("devices").values.flatten; phone = devices.find { |device| device["isAvailable"] && device["name"].start_with?("iPhone") }; abort "No available iPhone simulator was found" unless phone; puts phone.fetch("udid")') + echo "DESTINATION_ID=$destination_id" >> "$GITHUB_ENV" + - name: Build and test + run: | + set -o pipefail + xcodebuild \ + -project Gapwise.xcodeproj \ + -scheme Gapwise \ + -configuration Debug \ + -destination "platform=iOS Simulator,id=$DESTINATION_ID" \ + CODE_SIGNING_ALLOWED=NO \ + test diff --git a/.swift-format b/.swift-format new file mode 100644 index 0000000..37826c2 --- /dev/null +++ b/.swift-format @@ -0,0 +1,11 @@ +{ + "indentation" : { + "spaces" : 4 + }, + "lineLength" : 120, + "rules" : { + "NeverForceUnwrap" : true, + "NeverUseImplicitlyUnwrappedOptionals" : true + }, + "version" : 1 +} diff --git a/Gapwise.xcodeproj/project.pbxproj b/Gapwise.xcodeproj/project.pbxproj new file mode 100644 index 0000000..588efd9 --- /dev/null +++ b/Gapwise.xcodeproj/project.pbxproj @@ -0,0 +1,441 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXContainerItemProxy section */ + A00000000000000000000032 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = A00000000000000000000000 /* Project object */; + proxyType = 1; + remoteGlobalIDString = A00000000000000000000030; + remoteInfo = Gapwise; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXFileReference section */ + A00000000000000000000005 /* Gapwise.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Gapwise.app; sourceTree = BUILT_PRODUCTS_DIR; }; + A00000000000000000000006 /* GapwiseTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = GapwiseTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + A00000000000000000000003 /* Gapwise */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = Gapwise; + sourceTree = ""; + }; + A00000000000000000000004 /* GapwiseTests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = GapwiseTests; + sourceTree = ""; + }; +/* End PBXFileSystemSynchronizedRootGroup section */ + +/* Begin PBXFrameworksBuildPhase section */ + A00000000000000000000011 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + A00000000000000000000014 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + A00000000000000000000001 = { + isa = PBXGroup; + children = ( + A00000000000000000000003 /* Gapwise */, + A00000000000000000000004 /* GapwiseTests */, + A00000000000000000000002 /* Products */, + ); + sourceTree = ""; + }; + A00000000000000000000002 /* Products */ = { + isa = PBXGroup; + children = ( + A00000000000000000000005 /* Gapwise.app */, + A00000000000000000000006 /* GapwiseTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + A00000000000000000000030 /* Gapwise */ = { + isa = PBXNativeTarget; + buildConfigurationList = A00000000000000000000025 /* Build configuration list for PBXNativeTarget "Gapwise" */; + buildPhases = ( + A00000000000000000000010 /* Sources */, + A00000000000000000000011 /* Frameworks */, + A00000000000000000000012 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + A00000000000000000000003 /* Gapwise */, + ); + name = Gapwise; + packageProductDependencies = ( + ); + productName = Gapwise; + productReference = A00000000000000000000005 /* Gapwise.app */; + productType = "com.apple.product-type.application"; + }; + A00000000000000000000031 /* GapwiseTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = A00000000000000000000028 /* Build configuration list for PBXNativeTarget "GapwiseTests" */; + buildPhases = ( + A00000000000000000000013 /* Sources */, + A00000000000000000000014 /* Frameworks */, + A00000000000000000000015 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + A00000000000000000000033 /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + A00000000000000000000004 /* GapwiseTests */, + ); + name = GapwiseTests; + packageProductDependencies = ( + ); + productName = GapwiseTests; + productReference = A00000000000000000000006 /* GapwiseTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + A00000000000000000000000 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 1640; + LastUpgradeCheck = 1640; + TargetAttributes = { + A00000000000000000000030 = { + CreatedOnToolsVersion = 16.4; + }; + A00000000000000000000031 = { + CreatedOnToolsVersion = 16.4; + TestTargetID = A00000000000000000000030; + }; + }; + }; + buildConfigurationList = A00000000000000000000022 /* Build configuration list for PBXProject "Gapwise" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = A00000000000000000000001; + minimizedProjectReferenceProxies = 1; + preferredProjectObjectVersion = 77; + productRefGroup = A00000000000000000000002 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + A00000000000000000000030 /* Gapwise */, + A00000000000000000000031 /* GapwiseTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + A00000000000000000000012 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + A00000000000000000000015 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + A00000000000000000000010 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + A00000000000000000000013 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + A00000000000000000000033 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = A00000000000000000000030 /* Gapwise */; + targetProxy = A00000000000000000000032 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin XCBuildConfiguration section */ + A00000000000000000000020 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + A00000000000000000000021 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + A00000000000000000000023 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = Gapwise; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.education"; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 0.1.0; + PRODUCT_BUNDLE_IDENTIFIER = ca.gapwise.ios; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_STRICT_CONCURRENCY = complete; + SWIFT_VERSION = 6.0; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Debug; + }; + A00000000000000000000024 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = Gapwise; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.education"; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 0.1.0; + PRODUCT_BUNDLE_IDENTIFIER = ca.gapwise.ios; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_STRICT_CONCURRENCY = complete; + SWIFT_VERSION = 6.0; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Release; + }; + A00000000000000000000026 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + MARKETING_VERSION = 0.1.0; + PRODUCT_BUNDLE_IDENTIFIER = ca.gapwise.ios.tests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_STRICT_CONCURRENCY = complete; + SWIFT_VERSION = 6.0; + TARGETED_DEVICE_FAMILY = 1; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Gapwise.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Gapwise"; + }; + name = Debug; + }; + A00000000000000000000027 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + MARKETING_VERSION = 0.1.0; + PRODUCT_BUNDLE_IDENTIFIER = ca.gapwise.ios.tests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_STRICT_CONCURRENCY = complete; + SWIFT_VERSION = 6.0; + TARGETED_DEVICE_FAMILY = 1; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Gapwise.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Gapwise"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + A00000000000000000000022 /* Build configuration list for PBXProject "Gapwise" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + A00000000000000000000020 /* Debug */, + A00000000000000000000021 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + A00000000000000000000025 /* Build configuration list for PBXNativeTarget "Gapwise" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + A00000000000000000000023 /* Debug */, + A00000000000000000000024 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + A00000000000000000000028 /* Build configuration list for PBXNativeTarget "GapwiseTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + A00000000000000000000026 /* Debug */, + A00000000000000000000027 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = A00000000000000000000000 /* Project object */; +} diff --git a/Gapwise.xcodeproj/xcshareddata/xcschemes/Gapwise.xcscheme b/Gapwise.xcodeproj/xcshareddata/xcschemes/Gapwise.xcscheme new file mode 100644 index 0000000..96bc031 --- /dev/null +++ b/Gapwise.xcodeproj/xcshareddata/xcschemes/Gapwise.xcscheme @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Gapwise/App/AppDependencies.swift b/Gapwise/App/AppDependencies.swift new file mode 100644 index 0000000..11d038f --- /dev/null +++ b/Gapwise/App/AppDependencies.swift @@ -0,0 +1,22 @@ +import Foundation + +enum AppDependencies { + @MainActor + static func live() -> AppModel { + let timetableRepository: any TimetableRepository + var startupMessage: String? + + do { + timetableRepository = try JSONTimetableRepository.applicationSupport() + } catch { + timetableRepository = InMemoryTimetableRepository() + startupMessage = "Local storage is temporarily unavailable. Changes made in this session may not persist." + } + + return AppModel( + timetableRepository: timetableRepository, + preferencesRepository: UserDefaultsPreferencesRepository(), + startupMessage: startupMessage + ) + } +} diff --git a/Gapwise/App/AppModel.swift b/Gapwise/App/AppModel.swift new file mode 100644 index 0000000..d7ef809 --- /dev/null +++ b/Gapwise/App/AppModel.swift @@ -0,0 +1,90 @@ +import Foundation +import Observation +import SwiftUI + +@MainActor +@Observable +final class AppModel { + private(set) var timetable: TimetableSnapshot + private(set) var preferences: UserPreferences + private(set) var isLoading = false + var selectedTimetableDate: Date + var alertMessage: String? + + @ObservationIgnored private let timetableRepository: any TimetableRepository + @ObservationIgnored private let preferencesRepository: any PreferencesRepository + @ObservationIgnored private var hasLoaded = false + @ObservationIgnored private var preferencesSaveTask: Task? + + init( + timetableRepository: any TimetableRepository, + preferencesRepository: any PreferencesRepository, + timetable: TimetableSnapshot = .empty, + preferences: UserPreferences = .defaults, + selectedTimetableDate: Date = .now, + startupMessage: String? = nil + ) { + self.timetableRepository = timetableRepository + self.preferencesRepository = preferencesRepository + self.timetable = timetable + self.preferences = preferences + self.selectedTimetableDate = selectedTimetableDate + alertMessage = startupMessage + } + + var preferredColorScheme: ColorScheme? { + switch preferences.appearance { + case .system: nil + case .light: .light + case .dark: .dark + } + } + + func load() async { + guard !hasLoaded else { return } + hasLoaded = true + isLoading = true + defer { isLoading = false } + + preferences = await preferencesRepository.load() + + do { + timetable = try await timetableRepository.load() + } catch { + alertMessage = "Your saved timetable could not be loaded. The stored data has not been changed." + } + } + + func setAppearance(_ appearance: AppearancePreference) { + preferences.appearance = appearance + persistPreferences() + } + + func setCampusContext(_ campus: Campus) { + preferences.campusContext = campus + persistPreferences() + } + + func clearTimetable() async { + do { + try await timetableRepository.clear() + timetable = .empty + } catch { + alertMessage = "Your saved timetable could not be removed." + } + } + + func dismissAlert() { + alertMessage = nil + } + + private func persistPreferences() { + let preferences = preferences + let repository = preferencesRepository + preferencesSaveTask?.cancel() + preferencesSaveTask = Task { + guard !Task.isCancelled else { return } + await repository.save(preferences) + } + } +} diff --git a/Gapwise/App/GapwiseApp.swift b/Gapwise/App/GapwiseApp.swift new file mode 100644 index 0000000..896c823 --- /dev/null +++ b/Gapwise/App/GapwiseApp.swift @@ -0,0 +1,21 @@ +import SwiftUI + +@main +struct GapwiseApp: App { + @State private var appModel: AppModel + + init() { + _appModel = State(initialValue: AppDependencies.live()) + } + + var body: some Scene { + WindowGroup { + RootView() + .environment(appModel) + .preferredColorScheme(appModel.preferredColorScheme) + .task { + await appModel.load() + } + } + } +} diff --git a/Gapwise/App/RootView.swift b/Gapwise/App/RootView.swift new file mode 100644 index 0000000..99f820f --- /dev/null +++ b/Gapwise/App/RootView.swift @@ -0,0 +1,55 @@ +import SwiftUI + +struct RootView: View { + @Environment(AppModel.self) private var appModel + + var body: some View { + TabView { + NavigationStack { + TodayView() + } + .tabItem { + Label("Today", systemImage: "sun.max") + } + + NavigationStack { + TimetableView() + } + .tabItem { + Label("Timetable", systemImage: "calendar") + } + + NavigationStack { + CampusView() + } + .tabItem { + Label("Campus", systemImage: "map") + } + + NavigationStack { + SettingsView() + } + .tabItem { + Label("Settings", systemImage: "gearshape") + } + } + .tint(.gapwiseAccent) + .alert( + "Gapwise", + isPresented: Binding( + get: { appModel.alertMessage != nil }, + set: { isPresented in + if !isPresented { appModel.dismissAlert() } + } + ) + ) { + Button("OK", role: .cancel) { + appModel.dismissAlert() + } + } message: { + if let alertMessage = appModel.alertMessage { + Text(alertMessage) + } + } + } +} diff --git a/Gapwise/Core/Models/AcademicTerm.swift b/Gapwise/Core/Models/AcademicTerm.swift new file mode 100644 index 0000000..748a925 --- /dev/null +++ b/Gapwise/Core/Models/AcademicTerm.swift @@ -0,0 +1,56 @@ +import Foundation + +struct LocalDate: Codable, Hashable, Comparable, Sendable { + let year: Int + let month: Int + let day: Int + + init(year: Int, month: Int, day: Int) { + self.year = year + self.month = month + self.day = day + } + + init(date: Date, calendar: Calendar) { + let components = calendar.dateComponents([.year, .month, .day], from: date) + year = components.year ?? 1 + month = components.month ?? 1 + day = components.day ?? 1 + } + + static func < (lhs: Self, rhs: Self) -> Bool { + (lhs.year, lhs.month, lhs.day) < (rhs.year, rhs.month, rhs.day) + } +} + +struct AcademicTerm: Codable, Hashable, Sendable { + struct ID: RawRepresentable, Codable, Hashable, Sendable { + let rawValue: String + + init(rawValue: String) { + self.rawValue = rawValue + } + } + + let id: ID + let displayName: String + let startsOn: LocalDate + let endsOn: LocalDate + + init( + id: ID, + displayName: String, + startsOn: LocalDate, + endsOn: LocalDate + ) { + self.id = id + self.displayName = displayName + self.startsOn = startsOn + self.endsOn = endsOn + } + + func contains(_ date: Date, calendar: Calendar) -> Bool { + let localDate = LocalDate(date: date, calendar: calendar) + return startsOn <= localDate && localDate <= endsOn + } +} diff --git a/Gapwise/Core/Models/Campus.swift b/Gapwise/Core/Models/Campus.swift new file mode 100644 index 0000000..7e464a7 --- /dev/null +++ b/Gapwise/Core/Models/Campus.swift @@ -0,0 +1,25 @@ +import Foundation + +enum Campus: String, CaseIterable, Codable, Hashable, Identifiable, Sendable { + case utm + case utsg + case utsc + + var id: Self { self } + + var shortName: String { + switch self { + case .utm: "UTM" + case .utsg: "UTSG" + case .utsc: "UTSC" + } + } + + var fullName: String { + switch self { + case .utm: "University of Toronto Mississauga" + case .utsg: "University of Toronto St. George" + case .utsc: "University of Toronto Scarborough" + } + } +} diff --git a/Gapwise/Core/Models/CourseMeeting.swift b/Gapwise/Core/Models/CourseMeeting.swift new file mode 100644 index 0000000..93974b2 --- /dev/null +++ b/Gapwise/Core/Models/CourseMeeting.swift @@ -0,0 +1,179 @@ +import Foundation + +enum MeetingType: String, Codable, CaseIterable, Hashable, Sendable { + case lecture + case tutorial + case practical + case other + + var shortName: String { + switch self { + case .lecture: "LEC" + case .tutorial: "TUT" + case .practical: "PRA" + case .other: "Other" + } + } +} + +struct CourseCode: RawRepresentable, Codable, Hashable, Comparable, Sendable { + let rawValue: String + + init?(rawValue: String) { + let normalized = rawValue.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() + guard !normalized.isEmpty else { return nil } + self.rawValue = normalized + } + + static func < (lhs: Self, rhs: Self) -> Bool { + lhs.rawValue < rhs.rawValue + } + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let rawValue = try container.decode(String.self) + guard let code = Self(rawValue: rawValue) else { + throw DecodingError.dataCorruptedError( + in: container, + debugDescription: "A course code cannot be empty." + ) + } + self = code + } + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(rawValue) + } +} + +struct MeetingIdentity: Codable, Hashable, Sendable { + let campus: Campus + let courseCode: CourseCode + let termID: AcademicTerm.ID + let courseSection: String + let meetingType: MeetingType + let meetingSection: String + let sourceIdentifier: String +} + +struct MeetingLocation: Codable, Hashable, Sendable { + let displayName: String + let buildingCode: String? + let room: String? + + init(displayName: String, buildingCode: String? = nil, room: String? = nil) { + self.displayName = displayName + self.buildingCode = buildingCode + self.room = room + } +} + +enum CourseMeetingValidationError: Error, Equatable { + case emptyCourseSection + case emptyMeetingSection + case emptySourceIdentifier + case endNotAfterStart + case noMeetingDays +} + +struct CourseMeeting: Identifiable, Codable, Hashable, Sendable { + let id: MeetingIdentity + let courseTitle: String? + let term: AcademicTerm + let days: Set + let startTime: LocalTime + let endTime: LocalTime + let location: MeetingLocation? + let instructor: String? + + var campus: Campus { id.campus } + var courseCode: CourseCode { id.courseCode } + var meetingType: MeetingType { id.meetingType } + var meetingSection: String { id.meetingSection } + + init( + campus: Campus, + courseCode: CourseCode, + courseTitle: String? = nil, + term: AcademicTerm, + courseSection: String, + meetingType: MeetingType, + meetingSection: String, + days: Set, + startTime: LocalTime, + endTime: LocalTime, + location: MeetingLocation? = nil, + instructor: String? = nil, + sourceIdentifier: String + ) throws { + let courseSection = courseSection.trimmingCharacters(in: .whitespacesAndNewlines) + let meetingSection = meetingSection.trimmingCharacters(in: .whitespacesAndNewlines) + let sourceIdentifier = sourceIdentifier.trimmingCharacters(in: .whitespacesAndNewlines) + + guard !courseSection.isEmpty else { + throw CourseMeetingValidationError.emptyCourseSection + } + guard !meetingSection.isEmpty else { + throw CourseMeetingValidationError.emptyMeetingSection + } + guard !sourceIdentifier.isEmpty else { + throw CourseMeetingValidationError.emptySourceIdentifier + } + guard endTime > startTime else { + throw CourseMeetingValidationError.endNotAfterStart + } + guard !days.isEmpty else { + throw CourseMeetingValidationError.noMeetingDays + } + + id = MeetingIdentity( + campus: campus, + courseCode: courseCode, + termID: term.id, + courseSection: courseSection, + meetingType: meetingType, + meetingSection: meetingSection, + sourceIdentifier: sourceIdentifier + ) + self.courseTitle = courseTitle?.trimmingCharacters(in: .whitespacesAndNewlines) + self.term = term + self.days = days + self.startTime = startTime + self.endTime = endTime + self.location = location + self.instructor = instructor?.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private enum CodingKeys: String, CodingKey { + case id + case courseTitle + case term + case days + case startTime + case endTime + case location + case instructor + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let id = try container.decode(MeetingIdentity.self, forKey: .id) + + try self.init( + campus: id.campus, + courseCode: id.courseCode, + courseTitle: try container.decodeIfPresent(String.self, forKey: .courseTitle), + term: try container.decode(AcademicTerm.self, forKey: .term), + courseSection: id.courseSection, + meetingType: id.meetingType, + meetingSection: id.meetingSection, + days: try container.decode(Set.self, forKey: .days), + startTime: try container.decode(LocalTime.self, forKey: .startTime), + endTime: try container.decode(LocalTime.self, forKey: .endTime), + location: try container.decodeIfPresent(MeetingLocation.self, forKey: .location), + instructor: try container.decodeIfPresent(String.self, forKey: .instructor), + sourceIdentifier: id.sourceIdentifier + ) + } +} diff --git a/Gapwise/Core/Models/LocalTime.swift b/Gapwise/Core/Models/LocalTime.swift new file mode 100644 index 0000000..789312b --- /dev/null +++ b/Gapwise/Core/Models/LocalTime.swift @@ -0,0 +1,51 @@ +import Foundation + +struct LocalTime: Codable, Hashable, Comparable, Sendable { + let minutesSinceMidnight: Int + + var hour: Int { minutesSinceMidnight / 60 } + var minute: Int { minutesSinceMidnight % 60 } + + init?(hour: Int, minute: Int) { + guard (0..<24).contains(hour), (0..<60).contains(minute) else { + return nil + } + + self.init(validatedMinutes: hour * 60 + minute) + } + + static func < (lhs: Self, rhs: Self) -> Bool { + lhs.minutesSinceMidnight < rhs.minutesSinceMidnight + } + + private init(validatedMinutes: Int) { + minutesSinceMidnight = validatedMinutes + } + + private enum CodingKeys: String, CodingKey { + case hour + case minute + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let hour = try container.decode(Int.self, forKey: .hour) + let minute = try container.decode(Int.self, forKey: .minute) + + guard let time = Self(hour: hour, minute: minute) else { + throw DecodingError.dataCorruptedError( + forKey: .hour, + in: container, + debugDescription: "A local time must be between 00:00 and 23:59." + ) + } + + self = time + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(hour, forKey: .hour) + try container.encode(minute, forKey: .minute) + } +} diff --git a/Gapwise/Core/Models/TimetableSnapshot.swift b/Gapwise/Core/Models/TimetableSnapshot.swift new file mode 100644 index 0000000..7677d2a --- /dev/null +++ b/Gapwise/Core/Models/TimetableSnapshot.swift @@ -0,0 +1,36 @@ +import Foundation + +struct TimetableSnapshot: Codable, Equatable, Sendable { + static let empty = TimetableSnapshot(meetings: [], lastModified: nil) + + var meetings: [CourseMeeting] + var lastModified: Date? + + init(meetings: [CourseMeeting], lastModified: Date? = nil) { + self.meetings = meetings + self.lastModified = lastModified + } +} + +enum AppearancePreference: String, CaseIterable, Codable, Identifiable, Sendable { + case system + case light + case dark + + var id: Self { self } + + var displayName: String { + switch self { + case .system: "System" + case .light: "Light" + case .dark: "Dark" + } + } +} + +struct UserPreferences: Codable, Equatable, Sendable { + var appearance: AppearancePreference + var campusContext: Campus + + static let defaults = UserPreferences(appearance: .system, campusContext: .utm) +} diff --git a/Gapwise/Core/Models/Weekday.swift b/Gapwise/Core/Models/Weekday.swift new file mode 100644 index 0000000..f872bfd --- /dev/null +++ b/Gapwise/Core/Models/Weekday.swift @@ -0,0 +1,15 @@ +import Foundation + +enum Weekday: Int, CaseIterable, Codable, Hashable, Sendable { + case sunday = 1 + case monday + case tuesday + case wednesday + case thursday + case friday + case saturday + + init?(date: Date, calendar: Calendar) { + self.init(rawValue: calendar.component(.weekday, from: date)) + } +} diff --git a/Gapwise/Core/Persistence/InMemoryTimetableRepository.swift b/Gapwise/Core/Persistence/InMemoryTimetableRepository.swift new file mode 100644 index 0000000..863e1e6 --- /dev/null +++ b/Gapwise/Core/Persistence/InMemoryTimetableRepository.swift @@ -0,0 +1,21 @@ +import Foundation + +actor InMemoryTimetableRepository: TimetableRepository { + private var snapshot: TimetableSnapshot + + init(snapshot: TimetableSnapshot = .empty) { + self.snapshot = snapshot + } + + func load() async throws -> TimetableSnapshot { + snapshot + } + + func save(_ snapshot: TimetableSnapshot) async throws { + self.snapshot = snapshot + } + + func clear() async throws { + snapshot = .empty + } +} diff --git a/Gapwise/Core/Persistence/JSONTimetableRepository.swift b/Gapwise/Core/Persistence/JSONTimetableRepository.swift new file mode 100644 index 0000000..c4352e5 --- /dev/null +++ b/Gapwise/Core/Persistence/JSONTimetableRepository.swift @@ -0,0 +1,63 @@ +import Foundation + +actor JSONTimetableRepository: TimetableRepository { + private let fileURL: URL + private let fileManager: FileManager + private let encoder: JSONEncoder + private let decoder: JSONDecoder + + init(fileURL: URL, fileManager: FileManager = .default) { + self.fileURL = fileURL + self.fileManager = fileManager + + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + self.encoder = encoder + + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + self.decoder = decoder + } + + static func applicationSupport(fileManager: FileManager = .default) throws -> Self { + guard let baseURL = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else { + throw TimetableRepositoryError.applicationSupportUnavailable + } + + return Self( + fileURL: + baseURL + .appendingPathComponent("Gapwise", isDirectory: true) + .appendingPathComponent("timetable.json"), + fileManager: fileManager + ) + } + + func load() async throws -> TimetableSnapshot { + guard fileManager.fileExists(atPath: fileURL.path) else { return .empty } + let data = try Data(contentsOf: fileURL) + return try decoder.decode(TimetableSnapshot.self, from: data) + } + + func save(_ snapshot: TimetableSnapshot) async throws { + try fileManager.createDirectory( + at: fileURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + let data = try encoder.encode(snapshot) + try data.write(to: fileURL, options: .atomic) + + #if os(iOS) + try fileManager.setAttributes( + [.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication], + ofItemAtPath: fileURL.path + ) + #endif + } + + func clear() async throws { + guard fileManager.fileExists(atPath: fileURL.path) else { return } + try fileManager.removeItem(at: fileURL) + } +} diff --git a/Gapwise/Core/Persistence/PreferencesRepository.swift b/Gapwise/Core/Persistence/PreferencesRepository.swift new file mode 100644 index 0000000..9dcb60c --- /dev/null +++ b/Gapwise/Core/Persistence/PreferencesRepository.swift @@ -0,0 +1,48 @@ +import Foundation + +protocol PreferencesRepository: Sendable { + func load() async -> UserPreferences + func save(_ preferences: UserPreferences) async +} + +actor UserDefaultsPreferencesRepository: PreferencesRepository { + private let defaults: UserDefaults + private let key: String + + init(defaults: UserDefaults = .standard, key: String = "gapwise.preferences") { + self.defaults = defaults + self.key = key + } + + func load() async -> UserPreferences { + guard + let data = defaults.data(forKey: key), + let preferences = try? JSONDecoder().decode(UserPreferences.self, from: data) + else { + return .defaults + } + + return preferences + } + + func save(_ preferences: UserPreferences) async { + guard let data = try? JSONEncoder().encode(preferences) else { return } + defaults.set(data, forKey: key) + } +} + +actor InMemoryPreferencesRepository: PreferencesRepository { + private var preferences: UserPreferences + + init(preferences: UserPreferences = .defaults) { + self.preferences = preferences + } + + func load() async -> UserPreferences { + preferences + } + + func save(_ preferences: UserPreferences) async { + self.preferences = preferences + } +} diff --git a/Gapwise/Core/Persistence/TimetableRepository.swift b/Gapwise/Core/Persistence/TimetableRepository.swift new file mode 100644 index 0000000..5a1a5bb --- /dev/null +++ b/Gapwise/Core/Persistence/TimetableRepository.swift @@ -0,0 +1,11 @@ +import Foundation + +protocol TimetableRepository: Sendable { + func load() async throws -> TimetableSnapshot + func save(_ snapshot: TimetableSnapshot) async throws + func clear() async throws +} + +enum TimetableRepositoryError: Error { + case applicationSupportUnavailable +} diff --git a/Gapwise/Core/Utilities/ScheduleCalculator.swift b/Gapwise/Core/Utilities/ScheduleCalculator.swift new file mode 100644 index 0000000..6f81a48 --- /dev/null +++ b/Gapwise/Core/Utilities/ScheduleCalculator.swift @@ -0,0 +1,126 @@ +import Foundation + +enum ClassTiming: Equatable, Sendable { + case inProgress(minutesRemaining: Int) + case upcoming(minutesUntil: Int) +} + +struct ScheduledOccurrence: Equatable, Sendable { + let meeting: CourseMeeting + let interval: DateInterval +} + +struct NextClassContext: Equatable, Sendable { + let occurrence: ScheduledOccurrence + let timing: ClassTiming +} + +struct ScheduleCalculator: Sendable { + let calendar: Calendar + + init(calendar: Calendar = .gapwiseToronto) { + self.calendar = calendar + } + + func meetings(on date: Date, in schedule: [CourseMeeting]) -> [CourseMeeting] { + guard let weekday = Weekday(date: date, calendar: calendar) else { return [] } + + return + schedule + .filter { meeting in + meeting.days.contains(weekday) && meeting.term.contains(date, calendar: calendar) + } + .sorted(by: Self.meetingOrder) + } + + func occurrences(on date: Date, in schedule: [CourseMeeting]) -> [ScheduledOccurrence] { + meetings(on: date, in: schedule).compactMap { meeting in + guard let interval = interval(for: meeting, on: date) else { return nil } + return ScheduledOccurrence(meeting: meeting, interval: interval) + } + } + + func currentOrNextClass(at date: Date, in schedule: [CourseMeeting]) -> NextClassContext? { + for occurrence in occurrences(on: date, in: schedule) { + if occurrence.interval.start <= date && date < occurrence.interval.end { + let minutes = wholeMinutes(from: date, to: occurrence.interval.end) + return NextClassContext( + occurrence: occurrence, + timing: .inProgress(minutesRemaining: minutes) + ) + } + + if occurrence.interval.start > date { + let minutes = wholeMinutes(from: date, to: occurrence.interval.start) + return NextClassContext( + occurrence: occurrence, + timing: .upcoming(minutesUntil: minutes) + ) + } + } + + return nil + } + + func weekDates(containing date: Date) -> [Date] { + let startOfDay = calendar.startOfDay(for: date) + let weekday = calendar.component(.weekday, from: startOfDay) + let daysSinceMonday = (weekday + 5) % 7 + + guard let monday = calendar.date(byAdding: .day, value: -daysSinceMonday, to: startOfDay) else { + return [startOfDay] + } + + return (0..<7).compactMap { offset in + calendar.date(byAdding: .day, value: offset, to: monday) + } + } + + func date(byAddingDays days: Int, to date: Date) -> Date { + calendar.date(byAdding: .day, value: days, to: date) ?? date + } + + private func interval(for meeting: CourseMeeting, on date: Date) -> DateInterval? { + let day = calendar.dateComponents([.year, .month, .day], from: date) + var startComponents = day + startComponents.hour = meeting.startTime.hour + startComponents.minute = meeting.startTime.minute + + var endComponents = day + endComponents.hour = meeting.endTime.hour + endComponents.minute = meeting.endTime.minute + + guard + let start = calendar.date(from: startComponents), + let end = calendar.date(from: endComponents), + end > start + else { + return nil + } + + return DateInterval(start: start, end: end) + } + + private func wholeMinutes(from start: Date, to end: Date) -> Int { + let seconds = max(0, end.timeIntervalSince(start)) + let minutes = seconds / 60 + return Int(ceil(minutes)) + } + + private static func meetingOrder(_ lhs: CourseMeeting, _ rhs: CourseMeeting) -> Bool { + if lhs.startTime != rhs.startTime { return lhs.startTime < rhs.startTime } + if lhs.endTime != rhs.endTime { return lhs.endTime < rhs.endTime } + if lhs.courseCode != rhs.courseCode { return lhs.courseCode < rhs.courseCode } + if lhs.campus != rhs.campus { return lhs.campus.rawValue < rhs.campus.rawValue } + return lhs.id.sourceIdentifier < rhs.id.sourceIdentifier + } +} + +extension Calendar { + static var gapwiseToronto: Calendar { + var calendar = Calendar(identifier: .gregorian) + calendar.locale = Locale(identifier: "en_CA") + calendar.timeZone = TimeZone(identifier: "America/Toronto") ?? .current + return calendar + } +} diff --git a/Gapwise/DesignSystem/GapwiseFormatters.swift b/Gapwise/DesignSystem/GapwiseFormatters.swift new file mode 100644 index 0000000..d4b9664 --- /dev/null +++ b/Gapwise/DesignSystem/GapwiseFormatters.swift @@ -0,0 +1,42 @@ +import Foundation + +enum GapwiseFormatters { + static func time(_ time: LocalTime, on date: Date, calendar: Calendar) -> String { + var components = calendar.dateComponents([.year, .month, .day], from: date) + components.hour = time.hour + components.minute = time.minute + + guard let value = calendar.date(from: components) else { + return String(format: "%02d:%02d", time.hour, time.minute) + } + + return value.formatted(date: .omitted, time: .shortened) + } + + static func weekRange(_ dates: [Date], calendar: Calendar) -> String { + guard let first = dates.first, let last = dates.last else { return "Week" } + let firstMonth = calendar.component(.month, from: first) + let lastMonth = calendar.component(.month, from: last) + + if firstMonth == lastMonth { + return + "\(first.formatted(.dateTime.month(.wide))) \(first.formatted(.dateTime.day()))-\(last.formatted(.dateTime.day()))" + } + + return + "\(first.formatted(.dateTime.month(.abbreviated).day())) - \(last.formatted(.dateTime.month(.abbreviated).day()))" + } + + static func relativeMinutes(_ minutes: Int) -> String { + if minutes < 60 { + return "in \(minutes) min" + } + + let hours = minutes / 60 + let remainingMinutes = minutes % 60 + if remainingMinutes == 0 { + return "in \(hours) hr" + } + return "in \(hours) hr \(remainingMinutes) min" + } +} diff --git a/Gapwise/DesignSystem/GapwiseTheme.swift b/Gapwise/DesignSystem/GapwiseTheme.swift new file mode 100644 index 0000000..66cd0c1 --- /dev/null +++ b/Gapwise/DesignSystem/GapwiseTheme.swift @@ -0,0 +1,32 @@ +import SwiftUI + +extension Color { + static let gapwiseAccent = Color("AccentColor") + static let gapwisePrimarySurface = Color(uiColor: .systemBackground) + static let gapwiseSecondarySurface = Color(uiColor: .secondarySystemGroupedBackground) + static let gapwiseSuccess = Color(uiColor: .systemGreen) +} + +enum GapwiseSpacing { + static let compact: CGFloat = 8 + static let standard: CGFloat = 16 + static let spacious: CGFloat = 24 +} + +enum GapwiseRadius { + static let card: CGFloat = 8 +} + +struct GapwiseCardModifier: ViewModifier { + func body(content: Content) -> some View { + content + .padding(GapwiseSpacing.standard) + .background(.gapwiseSecondarySurface, in: RoundedRectangle(cornerRadius: GapwiseRadius.card)) + } +} + +extension View { + func gapwiseCard() -> some View { + modifier(GapwiseCardModifier()) + } +} diff --git a/Gapwise/Development/PreviewData.swift b/Gapwise/Development/PreviewData.swift new file mode 100644 index 0000000..b5b374e --- /dev/null +++ b/Gapwise/Development/PreviewData.swift @@ -0,0 +1,114 @@ +import Foundation + +#if DEBUG + enum PreviewData { + static let calendar = Calendar.gapwiseToronto + + static var timetable: TimetableSnapshot { + TimetableSnapshot(meetings: meetings, lastModified: .now) + } + + @MainActor + static func appModel(selectedDate: Date = .now) -> AppModel { + AppModel( + timetableRepository: InMemoryTimetableRepository(snapshot: timetable), + preferencesRepository: InMemoryPreferencesRepository(), + timetable: timetable, + selectedTimetableDate: selectedDate + ) + } + + private static var meetings: [CourseMeeting] { + let year = calendar.component(.year, from: .now) + let term = AcademicTerm( + id: .init(rawValue: "preview-term"), + displayName: "Preview Term", + startsOn: LocalDate(year: year - 1, month: 1, day: 1), + endsOn: LocalDate(year: year + 1, month: 12, day: 31) + ) + + return [ + meeting( + campus: .utm, + code: "DEMO101", + title: "Sample Seminar", + term: term, + courseSection: "F", + type: .lecture, + section: "LEC0101", + days: [.monday, .wednesday, .friday], + start: (10, 0), + end: (11, 0), + location: "Sample Room A", + sourceID: "preview-utm-demo101-lec0101" + ), + meeting( + campus: .utsg, + code: "DEMO204", + title: "Sample Studio", + term: term, + courseSection: "F", + type: .practical, + section: "PRA0201", + days: [.tuesday, .thursday], + start: (13, 0), + end: (15, 0), + location: "Sample Lab B", + sourceID: "preview-utsg-demo204-pra0201" + ), + meeting( + campus: .utm, + code: "DEMO310", + title: "Sample Tutorial", + term: term, + courseSection: "F", + type: .tutorial, + section: "TUT0102", + days: [.monday], + start: (11, 0), + end: (12, 0), + location: "Sample Room C", + sourceID: "preview-utm-demo310-tut0102" + ), + ].compactMap { $0 } + } + + private static func meeting( + campus: Campus, + code: String, + title: String, + term: AcademicTerm, + courseSection: String, + type: MeetingType, + section: String, + days: Set, + start: (Int, Int), + end: (Int, Int), + location: String, + sourceID: String + ) -> CourseMeeting? { + guard + let courseCode = CourseCode(rawValue: code), + let startTime = LocalTime(hour: start.0, minute: start.1), + let endTime = LocalTime(hour: end.0, minute: end.1) + else { + return nil + } + + return try? CourseMeeting( + campus: campus, + courseCode: courseCode, + courseTitle: title, + term: term, + courseSection: courseSection, + meetingType: type, + meetingSection: section, + days: days, + startTime: startTime, + endTime: endTime, + location: MeetingLocation(displayName: location), + sourceIdentifier: sourceID + ) + } + } +#endif diff --git a/Gapwise/Features/Campus/CampusView.swift b/Gapwise/Features/Campus/CampusView.swift new file mode 100644 index 0000000..9cbb04c --- /dev/null +++ b/Gapwise/Features/Campus/CampusView.swift @@ -0,0 +1,69 @@ +import SwiftUI + +struct CampusView: View { + @Environment(AppModel.self) private var appModel + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: GapwiseSpacing.spacious) { + Picker( + "Campus context", + selection: Binding( + get: { appModel.preferences.campusContext }, + set: { appModel.setCampusContext($0) } + ) + ) { + ForEach(Campus.allCases) { campus in + Text(campus.shortName).tag(campus) + } + } + .pickerStyle(.segmented) + + VStack(alignment: .leading, spacing: GapwiseSpacing.compact) { + Text(appModel.preferences.campusContext.shortName) + .font(.largeTitle.weight(.bold)) + Text(appModel.preferences.campusContext.fullName) + .font(.body) + .foregroundStyle(.secondary) + } + + campusStatus(for: appModel.preferences.campusContext) + } + .padding(GapwiseSpacing.standard) + } + .background(Color(uiColor: .systemGroupedBackground)) + .navigationTitle("Campus") + } + + @ViewBuilder + private func campusStatus(for campus: Campus) -> some View { + if campus == .utm { + VStack(alignment: .leading, spacing: GapwiseSpacing.standard) { + Label("UTM is the first campus-intelligence focus", systemImage: "mappin.and.ellipse") + .font(.headline) + .foregroundStyle(.gapwiseAccent) + Text("Map and routing will arrive after verified campus data is integrated.") + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + .gapwiseCard() + } else { + ContentUnavailableView( + "Campus Intelligence Not Available", + systemImage: "map", + description: Text( + "Timetable support remains campus-aware. The campus layer is currently focused on UTM.") + ) + .frame(maxWidth: .infinity, minHeight: 260) + } + } +} + +#if DEBUG + #Preview { + NavigationStack { + CampusView() + .environment(PreviewData.appModel()) + } + } +#endif diff --git a/Gapwise/Features/Settings/SettingsView.swift b/Gapwise/Features/Settings/SettingsView.swift new file mode 100644 index 0000000..2a2a591 --- /dev/null +++ b/Gapwise/Features/Settings/SettingsView.swift @@ -0,0 +1,136 @@ +import SwiftUI + +struct SettingsView: View { + @Environment(AppModel.self) private var appModel + @State private var isConfirmingTimetableRemoval = false + + var body: some View { + Form { + Section("Appearance") { + Picker( + "Theme", + selection: Binding( + get: { appModel.preferences.appearance }, + set: { appModel.setAppearance($0) } + ) + ) { + ForEach(AppearancePreference.allCases) { appearance in + Text(appearance.displayName).tag(appearance) + } + } + } + + Section("Timetable") { + LabeledContent("Saved meetings", value: "\(appModel.timetable.meetings.count)") + + if let lastModified = appModel.timetable.lastModified { + LabeledContent( + "Last updated", + value: lastModified.formatted(date: .abbreviated, time: .shortened) + ) + } + + Button("Remove Saved Timetable", role: .destructive) { + isConfirmingTimetableRemoval = true + } + .disabled(appModel.timetable.meetings.isEmpty) + } + + Section("Campus Context") { + Picker( + "Campus", + selection: Binding( + get: { appModel.preferences.campusContext }, + set: { appModel.setCampusContext($0) } + ) + ) { + ForEach(Campus.allCases) { campus in + Text(campus.shortName).tag(campus) + } + } + } + + Section("Privacy") { + Label("Timetable data stays in this app's local container.", systemImage: "lock.shield") + Text("Gapwise does not require an account for the basic timetable experience.") + .foregroundStyle(.secondary) + } + + Section("Gapwise") { + if let website = AppLinks.website { + Link(destination: website) { + Label("Website", systemImage: "safari") + } + } + if let documentation = AppLinks.documentation { + Link(destination: documentation) { + Label("Documentation", systemImage: "book.closed") + } + } + if let status = AppLinks.status { + Link(destination: status) { + Label("Service Status", systemImage: "waveform.path.ecg") + } + } + } + + Section("About") { + Image("GapwiseBrand") + .resizable() + .scaledToFit() + .frame(maxWidth: .infinity, maxHeight: 96) + .accessibilityLabel("Gapwise for iOS") + LabeledContent("Version", value: AppInformation.versionDescription) + Text( + "Gapwise is an independent student software project and is not affiliated with or endorsed by the University of Toronto." + ) + .font(.footnote) + .foregroundStyle(.secondary) + } + } + .navigationTitle("Settings") + .confirmationDialog( + "Remove your saved timetable?", + isPresented: $isConfirmingTimetableRemoval, + titleVisibility: .visible + ) { + Button("Remove Timetable", role: .destructive) { + Task { + await appModel.clearTimetable() + } + } + Button("Cancel", role: .cancel) {} + } message: { + Text("This removes the timetable stored on this device.") + } + } +} + +private enum AppLinks { + static let website = URL(string: "https://gapwise.ca") + static let documentation = URL(string: "https://docs.gapwise.ca") + static let status = URL(string: "https://status.gapwise.ca") +} + +private enum AppInformation { + static var versionDescription: String { + let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String + let build = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String + + switch (version, build) { + case let (.some(version), .some(build)): "\(version) (\(build))" + case let (.some(version), .none): version + case let (.none, .some(build)): build + case (.none, .none): "Development" + } + } +} + +#if DEBUG + #Preview { + NavigationStack { + SettingsView() + .environment(PreviewData.appModel()) + } + } +#endif diff --git a/Gapwise/Features/Timetable/MeetingCard.swift b/Gapwise/Features/Timetable/MeetingCard.swift new file mode 100644 index 0000000..eb710d9 --- /dev/null +++ b/Gapwise/Features/Timetable/MeetingCard.swift @@ -0,0 +1,71 @@ +import SwiftUI + +struct MeetingCard: View { + let meeting: CourseMeeting + let date: Date + var isHighlighted = false + + private let calendar = Calendar.gapwiseToronto + + var body: some View { + HStack(alignment: .top, spacing: GapwiseSpacing.standard) { + VStack(alignment: .leading, spacing: 3) { + Text(GapwiseFormatters.time(meeting.startTime, on: date, calendar: calendar)) + .font(.subheadline.weight(.semibold)) + Text(GapwiseFormatters.time(meeting.endTime, on: date, calendar: calendar)) + .font(.caption) + .foregroundStyle(.secondary) + } + .frame(minWidth: 68, alignment: .leading) + + RoundedRectangle(cornerRadius: 2) + .fill(isHighlighted ? Color.gapwiseSuccess : .gapwiseAccent) + .frame(width: 4) + .accessibilityHidden(true) + + VStack(alignment: .leading, spacing: GapwiseSpacing.compact) { + HStack(alignment: .firstTextBaseline) { + Text(meeting.courseCode.rawValue) + .font(.headline) + Spacer(minLength: GapwiseSpacing.compact) + Text(meeting.campus.shortName) + .font(.caption.weight(.semibold)) + .foregroundStyle(.gapwiseAccent) + .padding(.horizontal, 7) + .padding(.vertical, 3) + .background(Color.gapwiseAccent.opacity(0.12), in: Capsule()) + } + + Text("\(meeting.meetingType.shortName) \(meeting.meetingSection)") + .font(.subheadline) + .foregroundStyle(.secondary) + + if let location = meeting.location { + Label(location.displayName, systemImage: "mappin") + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + } + .gapwiseCard() + .accessibilityElement(children: .combine) + .accessibilityLabel(accessibilitySummary) + } + + private var accessibilitySummary: String { + let start = GapwiseFormatters.time(meeting.startTime, on: date, calendar: calendar) + let end = GapwiseFormatters.time(meeting.endTime, on: date, calendar: calendar) + let location = meeting.location.map { ", \($0.displayName)" } ?? "" + return + "\(meeting.courseCode.rawValue), \(meeting.meetingType.shortName) \(meeting.meetingSection), \(start) to \(end), \(meeting.campus.shortName)\(location)" + } +} + +#if DEBUG + #Preview { + if let meeting = PreviewData.timetable.meetings.first { + MeetingCard(meeting: meeting, date: .now) + .padding() + } + } +#endif diff --git a/Gapwise/Features/Timetable/TimetableView.swift b/Gapwise/Features/Timetable/TimetableView.swift new file mode 100644 index 0000000..141d500 --- /dev/null +++ b/Gapwise/Features/Timetable/TimetableView.swift @@ -0,0 +1,117 @@ +import SwiftUI + +struct TimetableView: View { + @Environment(AppModel.self) private var appModel + + private let calculator = ScheduleCalculator() + + var body: some View { + let selectedDate = appModel.selectedTimetableDate + let week = calculator.weekDates(containing: selectedDate) + let meetings = calculator.meetings(on: selectedDate, in: appModel.timetable.meetings) + + ScrollView { + LazyVStack(alignment: .leading, spacing: GapwiseSpacing.standard) { + weekHeader(week) + + WeekdayStrip( + dates: week, + selectedDate: selectedDate, + calendar: calculator.calendar, + onSelect: { appModel.selectedTimetableDate = $0 } + ) + .padding(.horizontal, -GapwiseSpacing.standard) + + Divider() + + Text(selectedDate.formatted(.dateTime.weekday(.wide).month(.wide).day())) + .font(.title3.weight(.semibold)) + .accessibilityAddTraits(.isHeader) + + if appModel.isLoading && appModel.timetable.meetings.isEmpty { + ProgressView("Loading timetable") + .frame(maxWidth: .infinity, minHeight: 180) + } else if meetings.isEmpty { + emptyState(hasSavedMeetings: !appModel.timetable.meetings.isEmpty) + } else { + ForEach(meetings) { meeting in + MeetingCard(meeting: meeting, date: selectedDate) + } + } + } + .padding(GapwiseSpacing.standard) + } + .background(Color(uiColor: .systemGroupedBackground)) + .navigationTitle("Timetable") + .toolbar { + ToolbarItemGroup(placement: .topBarTrailing) { + Button { + appModel.selectedTimetableDate = calculator.date( + byAddingDays: -7, + to: appModel.selectedTimetableDate + ) + } label: { + Label("Previous week", systemImage: "chevron.left") + } + + Button("Today") { + appModel.selectedTimetableDate = .now + } + + Button { + appModel.selectedTimetableDate = calculator.date( + byAddingDays: 7, + to: appModel.selectedTimetableDate + ) + } label: { + Label("Next week", systemImage: "chevron.right") + } + } + } + } + + private func weekHeader(_ dates: [Date]) -> some View { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text("Week of") + .font(.caption) + .foregroundStyle(.secondary) + Text(GapwiseFormatters.weekRange(dates, calendar: calculator.calendar)) + .font(.title2.weight(.bold)) + } + Spacer() + Image(systemName: "calendar.badge.clock") + .font(.title2) + .foregroundStyle(.gapwiseAccent) + .accessibilityHidden(true) + } + } + + @ViewBuilder + private func emptyState(hasSavedMeetings: Bool) -> some View { + if hasSavedMeetings { + ContentUnavailableView( + "No Classes", + systemImage: "calendar.badge.checkmark", + description: Text("Nothing is scheduled for this day.") + ) + .frame(maxWidth: .infinity, minHeight: 220) + } else { + ContentUnavailableView( + "No Saved Timetable", + systemImage: "calendar", + description: Text("Your saved classes will appear here.") + ) + .frame(maxWidth: .infinity, minHeight: 220) + } + } +} + +#if DEBUG + #Preview { + NavigationStack { + TimetableView() + .environment(PreviewData.appModel()) + } + } +#endif diff --git a/Gapwise/Features/Timetable/WeekdayStrip.swift b/Gapwise/Features/Timetable/WeekdayStrip.swift new file mode 100644 index 0000000..04cff95 --- /dev/null +++ b/Gapwise/Features/Timetable/WeekdayStrip.swift @@ -0,0 +1,49 @@ +import SwiftUI + +struct WeekdayStrip: View { + let dates: [Date] + let selectedDate: Date + let calendar: Calendar + let onSelect: (Date) -> Void + + var body: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: GapwiseSpacing.compact) { + ForEach(dates, id: \.self) { date in + dayButton(for: date) + } + } + .padding(.horizontal, GapwiseSpacing.standard) + } + .accessibilityLabel("Days in selected week") + } + + private func dayButton(for date: Date) -> some View { + let isSelected = calendar.isDate(date, inSameDayAs: selectedDate) + let isToday = calendar.isDateInToday(date) + + return Button { + onSelect(date) + } label: { + VStack(spacing: 5) { + Text(date.formatted(.dateTime.weekday(.narrow))) + .font(.caption.weight(.medium)) + Text(date.formatted(.dateTime.day())) + .font(.body.weight(isSelected ? .bold : .regular)) + Circle() + .fill(isToday ? Color.gapwiseAccent : .clear) + .frame(width: 4, height: 4) + } + .foregroundStyle(isSelected ? Color.white : Color.primary) + .frame(minWidth: 44, minHeight: 58) + .padding(.horizontal, 3) + .background( + isSelected ? Color.gapwiseAccent : Color.clear, + in: RoundedRectangle(cornerRadius: GapwiseRadius.card) + ) + } + .buttonStyle(.plain) + .accessibilityLabel(date.formatted(.dateTime.weekday(.wide).month(.wide).day())) + .accessibilityAddTraits(isSelected ? .isSelected : []) + } +} diff --git a/Gapwise/Features/Today/TodayView.swift b/Gapwise/Features/Today/TodayView.swift new file mode 100644 index 0000000..4370462 --- /dev/null +++ b/Gapwise/Features/Today/TodayView.swift @@ -0,0 +1,138 @@ +import SwiftUI + +struct TodayView: View { + @Environment(AppModel.self) private var appModel + + private let calculator = ScheduleCalculator() + + var body: some View { + TimelineView(.periodic(from: .now, by: 60)) { context in + todayContent(now: context.date) + } + .background(Color(uiColor: .systemGroupedBackground)) + .navigationTitle("Today") + } + + private func todayContent(now: Date) -> some View { + let meetings = calculator.meetings(on: now, in: appModel.timetable.meetings) + let nextClass = calculator.currentOrNextClass(at: now, in: appModel.timetable.meetings) + + return ScrollView { + LazyVStack(alignment: .leading, spacing: GapwiseSpacing.standard) { + Text(now.formatted(.dateTime.weekday(.wide).month(.wide).day())) + .font(.title2.weight(.bold)) + .accessibilityAddTraits(.isHeader) + + if appModel.isLoading && appModel.timetable.meetings.isEmpty { + ProgressView("Loading today") + .frame(maxWidth: .infinity, minHeight: 220) + } else if meetings.isEmpty { + ContentUnavailableView( + "No Classes Today", + systemImage: "sun.max", + description: Text("Your day is clear.") + ) + .frame(maxWidth: .infinity, minHeight: 220) + } else { + if let nextClass { + nextClassCard(nextClass, now: now) + } else { + classesCompleteCard + } + + Text("Today's classes") + .font(.headline) + .padding(.top, GapwiseSpacing.compact) + .accessibilityAddTraits(.isHeader) + + ForEach(meetings) { meeting in + MeetingCard( + meeting: meeting, + date: now, + isHighlighted: nextClass?.occurrence.meeting.id == meeting.id + ) + } + } + } + .padding(GapwiseSpacing.standard) + } + } + + private func nextClassCard(_ context: NextClassContext, now: Date) -> some View { + let meeting = context.occurrence.meeting + + return VStack(alignment: .leading, spacing: GapwiseSpacing.standard) { + HStack { + Label(statusTitle(for: context.timing), systemImage: statusSymbol(for: context.timing)) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(statusColor(for: context.timing)) + Spacer() + Text(meeting.campus.shortName) + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + } + + VStack(alignment: .leading, spacing: 4) { + Text(meeting.courseCode.rawValue) + .font(.title2.weight(.bold)) + Text("\(meeting.meetingType.shortName) \(meeting.meetingSection)") + .font(.subheadline) + .foregroundStyle(.secondary) + } + + HStack(spacing: GapwiseSpacing.standard) { + Label( + GapwiseFormatters.time(meeting.startTime, on: now, calendar: calculator.calendar), + systemImage: "clock" + ) + if let location = meeting.location { + Label(location.displayName, systemImage: "mappin") + .lineLimit(2) + } + } + .font(.subheadline) + } + .gapwiseCard() + .accessibilityElement(children: .combine) + } + + private var classesCompleteCard: some View { + Label("Classes complete for today", systemImage: "checkmark.circle.fill") + .font(.headline) + .foregroundStyle(.gapwiseSuccess) + .frame(maxWidth: .infinity, alignment: .leading) + .gapwiseCard() + } + + private func statusTitle(for timing: ClassTiming) -> String { + switch timing { + case let .inProgress(minutesRemaining): + "In progress, \(minutesRemaining) min left" + case let .upcoming(minutesUntil): + "Next class, \(GapwiseFormatters.relativeMinutes(minutesUntil))" + } + } + + private func statusSymbol(for timing: ClassTiming) -> String { + switch timing { + case .inProgress: "play.circle.fill" + case .upcoming: "clock.fill" + } + } + + private func statusColor(for timing: ClassTiming) -> Color { + switch timing { + case .inProgress: .gapwiseSuccess + case .upcoming: .gapwiseAccent + } + } +} + +#if DEBUG + #Preview { + NavigationStack { + TodayView() + .environment(PreviewData.appModel()) + } + } +#endif diff --git a/Gapwise/Resources/Assets.xcassets/AccentColor.colorset/Contents.json b/Gapwise/Resources/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..f1f2ce4 --- /dev/null +++ b/Gapwise/Resources/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.969", + "green" : "0.612", + "red" : "0.243" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "1.000", + "green" : "0.725", + "red" : "0.384" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Gapwise/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon.png b/Gapwise/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon.png new file mode 100644 index 0000000000000000000000000000000000000000..f54c87be6366ab28b6936b7b8ae18cdf35d5550a GIT binary patch literal 128675 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 V-0Zm7Fyb#EElq7rP)6Pl{tpXj@Cg6_ literal 0 HcmV?d00001 diff --git a/Gapwise/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json b/Gapwise/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..cefcc87 --- /dev/null +++ b/Gapwise/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images" : [ + { + "filename" : "AppIcon.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Gapwise/Resources/Assets.xcassets/Contents.json b/Gapwise/Resources/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Gapwise/Resources/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Gapwise/Resources/Assets.xcassets/GapwiseBrand.imageset/Contents.json b/Gapwise/Resources/Assets.xcassets/GapwiseBrand.imageset/Contents.json new file mode 100644 index 0000000..c161b51 --- /dev/null +++ b/Gapwise/Resources/Assets.xcassets/GapwiseBrand.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "gapwise-ios.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Gapwise/Resources/Assets.xcassets/GapwiseBrand.imageset/gapwise-ios.png b/Gapwise/Resources/Assets.xcassets/GapwiseBrand.imageset/gapwise-ios.png new file mode 100644 index 0000000000000000000000000000000000000000..f42d0d90bcf08410bedb4b813678131626ea3959 GIT binary patch literal 58503 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�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 LG~{b!O(FjWwG(fy literal 0 HcmV?d00001 diff --git a/GapwiseTests/Core/TimetableDomainTests.swift b/GapwiseTests/Core/TimetableDomainTests.swift new file mode 100644 index 0000000..432a253 --- /dev/null +++ b/GapwiseTests/Core/TimetableDomainTests.swift @@ -0,0 +1,291 @@ +import Foundation +import XCTest + +#if canImport(Gapwise) + @testable import Gapwise +#else + @testable import GapwiseCore +#endif + +final class TimetableDomainTests: XCTestCase, @unchecked Sendable { + private let calendar = Calendar.gapwiseToronto + + func testIdentitySeparatesMatchingCourseLabelsAcrossCampuses() throws { + let utm = try meeting( + campus: .utm, + code: "MAT157", + sourceID: "shared-source", + days: [.monday], + start: (9, 0), + end: (10, 0) + ) + let utsg = try meeting( + campus: .utsg, + code: "MAT157", + sourceID: "shared-source", + days: [.monday], + start: (9, 0), + end: (10, 0) + ) + + XCTAssertNotEqual(utm.id, utsg.id) + XCTAssertEqual(Set([utm, utsg]).count, 2) + } + + func testIdentityIsStableThroughPersistenceEncoding() throws { + let original = try meeting( + campus: .utm, + code: "STABLE100", + sourceID: "stable-source", + days: [.wednesday], + start: (15, 0), + end: (16, 0) + ) + + let data = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode(CourseMeeting.self, from: data) + + XCTAssertEqual(decoded.id, original.id) + XCTAssertEqual(decoded, original) + } + + func testMeetingsAreFilteredByDayAndSortedByTime() throws { + let monday = date(year: 2026, month: 9, day: 14, hour: 8) + let late = try meeting( + campus: .utm, + code: "LATE200", + sourceID: "late", + days: [.monday], + start: (13, 0), + end: (14, 0) + ) + let early = try meeting( + campus: .utsc, + code: "EARLY100", + sourceID: "early", + days: [.monday], + start: (9, 0), + end: (10, 0) + ) + let tuesday = try meeting( + campus: .utsg, + code: "OTHER300", + sourceID: "tuesday", + days: [.tuesday], + start: (8, 0), + end: (9, 0) + ) + + let result = ScheduleCalculator(calendar: calendar).meetings( + on: monday, + in: [late, tuesday, early] + ) + + XCTAssertEqual(result.map(\.id), [early.id, late.id]) + } + + func testNextClassRoundsPartialMinutesUp() throws { + let meeting = try meeting( + campus: .utm, + code: "NEXT100", + sourceID: "next", + days: [.monday], + start: (10, 0), + end: (11, 0) + ) + let now = date(year: 2026, month: 9, day: 14, hour: 9, minute: 18, second: 30) + + let context = ScheduleCalculator(calendar: calendar).currentOrNextClass(at: now, in: [meeting]) + + XCTAssertEqual(context?.occurrence.meeting.id, meeting.id) + XCTAssertEqual(context?.timing, .upcoming(minutesUntil: 42)) + } + + func testCurrentClassReportsRemainingTime() throws { + let meeting = try meeting( + campus: .utm, + code: "LIVE100", + sourceID: "live", + days: [.monday], + start: (10, 0), + end: (11, 0) + ) + let now = date(year: 2026, month: 9, day: 14, hour: 10, minute: 25) + + let context = ScheduleCalculator(calendar: calendar).currentOrNextClass(at: now, in: [meeting]) + + XCTAssertEqual(context?.timing, .inProgress(minutesRemaining: 35)) + } + + func testOverlappingMeetingsRemainDeterministicallyOrdered() throws { + let first = try meeting( + campus: .utm, + code: "FIRST100", + sourceID: "first", + days: [.monday], + start: (10, 0), + end: (12, 0) + ) + let second = try meeting( + campus: .utsg, + code: "SECOND200", + sourceID: "second", + days: [.monday], + start: (10, 30), + end: (11, 30) + ) + let now = date(year: 2026, month: 9, day: 14, hour: 10, minute: 45) + + let context = ScheduleCalculator(calendar: calendar).currentOrNextClass( + at: now, + in: [second, first] + ) + + XCTAssertEqual(context?.occurrence.meeting.id, first.id) + } + + func testAdjacentMeetingStartsWhenPreviousMeetingEnds() throws { + let first = try meeting( + campus: .utm, + code: "FIRST100", + sourceID: "first", + days: [.monday], + start: (10, 0), + end: (11, 0) + ) + let second = try meeting( + campus: .utm, + code: "SECOND200", + sourceID: "second", + days: [.monday], + start: (11, 0), + end: (12, 0) + ) + let boundary = date(year: 2026, month: 9, day: 14, hour: 11) + + let context = ScheduleCalculator(calendar: calendar).currentOrNextClass( + at: boundary, + in: [first, second] + ) + + XCTAssertEqual(context?.occurrence.meeting.id, second.id) + XCTAssertEqual(context?.timing, .inProgress(minutesRemaining: 60)) + } + + func testEmptyScheduleHasNoMeetingsOrNextClass() { + let monday = date(year: 2026, month: 9, day: 14, hour: 9) + let calculator = ScheduleCalculator(calendar: calendar) + + XCTAssertTrue(calculator.meetings(on: monday, in: []).isEmpty) + XCTAssertNil(calculator.currentOrNextClass(at: monday, in: [])) + } + + func testWeekDatesAlwaysBeginOnMonday() { + let sunday = date(year: 2026, month: 9, day: 13, hour: 12) + let week = ScheduleCalculator(calendar: calendar).weekDates(containing: sunday) + + XCTAssertEqual(week.count, 7) + XCTAssertEqual(week.first.flatMap { Weekday(date: $0, calendar: calendar) }, .monday) + XCTAssertEqual(week.last.flatMap { Weekday(date: $0, calendar: calendar) }, .sunday) + } + + func testMeetingOutsideTermIsExcluded() throws { + let meeting = try meeting( + campus: .utm, + code: "TERM100", + sourceID: "term", + days: [.monday], + start: (9, 0), + end: (10, 0) + ) + let afterTerm = date(year: 2027, month: 9, day: 13, hour: 9) + + XCTAssertTrue( + ScheduleCalculator(calendar: calendar) + .meetings(on: afterTerm, in: [meeting]) + .isEmpty + ) + } + + func testJSONRepositoryRoundTripAndClear() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("GapwiseTests-\(UUID().uuidString)", isDirectory: true) + let fileURL = directory.appendingPathComponent("timetable.json") + let repository = JSONTimetableRepository(fileURL: fileURL) + let saved = TimetableSnapshot( + meetings: [ + try meeting( + campus: .utsc, + code: "SAVE100", + sourceID: "persistence", + days: [.friday], + start: (14, 0), + end: (15, 0) + ) + ], + lastModified: date(year: 2026, month: 9, day: 14, hour: 9) + ) + defer { try? FileManager.default.removeItem(at: directory) } + + try await repository.save(saved) + let loaded = try await repository.load() + XCTAssertEqual(loaded, saved) + + try await repository.clear() + let cleared = try await repository.load() + XCTAssertEqual(cleared, .empty) + } + + private func meeting( + campus: Campus, + code: String, + sourceID: String, + days: Set, + start: (Int, Int), + end: (Int, Int) + ) throws -> CourseMeeting { + let courseCode = try XCTUnwrap(CourseCode(rawValue: code)) + let startTime = try XCTUnwrap(LocalTime(hour: start.0, minute: start.1)) + let endTime = try XCTUnwrap(LocalTime(hour: end.0, minute: end.1)) + let term = AcademicTerm( + id: .init(rawValue: "2026-fall"), + displayName: "Fall 2026", + startsOn: LocalDate(year: 2026, month: 9, day: 1), + endsOn: LocalDate(year: 2026, month: 12, day: 31) + ) + + return try CourseMeeting( + campus: campus, + courseCode: courseCode, + term: term, + courseSection: "F", + meetingType: .lecture, + meetingSection: "LEC0101", + days: days, + startTime: startTime, + endTime: endTime, + location: MeetingLocation(displayName: "Test Room"), + sourceIdentifier: sourceID + ) + } + + private func date( + year: Int, + month: Int, + day: Int, + hour: Int, + minute: Int = 0, + second: Int = 0 + ) -> Date { + calendar.date( + from: DateComponents( + year: year, + month: month, + day: day, + hour: hour, + minute: minute, + second: second + ) + ) ?? .distantPast + } +} diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..1dda82a --- /dev/null +++ b/Package.swift @@ -0,0 +1,25 @@ +// swift-tools-version: 6.0 + +import PackageDescription + +let package = Package( + name: "GapwiseCore", + platforms: [ + .iOS(.v17), + .macOS(.v14), + ], + products: [ + .library(name: "GapwiseCore", targets: ["GapwiseCore"]) + ], + targets: [ + .target( + name: "GapwiseCore", + path: "Gapwise/Core" + ), + .testTarget( + name: "GapwiseCoreTests", + dependencies: ["GapwiseCore"], + path: "GapwiseTests/Core" + ), + ] +) diff --git a/README.md b/README.md index 94ba5d1..a8205ba 100644 --- a/README.md +++ b/README.md @@ -35,9 +35,9 @@ The goal is a real native iOS application rather than a WebView wrapper: navigat ## Current status -The iOS client is at an **early repository/bootstrap stage**. The brand, product boundary, technology direction, and ecosystem integration are established here; the application features described below are the implementation target, not a claim that they are already shipped. +The repository now contains the first native application foundation: an iPhone SwiftUI target, local timetable persistence, campus-qualified schedule models, date-aware Today and Timetable views, an honest UTM-first Campus boundary, Settings, portable domain tests, and macOS CI validation. -That distinction is intentional. Gapwise documentation should describe what exists and what is planned separately rather than presenting roadmap work as completed functionality. +This remains an **early implementation**, not a shipped client. ACORN import, timetable editing, gaps, maps, routing, account continuity, and remote integrations are still planned work. The preview schedule is development-only and the production app starts with an empty local timetable. --- @@ -55,35 +55,41 @@ The native iOS experience is being designed around the same principles as the An --- -## Planned app surface +## App surface -The native iOS client is intended to grow toward the core Gapwise experience: +The current foundation includes: -- **Today** for the current day's schedule and immediate context; -- **Timetable** for local ACORN `.ics` import across U of T campuses; +- **Today**, derived from local timetable data, with current/next-class context and empty states; +- **Timetable**, with week/day navigation and campus-aware meeting cards; +- **Campus**, with a selectable campus context and an explicit UTM-first availability state; +- **Settings**, with appearance, local timetable removal, campus context, privacy, version, and ecosystem links; +- atomic local JSON persistence behind an async repository boundary; +- light/dark appearance, Dynamic Type-friendly layouts, VoiceOver labels, and native navigation. + +The native iOS client is intended to grow toward the remaining Gapwise experience: + +- local ACORN `.ics` import across U of T campuses; - **Gaps** for deterministic time-between-class planning; - **Map** for UTM-focused campus navigation and route context; -- **Settings** for appearance, privacy, timetable, account/sync, routing, exports, and integrations as those systems are implemented; -- light and dark appearance; -- privacy-first local persistence; +- timetable editing, exports, and integrations; - optional, explicitly scoped account continuity. These are roadmap targets. This README will be tightened as implementation lands so the “current” surface never outruns the code. --- -## Technology direction +## Technology -Gapwise for iOS is intended to use modern native Apple tooling: +Gapwise for iOS uses modern native Apple tooling: - **Swift** - **SwiftUI** -- Apple document-picker and lifecycle APIs +- Foundation persistence and Apple lifecycle APIs - **Keychain / platform-secure storage** where secret material is eventually required - a native map stack compatible with canonical Gapwise UTM data and routing semantics - optional Gapwise account integration only after its security boundary is implemented and reviewed -The architecture should keep domain models, timetable parsing, persistence, account/sync, navigation, map integration, and feature UI separated as the application grows. +The architecture keeps portable domain models and schedule arithmetic separate from persistence and SwiftUI features. Parsing, account/sync, and map integration will remain separate boundaries as they are introduced. --- @@ -99,20 +105,28 @@ The iOS client follows the same security posture as the wider Gapwise ecosystem: - preserve campus/source identity rather than inventing location certainty; - make permissions narrow, understandable, and revocable. -No persistence, encryption, account-sync, or map capability should be described as implemented before the corresponding code and verification exist. +The current timetable store writes an atomically replaced JSON snapshot inside Application Support and applies iOS file protection. No account sync, end-to-end encryption, analytics, or map capability is implemented. --- ## Development -Native iOS development lives in this repository as the client is built out in Swift and SwiftUI. +The app targets iPhone on iOS 17 or later and uses a filesystem-synchronized Xcode 16 project. There are no third-party runtime dependencies. ```bash git clone https://github.com/Gapwise-for-UTM/ios.git cd ios ``` -As the Xcode project and build pipeline land, this section will carry the exact supported Xcode/iOS requirements and verification commands. +Open `Gapwise.xcodeproj` in Xcode 16 or later, select the `Gapwise` scheme, and run it on an iPhone simulator or device. The shared scheme builds the app and runs `GapwiseTests`. + +The Foundation-only core and its tests also build on Linux: + +```bash +swift test +``` + +GitHub Actions runs those portable tests on Ubuntu and runs the complete unsigned iOS build and unit-test suite on a dynamically selected iPhone simulator on `macos-15`. --- From f095a8a24d9dc0e582285b34b7e8e3b7ce5b450c Mon Sep 17 00:00:00 2001 From: andrewmuratov Date: Sun, 13 Sep 2026 15:00:29 -0400 Subject: [PATCH 02/12] feat: add local timetable import --- Gapwise/App/AppModel.swift | 48 +++ Gapwise/Core/Import/CalendarDateParser.swift | 111 ++++++ Gapwise/Core/Import/CampusDetector.swift | 72 ++++ .../Core/Import/CourseDescriptorParser.swift | 110 ++++++ Gapwise/Core/Import/ICalendarModels.swift | 38 ++ Gapwise/Core/Import/ICalendarParser.swift | 352 ++++++++++++++++++ .../Import/RecurrenceRuleInterpreter.swift | 158 ++++++++ .../Core/Import/TimetableImportModels.swift | 154 ++++++++ .../Core/Import/TimetableImportService.swift | 16 + Gapwise/Core/Import/TimetableImporter.swift | 343 +++++++++++++++++ Gapwise/Core/Import/TimetableReconciler.swift | 80 ++++ .../Import/UniversityLocationParser.swift | 90 +++++ Gapwise/Core/Models/Campus.swift | 5 + Gapwise/Core/Models/CourseMeeting.swift | 131 ++++++- Gapwise/Core/Models/Weekday.swift | 14 + .../Core/Utilities/ScheduleCalculator.swift | 2 +- Gapwise/DesignSystem/GapwiseFormatters.swift | 43 ++- .../DesignSystem/MeetingLocationStyle.swift | 10 + Gapwise/Features/Campus/CampusView.swift | 2 +- Gapwise/Features/Settings/SettingsView.swift | 48 ++- Gapwise/Features/Timetable/MeetingCard.swift | 13 +- .../Features/Timetable/TimetableView.swift | 2 +- Gapwise/Features/Timetable/WeekdayStrip.swift | 6 +- .../TimetableImport/CalendarContentType.swift | 5 + .../ImportedDocumentReader.swift | 51 +++ .../TimetableImportPreviewView.swift | 197 ++++++++++ Gapwise/Features/Today/TodayView.swift | 21 +- .../Core/CampusAndLocationTests.swift | 97 +++++ .../Core/CourseDescriptorParserTests.swift | 53 +++ GapwiseTests/Core/FixtureLoader.swift | 20 + GapwiseTests/Core/Fixtures/empty.ics | 5 + GapwiseTests/Core/Fixtures/folded-escaped.ics | 16 + GapwiseTests/Core/Fixtures/malformed.ics | 29 ++ GapwiseTests/Core/Fixtures/mixed-campus.ics | 43 +++ GapwiseTests/Core/Fixtures/normal-utm.ics | 30 ++ GapwiseTests/Core/Fixtures/update-v1.ics | 21 ++ GapwiseTests/Core/Fixtures/update-v2.ics | 13 + GapwiseTests/Core/Fixtures/utsc.ics | 13 + GapwiseTests/Core/Fixtures/utsg.ics | 13 + GapwiseTests/Core/ICalendarParserTests.swift | 159 ++++++++ .../Core/RecurrenceRuleInterpreterTests.swift | 49 +++ .../Core/TimetableImporterTests.swift | 237 ++++++++++++ .../Core/TimetableReconcilerTests.swift | 157 ++++++++ Package.swift | 3 +- README.md | 34 +- 45 files changed, 3076 insertions(+), 38 deletions(-) create mode 100644 Gapwise/Core/Import/CalendarDateParser.swift create mode 100644 Gapwise/Core/Import/CampusDetector.swift create mode 100644 Gapwise/Core/Import/CourseDescriptorParser.swift create mode 100644 Gapwise/Core/Import/ICalendarModels.swift create mode 100644 Gapwise/Core/Import/ICalendarParser.swift create mode 100644 Gapwise/Core/Import/RecurrenceRuleInterpreter.swift create mode 100644 Gapwise/Core/Import/TimetableImportModels.swift create mode 100644 Gapwise/Core/Import/TimetableImportService.swift create mode 100644 Gapwise/Core/Import/TimetableImporter.swift create mode 100644 Gapwise/Core/Import/TimetableReconciler.swift create mode 100644 Gapwise/Core/Import/UniversityLocationParser.swift create mode 100644 Gapwise/DesignSystem/MeetingLocationStyle.swift create mode 100644 Gapwise/Features/TimetableImport/CalendarContentType.swift create mode 100644 Gapwise/Features/TimetableImport/ImportedDocumentReader.swift create mode 100644 Gapwise/Features/TimetableImport/TimetableImportPreviewView.swift create mode 100644 GapwiseTests/Core/CampusAndLocationTests.swift create mode 100644 GapwiseTests/Core/CourseDescriptorParserTests.swift create mode 100644 GapwiseTests/Core/FixtureLoader.swift create mode 100644 GapwiseTests/Core/Fixtures/empty.ics create mode 100644 GapwiseTests/Core/Fixtures/folded-escaped.ics create mode 100644 GapwiseTests/Core/Fixtures/malformed.ics create mode 100644 GapwiseTests/Core/Fixtures/mixed-campus.ics create mode 100644 GapwiseTests/Core/Fixtures/normal-utm.ics create mode 100644 GapwiseTests/Core/Fixtures/update-v1.ics create mode 100644 GapwiseTests/Core/Fixtures/update-v2.ics create mode 100644 GapwiseTests/Core/Fixtures/utsc.ics create mode 100644 GapwiseTests/Core/Fixtures/utsg.ics create mode 100644 GapwiseTests/Core/ICalendarParserTests.swift create mode 100644 GapwiseTests/Core/RecurrenceRuleInterpreterTests.swift create mode 100644 GapwiseTests/Core/TimetableImporterTests.swift create mode 100644 GapwiseTests/Core/TimetableReconcilerTests.swift diff --git a/Gapwise/App/AppModel.swift b/Gapwise/App/AppModel.swift index d7ef809..43263db 100644 --- a/Gapwise/App/AppModel.swift +++ b/Gapwise/App/AppModel.swift @@ -8,6 +8,9 @@ final class AppModel { private(set) var timetable: TimetableSnapshot private(set) var preferences: UserPreferences private(set) var isLoading = false + private(set) var isPreparingImport = false + private(set) var isSavingImport = false + private(set) var pendingImportPlan: TimetableImportPlan? var selectedTimetableDate: Date var alertMessage: String? @@ -74,6 +77,51 @@ final class AppModel { } } + func prepareTimetableImport(from url: URL) async { + guard !isLoading, !isPreparingImport, !isSavingImport, pendingImportPlan == nil else { return } + isPreparingImport = true + defer { isPreparingImport = false } + + do { + let data = try await ImportedDocumentReader.read( + url: url, + maximumByteCount: TimetableImportService.maximumDocumentSize + ) + let existingSnapshot = timetable + let suggestedFileName = url.lastPathComponent + pendingImportPlan = try await Task.detached(priority: .userInitiated) { + try TimetableImportService().prepareImport( + data: data, + suggestedFileName: suggestedFileName, + existingSnapshot: existingSnapshot + ) + }.value + } catch { + alertMessage = + (error as? LocalizedError)?.errorDescription + ?? "The selected calendar could not be imported." + } + } + + func confirmPendingImport() async { + guard let plan = pendingImportPlan, !isSavingImport else { return } + isSavingImport = true + defer { isSavingImport = false } + + do { + try await timetableRepository.save(plan.resultingSnapshot) + timetable = plan.resultingSnapshot + pendingImportPlan = nil + } catch { + alertMessage = "The imported timetable could not be saved. Your existing timetable has not been changed." + } + } + + func cancelPendingImport() { + guard !isSavingImport else { return } + pendingImportPlan = nil + } + func dismissAlert() { alertMessage = nil } diff --git a/Gapwise/Core/Import/CalendarDateParser.swift b/Gapwise/Core/Import/CalendarDateParser.swift new file mode 100644 index 0000000..6f2577a --- /dev/null +++ b/Gapwise/Core/Import/CalendarDateParser.swift @@ -0,0 +1,111 @@ +import Foundation + +enum CalendarDateParser { + static func parse( + value: String, + parameters: [String: String] + ) -> Result { + let valueType = parameters["VALUE"]?.uppercased() + guard valueType == nil || valueType == "DATE" || valueType == "DATE-TIME" else { + return .failure(.invalidDate(value)) + } + if valueType == "DATE" || (valueType == nil && !value.contains("T") && value.count == 8) { + guard parameters["TZID"] == nil else { return .failure(.invalidDate(value)) } + guard let date = localDate(value) else { return .failure(.invalidDate(value)) } + return .success(.date(date)) + } + + let timeZoneIdentifier = parameters["TZID"] + guard timeZoneIdentifier == nil || (!value.hasSuffix("Z") && !hasNumericOffset(value)) else { + return .failure(.invalidDate(value)) + } + let timeZone: TimeZone + let isFloating: Bool + + if value.hasSuffix("Z") { + timeZone = TimeZone(secondsFromGMT: 0) ?? .gmt + isFloating = false + } else if let timeZoneIdentifier { + guard let resolved = resolveTimeZone(timeZoneIdentifier) else { + return .failure(.unknownTimeZone(timeZoneIdentifier)) + } + timeZone = resolved + isFloating = false + } else if hasNumericOffset(value) { + timeZone = TimeZone(secondsFromGMT: 0) ?? .gmt + isFloating = false + } else { + timeZone = Calendar.gapwiseToronto.timeZone + isFloating = true + } + + guard let date = dateTime(value, timeZone: timeZone) else { + return .failure(.invalidDate(value)) + } + return .success( + .dateTime( + ICalendarDateTime( + instant: date, + timeZoneIdentifier: timeZoneIdentifier, + isFloating: isFloating + ) + ) + ) + } + + private static func localDate(_ value: String) -> LocalDate? { + guard value.count == 8 else { return nil } + let formatter = formatter(format: "yyyyMMdd", timeZone: .gmt) + guard let date = formatter.date(from: value) else { return nil } + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = .gmt + let components = calendar.dateComponents([.year, .month, .day], from: date) + guard let year = components.year, let month = components.month, let day = components.day else { return nil } + return LocalDate(year: year, month: month, day: day) + } + + private static func dateTime(_ value: String, timeZone: TimeZone) -> Date? { + let formats: [String] + if hasNumericOffset(value) { + formats = ["yyyyMMdd'T'HHmmssZ", "yyyyMMdd'T'HHmmZ"] + } else if value.hasSuffix("Z") { + formats = ["yyyyMMdd'T'HHmmss'Z'", "yyyyMMdd'T'HHmm'Z'"] + } else { + formats = ["yyyyMMdd'T'HHmmss", "yyyyMMdd'T'HHmm"] + } + + for format in formats { + if let date = formatter(format: format, timeZone: timeZone).date(from: value) { + return date + } + } + return nil + } + + private static func formatter(format: String, timeZone: TimeZone) -> DateFormatter { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.calendar = Calendar(identifier: .gregorian) + formatter.timeZone = timeZone + formatter.dateFormat = format + formatter.isLenient = false + return formatter + } + + private static func resolveTimeZone(_ identifier: String) -> TimeZone? { + if let timeZone = TimeZone(identifier: identifier) { + return timeZone + } + if identifier.contains("America/Toronto") { + return TimeZone(identifier: "America/Toronto") + } + return nil + } + + private static func hasNumericOffset(_ value: String) -> Bool { + guard value.count >= 5 else { return false } + let suffix = value.suffix(5) + guard suffix.first == "+" || suffix.first == "-" else { return false } + return suffix.dropFirst().allSatisfy(\.isNumber) + } +} diff --git a/Gapwise/Core/Import/CampusDetector.swift b/Gapwise/Core/Import/CampusDetector.swift new file mode 100644 index 0000000..96504a8 --- /dev/null +++ b/Gapwise/Core/Import/CampusDetector.swift @@ -0,0 +1,72 @@ +import Foundation + +enum CampusDetectionEvidence: Equatable, Sendable { + case buildingCode(String) + case calendarMetadata + case conflictingSignals + case importContext + case locationWording + case unresolved +} + +struct CampusDetection: Equatable, Sendable { + let campus: Campus + let evidence: CampusDetectionEvidence +} + +struct CampusDetector: Sendable { + func detect( + calendarName: String?, + productIdentifier: String?, + location: MeetingLocation?, + description: String?, + importContext: Campus? = nil + ) -> CampusDetection { + if let importContext, importContext != .unknown { + return CampusDetection(campus: importContext, evidence: .importContext) + } + + let metadataCandidates = candidates( + in: [calendarName, productIdentifier].compactMap { $0 }.joined(separator: " ")) + let eventCandidates = candidates( + in: [location?.rawLocation, description].compactMap { $0 }.joined(separator: " ") + ) + let explicitCandidates = metadataCandidates.union(eventCandidates) + + if explicitCandidates.count > 1 { + return CampusDetection(campus: .unknown, evidence: .conflictingSignals) + } + if let campus = explicitCandidates.first { + let evidence: CampusDetectionEvidence = + eventCandidates.contains(campus) ? .locationWording : .calendarMetadata + return CampusDetection(campus: campus, evidence: evidence) + } + + if let code = location?.buildingCode, UniversityBuildingCatalog.isKnownUTMCode(code) { + return CampusDetection(campus: .utm, evidence: .buildingCode(code.uppercased())) + } + + return CampusDetection(campus: .unknown, evidence: .unresolved) + } + + private func candidates(in value: String) -> Set { + let normalized = value.uppercased() + let tokens = Set(normalized.split(whereSeparator: { !$0.isLetter && !$0.isNumber }).map(String.init)) + var matches: Set = [] + + if tokens.contains("UTM") || normalized.contains("UNIVERSITY OF TORONTO MISSISSAUGA") + || normalized.contains("MISSISSAUGA CAMPUS") + { + matches.insert(.utm) + } + if tokens.contains("UTSG") || normalized.contains("ST. GEORGE") || normalized.contains("ST GEORGE") { + matches.insert(.utsg) + } + if tokens.contains("UTSC") || normalized.contains("UNIVERSITY OF TORONTO SCARBOROUGH") + || normalized.contains("SCARBOROUGH CAMPUS") + { + matches.insert(.utsc) + } + return matches + } +} diff --git a/Gapwise/Core/Import/CourseDescriptorParser.swift b/Gapwise/Core/Import/CourseDescriptorParser.swift new file mode 100644 index 0000000..0c60738 --- /dev/null +++ b/Gapwise/Core/Import/CourseDescriptorParser.swift @@ -0,0 +1,110 @@ +import Foundation + +struct ParsedCourseDescriptor: Equatable, Sendable { + let courseCode: CourseCode + let courseTitle: String? + let meetingType: MeetingType + let meetingSection: String + let rawMeetingType: String + let instructor: String? +} + +struct CourseDescriptorParser: Sendable { + private static let courseCodePattern = + #"(?i)(? ParsedCourseDescriptor? { + let fields = [summary, description].compactMap { $0 } + for field in fields { + guard + let courseMatch = firstMatch(pattern: Self.courseCodePattern, in: [field]), + let rawCourseCode = courseMatch.groups.first, + let courseCode = CourseCode(rawValue: rawCourseCode), + let meetingMatch = firstMatch(pattern: Self.meetingPattern, in: [field]), + let capturedMeetingType = meetingMatch.groups.first, + let capturedSection = meetingMatch.groups.dropFirst().first + else { + continue + } + + let rawMeetingType = capturedMeetingType.uppercased() + let sectionDigits = capturedSection.uppercased() + let meetingType: MeetingType + switch rawMeetingType { + case "LEC": meetingType = .lecture + case "TUT": meetingType = .tutorial + case "PRA", "LAB": meetingType = .practical + default: meetingType = .other + } + + return ParsedCourseDescriptor( + courseCode: courseCode, + courseTitle: courseTitle(from: summary, courseCode: courseCode, meetingSection: meetingMatch.fullMatch), + meetingType: meetingType, + meetingSection: rawMeetingType + sectionDigits, + rawMeetingType: rawMeetingType, + instructor: instructor(from: description) + ) + } + return nil + } + + func containsCourseCode(summary: String?, description: String?) -> Bool { + firstMatch( + pattern: Self.courseCodePattern, + in: [summary, description].compactMap { $0 } + ) != nil + } + + private func courseTitle(from summary: String?, courseCode: CourseCode, meetingSection: String) -> String? { + guard let summary else { return nil } + let separators = [" - ", " – ", " — "] + guard let separator = separators.first(where: summary.contains) else { return nil } + guard let range = summary.range(of: separator) else { return nil } + + var candidate = String(summary[range.upperBound...]) + candidate = candidate.replacingOccurrences(of: courseCode.rawValue, with: "", options: .caseInsensitive) + candidate = candidate.replacingOccurrences(of: meetingSection, with: "", options: .caseInsensitive) + candidate = candidate.trimmingCharacters(in: .whitespacesAndNewlines.union(.punctuationCharacters)) + return candidate.isEmpty ? nil : candidate + } + + private func instructor(from description: String?) -> String? { + guard let description else { return nil } + guard + let match = firstMatch(pattern: #"(?im)^\s*INSTRUCTORS?\s*:\s*(.+)$"#, in: [description]), + let value = match.groups.first?.trimmingCharacters(in: .whitespacesAndNewlines), + !value.isEmpty + else { + return nil + } + return value + } + + private func firstMatch(pattern: String, in values: [String]) -> RegexMatch? { + guard let expression = try? NSRegularExpression(pattern: pattern) else { return nil } + + for value in values { + let range = NSRange(value.startIndex.. + let status: String? + let issues: [ICalendarEventIssue] +} + +struct ICalendarDocument: Equatable, Sendable { + let calendarName: String? + let productIdentifier: String? + let events: [ICalendarEvent] +} diff --git a/Gapwise/Core/Import/ICalendarParser.swift b/Gapwise/Core/Import/ICalendarParser.swift new file mode 100644 index 0000000..7a144f8 --- /dev/null +++ b/Gapwise/Core/Import/ICalendarParser.swift @@ -0,0 +1,352 @@ +import Foundation + +struct ICalendarParser: Sendable { + static let maximumByteCount = 5 * 1_048_576 + static let maximumEventCount = 10_000 + + private static let maximumComponentDepth = 32 + private static let maximumUnfoldedLineByteCount = 65_536 + + func parse(_ data: Data) throws -> ICalendarDocument { + guard data.count <= Self.maximumByteCount else { + throw TimetableImportError.documentTooLarge(maximumBytes: Self.maximumByteCount) + } + guard var text = String(data: data, encoding: .utf8) else { + throw TimetableImportError.invalidTextEncoding + } + guard !text.contains("\0") else { + throw TimetableImportError.invalidTextEncoding + } + + if text.hasPrefix("\u{FEFF}") { + text.removeFirst() + } + + let lines = try unfold(text) + var stack: [String] = [] + var sawCalendar = false + var calendarName: String? + var productIdentifier: String? + var events: [ICalendarEvent] = [] + var eventBuilder: EventBuilder? + + for rawLine in lines where !rawLine.isEmpty { + guard let line = ContentLine(rawLine) else { + if eventBuilder != nil { + eventBuilder?.issues.append(.malformedProperty(Self.possiblePropertyName(in: rawLine))) + } + continue + } + + if line.name == "BEGIN" { + guard line.parameters.isEmpty, !line.hasMalformedParameters, stack.count < Self.maximumComponentDepth + else { + throw TimetableImportError.malformedCalendar + } + let component = line.value.uppercased() + if component == "VCALENDAR" { + guard stack.isEmpty, !sawCalendar else { + throw TimetableImportError.malformedCalendar + } + sawCalendar = true + } else if component == "VEVENT" { + guard stack == ["VCALENDAR"], eventBuilder == nil else { + throw TimetableImportError.malformedCalendar + } + eventBuilder = EventBuilder() + } + stack.append(component) + continue + } + + if line.name == "END" { + guard line.parameters.isEmpty, !line.hasMalformedParameters else { + throw TimetableImportError.malformedCalendar + } + let component = line.value.uppercased() + guard stack.last == component else { + throw TimetableImportError.malformedCalendar + } + + if component == "VEVENT", let builder = eventBuilder { + guard events.count < Self.maximumEventCount else { + throw TimetableImportError.tooManyEvents(maximum: Self.maximumEventCount) + } + events.append(builder.build()) + eventBuilder = nil + } + stack.removeLast() + continue + } + + if stack == ["VCALENDAR"] { + switch line.name { + case "X-WR-CALNAME": calendarName = Self.unescapeText(line.value) + case "PRODID": productIdentifier = line.value.trimmingCharacters(in: .whitespacesAndNewlines) + default: break + } + } else if stack == ["VCALENDAR", "VEVENT"] { + eventBuilder?.consume(line) + } + } + + guard sawCalendar else { throw TimetableImportError.missingCalendar } + guard stack.isEmpty, eventBuilder == nil else { throw TimetableImportError.malformedCalendar } + + return ICalendarDocument( + calendarName: calendarName?.nilIfEmpty, + productIdentifier: productIdentifier?.nilIfEmpty, + events: events + ) + } + + private func unfold(_ text: String) throws -> [String] { + let normalized = text.replacingOccurrences(of: "\r\n", with: "\n").replacingOccurrences(of: "\r", with: "\n") + let physicalLines = normalized.split(separator: "\n", omittingEmptySubsequences: false) + var lines: [String] = [] + + for physicalLine in physicalLines { + let line = String(physicalLine) + guard line.utf8.count <= Self.maximumUnfoldedLineByteCount else { + throw TimetableImportError.malformedCalendar + } + + if line.first == " " || line.first == "\t" { + guard let previous = lines.popLast() else { + throw TimetableImportError.malformedCalendar + } + let unfolded = previous + line.dropFirst() + guard unfolded.utf8.count <= Self.maximumUnfoldedLineByteCount else { + throw TimetableImportError.malformedCalendar + } + lines.append(unfolded) + } else { + lines.append(line) + } + } + + return lines + } + + fileprivate static func unescapeText(_ value: String) -> String { + var result = "" + var isEscaped = false + + for character in value { + if isEscaped { + switch character { + case "n", "N": result.append("\n") + case "\\": result.append("\\") + case ",": result.append(",") + case ";": result.append(";") + default: + result.append("\\") + result.append(character) + } + isEscaped = false + } else if character == "\\" { + isEscaped = true + } else { + result.append(character) + } + } + + if isEscaped { + result.append("\\") + } + return result + } + + private static func possiblePropertyName(in line: String) -> String? { + let candidate = line.prefix { character in + character.isLetter || character.isNumber || character == "-" + }.uppercased() + return candidate.isEmpty ? nil : candidate + } +} + +private struct ContentLine { + let name: String + let parameters: [String: String] + let value: String + let hasMalformedParameters: Bool + + init?(_ rawLine: String) { + guard let colon = Self.firstUnquotedColon(in: rawLine) else { return nil } + let header = String(rawLine[..= 2 { + parameterValue.removeFirst() + parameterValue.removeLast() + } + parameters[key] = parameterValue + } + self.parameters = parameters + self.hasMalformedParameters = hasMalformedParameters + } + + private static func firstUnquotedColon(in value: String) -> String.Index? { + var isQuoted = false + for index in value.indices { + let character = value[index] + if character == "\"" { + isQuoted.toggle() + } else if character == ":", !isQuoted { + return index + } + } + return nil + } + + private static func splitHeader(_ value: String) -> [String] { + var components: [String] = [] + var current = "" + var isQuoted = false + + for character in value { + if character == "\"" { + isQuoted.toggle() + current.append(character) + } else if character == ";", !isQuoted { + components.append(current) + current = "" + } else { + current.append(character) + } + } + components.append(current) + return components + } +} + +private struct EventBuilder { + var uid: String? + var summary: String? + var location: String? + var description: String? + var start: ICalendarTemporalValue? + var end: ICalendarTemporalValue? + var recurrenceRule: String? + var unsupportedRecurrenceProperties: Set = [] + var status: String? + var issues: [ICalendarEventIssue] = [] + + mutating func consume(_ line: ContentLine) { + guard !line.hasMalformedParameters else { + issues.append(.malformedProperty(line.name)) + return + } + + switch line.name { + case "UID": + uid = uniqueValue( + current: uid, + proposed: line.value.trimmingCharacters(in: .whitespacesAndNewlines), + propertyName: line.name + ) + case "SUMMARY": + summary = uniqueValue( + current: summary, + proposed: ICalendarParser.unescapeText(line.value), + propertyName: line.name + ) + case "LOCATION": + location = uniqueValue( + current: location, + proposed: ICalendarParser.unescapeText(line.value), + propertyName: line.name + ) + case "DESCRIPTION": + description = uniqueValue( + current: description, + proposed: ICalendarParser.unescapeText(line.value), + propertyName: line.name + ) + case "DTSTART": start = uniqueTemporalValue(current: start, line: line) + case "DTEND": end = uniqueTemporalValue(current: end, line: line) + case "RRULE": + recurrenceRule = uniqueValue(current: recurrenceRule, proposed: line.value, propertyName: line.name) + case "EXDATE", "RDATE", "RECURRENCE-ID": unsupportedRecurrenceProperties.insert(line.name) + case "STATUS": + let normalizedStatus = line.value.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() + if ["TENTATIVE", "CONFIRMED", "CANCELLED"].contains(normalizedStatus) { + status = uniqueValue(current: status, proposed: normalizedStatus, propertyName: line.name) + } else { + issues.append(.malformedProperty(line.name)) + } + default: break + } + } + + func build() -> ICalendarEvent { + ICalendarEvent( + uid: uid?.nilIfEmpty, + summary: summary?.nilIfEmpty, + location: location?.nilIfEmpty, + description: description?.nilIfEmpty, + start: start, + end: end, + recurrenceRule: recurrenceRule?.nilIfEmpty, + unsupportedRecurrenceProperties: unsupportedRecurrenceProperties, + status: status, + issues: issues + ) + } + + private mutating func uniqueValue( + current: String?, + proposed: String, + propertyName: String + ) -> String? { + guard current == nil else { + issues.append(.duplicateProperty(propertyName)) + return current + } + return proposed + } + + private mutating func uniqueTemporalValue( + current: ICalendarTemporalValue?, + line: ContentLine + ) -> ICalendarTemporalValue? { + guard current == nil else { + issues.append(.duplicateProperty(line.name)) + return current + } + + switch CalendarDateParser.parse(value: line.value, parameters: line.parameters) { + case let .success(value): return value + case let .failure(issue): + issues.append(issue) + return nil + } + } +} + +private extension String { + var nilIfEmpty: String? { + let trimmed = trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } +} diff --git a/Gapwise/Core/Import/RecurrenceRuleInterpreter.swift b/Gapwise/Core/Import/RecurrenceRuleInterpreter.swift new file mode 100644 index 0000000..0f06214 --- /dev/null +++ b/Gapwise/Core/Import/RecurrenceRuleInterpreter.swift @@ -0,0 +1,158 @@ +import Foundation + +struct WeeklyRecurrence: Equatable, Sendable { + let days: Set + let endsOn: LocalDate +} + +struct RecurrenceRuleInterpreter: Sendable { + private let calendar: Calendar + + init(calendar: Calendar = .gapwiseToronto) { + self.calendar = calendar + } + + func interpret(_ rawRule: String, startingAt start: Date) throws -> WeeklyRecurrence { + var values: [String: String] = [:] + for component in rawRule.split(separator: ";", omittingEmptySubsequences: false) { + let pair = component.split(separator: "=", maxSplits: 1, omittingEmptySubsequences: false) + guard pair.count == 2 else { throw SkippedEventReason.unsupportedRecurrence(rawRule) } + let key = String(pair[0]).uppercased() + guard !key.isEmpty, values[key] == nil else { + throw SkippedEventReason.unsupportedRecurrence(rawRule) + } + values[key] = String(pair[1]) + } + + let supportedKeys: Set = ["FREQ", "INTERVAL", "BYDAY", "UNTIL", "COUNT", "WKST"] + guard Set(values.keys).isSubset(of: supportedKeys), values["FREQ"]?.uppercased() == "WEEKLY" else { + throw SkippedEventReason.unsupportedRecurrence(rawRule) + } + + if let interval = values["INTERVAL"], interval != "1" { + throw SkippedEventReason.unsupportedRecurrence(rawRule) + } + if let weekStart = values["WKST"], try parseDays(weekStart, rawRule: rawRule).count != 1 { + throw SkippedEventReason.unsupportedRecurrence(rawRule) + } + guard values["UNTIL"] == nil || values["COUNT"] == nil else { + throw SkippedEventReason.unsupportedRecurrence(rawRule) + } + + let startWeekday = Weekday(date: start, calendar: calendar) + let days: Set + if let byDay = values["BYDAY"] { + let parsed = try parseDays(byDay, rawRule: rawRule) + guard let startWeekday, parsed.contains(startWeekday) else { + throw SkippedEventReason.unsupportedRecurrence(rawRule) + } + days = parsed + } else if let startWeekday { + days = [startWeekday] + } else { + throw SkippedEventReason.unsupportedRecurrence(rawRule) + } + + let endsOn: LocalDate + if let until = values["UNTIL"] { + endsOn = try endDate(from: until, start: start, days: days, rawRule: rawRule) + } else if let rawCount = values["COUNT"], let count = Int(rawCount), (1...10_000).contains(count) { + endsOn = try endDate(forOccurrenceCount: count, start: start, days: days, rawRule: rawRule) + } else { + throw SkippedEventReason.unsupportedRecurrence(rawRule) + } + + guard LocalDate(date: start, calendar: calendar) <= endsOn else { + throw SkippedEventReason.unsupportedRecurrence(rawRule) + } + return WeeklyRecurrence(days: days, endsOn: endsOn) + } + + private func parseDays(_ value: String, rawRule: String) throws -> Set { + var days: Set = [] + for token in value.split(separator: ",") { + let day: Weekday? + switch token.uppercased() { + case "SU": day = .sunday + case "MO": day = .monday + case "TU": day = .tuesday + case "WE": day = .wednesday + case "TH": day = .thursday + case "FR": day = .friday + case "SA": day = .saturday + default: day = nil + } + guard let day else { throw SkippedEventReason.unsupportedRecurrence(rawRule) } + days.insert(day) + } + guard !days.isEmpty else { throw SkippedEventReason.unsupportedRecurrence(rawRule) } + return days + } + + private func endDate( + from value: String, + start: Date, + days: Set, + rawRule: String + ) throws -> LocalDate { + switch CalendarDateParser.parse(value: value, parameters: [:]) { + case let .success(.date(date)): + return date + case let .success(.dateTime(dateTime)): + let boundaryDate = LocalDate(date: dateTime.instant, calendar: calendar) + guard + let boundaryWeekday = Weekday(date: dateTime.instant, calendar: calendar), + days.contains(boundaryWeekday), + let occurrence = occurrence(on: boundaryDate, atSameLocalTimeAs: start), + occurrence > dateTime.instant, + let previousDay = calendar.date(byAdding: .day, value: -1, to: dateTime.instant) + else { + return boundaryDate + } + return LocalDate(date: previousDay, calendar: calendar) + case .failure: + throw SkippedEventReason.unsupportedRecurrence(rawRule) + } + } + + private func occurrence(on date: LocalDate, atSameLocalTimeAs start: Date) -> Date? { + let startTime = calendar.dateComponents([.hour, .minute, .second], from: start) + return calendar.date( + from: DateComponents( + timeZone: calendar.timeZone, + year: date.year, + month: date.month, + day: date.day, + hour: startTime.hour, + minute: startTime.minute, + second: startTime.second + ) + ) + } + + private func endDate( + forOccurrenceCount count: Int, + start: Date, + days: Set, + rawRule: String + ) throws -> LocalDate { + var candidate = calendar.startOfDay(for: start) + var occurrenceCount = 0 + let maximumDays = count * 7 + 7 + + for _ in 0.. { + Set(meetings.map(\.campus)) + } + + var unresolvedCampusCount: Int { + meetings.lazy.filter { $0.campus == .unknown }.count + } +} + +private struct ImportedCourseIdentity: Hashable { + let campus: Campus + let courseCode: CourseCode +} + +struct TimetableImportChanges: Equatable, Sendable { + let added: Int + let updated: Int + let unchanged: Int + let retainedFromPreviousImport: Int +} + +struct TimetableImportPlan: Identifiable, Sendable { + var id: String { draft.sourceIdentifier } + + let draft: TimetableImportDraft + let changes: TimetableImportChanges + let resultingSnapshot: TimetableSnapshot +} diff --git a/Gapwise/Core/Import/TimetableImportService.swift b/Gapwise/Core/Import/TimetableImportService.swift new file mode 100644 index 0000000..4e320dc --- /dev/null +++ b/Gapwise/Core/Import/TimetableImportService.swift @@ -0,0 +1,16 @@ +import Foundation + +struct TimetableImportService: Sendable { + static let maximumDocumentSize = ICalendarParser.maximumByteCount + + func prepareImport( + data: Data, + suggestedFileName: String?, + existingSnapshot: TimetableSnapshot, + importedAt: Date = .now + ) throws -> TimetableImportPlan { + let document = try ICalendarParser().parse(data) + let draft = try TimetableImporter().interpret(document, suggestedFileName: suggestedFileName) + return TimetableReconciler().plan(draft: draft, existingSnapshot: existingSnapshot, importedAt: importedAt) + } +} diff --git a/Gapwise/Core/Import/TimetableImporter.swift b/Gapwise/Core/Import/TimetableImporter.swift new file mode 100644 index 0000000..0493c42 --- /dev/null +++ b/Gapwise/Core/Import/TimetableImporter.swift @@ -0,0 +1,343 @@ +import Foundation + +struct TimetableImporter: Sendable { + private let calendar: Calendar + private let courseParser = CourseDescriptorParser() + private let locationParser = UniversityLocationParser() + private let campusDetector = CampusDetector() + + init(calendar: Calendar = .gapwiseToronto) { + self.calendar = calendar + } + + func interpret( + _ document: ICalendarDocument, + suggestedFileName: String?, + campusContext: Campus? = nil + ) throws -> TimetableImportDraft { + let sourceIdentifier = CalendarImportSourceIdentifier.make( + calendarName: document.calendarName, + productIdentifier: document.productIdentifier, + suggestedFileName: suggestedFileName + ) + var meetings: [CourseMeeting] = [] + var warnings: [TimetableImportWarning] = [] + var skippedEvents: [SkippedCalendarEvent] = [] + var ignoredEventCount = 0 + var seenUIDs: Set = [] + + for (index, event) in document.events.enumerated() { + let fallbackIdentifier = "event-\(index + 1)" + let eventIdentifier = event.uid ?? fallbackIdentifier + + guard let descriptor = courseParser.parse(summary: event.summary, description: event.description) else { + if courseParser.containsCourseCode(summary: event.summary, description: event.description) { + skippedEvents.append( + skipped(event, fallbackID: fallbackIdentifier, reason: .missingMeetingSection) + ) + } else { + ignoredEventCount += 1 + } + continue + } + + guard event.status != "CANCELLED" else { + skippedEvents.append(skipped(event, fallbackID: fallbackIdentifier, reason: .cancelled)) + continue + } + + guard event.issues.allSatisfy({ !$0.blocksImport }) else { + skippedEvents.append(skipped(event, fallbackID: fallbackIdentifier, reason: .malformedRequiredProperty)) + continue + } + guard !event.issues.contains(where: \.isDateFailure) else { + skippedEvents.append(skipped(event, fallbackID: fallbackIdentifier, reason: .malformedDate)) + continue + } + guard let uid = event.uid else { + skippedEvents.append(skipped(event, fallbackID: fallbackIdentifier, reason: .missingUID)) + continue + } + guard let startValue = event.start else { + skippedEvents.append(skipped(event, fallbackID: fallbackIdentifier, reason: .missingStartTime)) + continue + } + guard let endValue = event.end else { + skippedEvents.append(skipped(event, fallbackID: fallbackIdentifier, reason: .missingEndTime)) + continue + } + guard case let .dateTime(startDateTime) = startValue, case let .dateTime(endDateTime) = endValue else { + skippedEvents.append(skipped(event, fallbackID: fallbackIdentifier, reason: .allDayEvent)) + continue + } + + let startDate = LocalDate(date: startDateTime.instant, calendar: calendar) + let endDate = LocalDate(date: endDateTime.instant, calendar: calendar) + let startComponents = calendar.dateComponents([.hour, .minute], from: startDateTime.instant) + let endComponents = calendar.dateComponents([.hour, .minute], from: endDateTime.instant) + guard + startDate == endDate, + endDateTime.instant > startDateTime.instant, + let startTime = LocalTime(hour: startComponents.hour ?? -1, minute: startComponents.minute ?? -1), + let endTime = LocalTime(hour: endComponents.hour ?? -1, minute: endComponents.minute ?? -1), + endTime > startTime + else { + skippedEvents.append(skipped(event, fallbackID: fallbackIdentifier, reason: .invalidTimeRange)) + continue + } + + if let unsupportedProperty = event.unsupportedRecurrenceProperties.sorted().first { + skippedEvents.append( + skipped( + event, + fallbackID: fallbackIdentifier, + reason: .unsupportedRecurrence(unsupportedProperty) + ) + ) + continue + } + + let recurrence: WeeklyRecurrence + if let rule = event.recurrenceRule { + do { + recurrence = try RecurrenceRuleInterpreter(calendar: calendar).interpret( + rule, + startingAt: startDateTime.instant + ) + } catch let reason as SkippedEventReason { + skippedEvents.append(skipped(event, fallbackID: fallbackIdentifier, reason: reason)) + continue + } + } else if let weekday = Weekday(date: startDateTime.instant, calendar: calendar) { + recurrence = WeeklyRecurrence(days: [weekday], endsOn: startDate) + } else { + skippedEvents.append(skipped(event, fallbackID: fallbackIdentifier, reason: .malformedDate)) + continue + } + + guard seenUIDs.insert(uid).inserted else { + warnings.append( + TimetableImportWarning( + kind: .duplicateUID, + courseCode: descriptor.courseCode, + eventIdentifier: eventIdentifier + ) + ) + continue + } + + let location = locationParser.parse(event.location) + let campus = campusDetector.detect( + calendarName: document.calendarName, + productIdentifier: document.productIdentifier, + location: location, + description: event.description, + importContext: campusContext + ).campus + let term = AcademicTerm( + id: .init(rawValue: "ics:\(startDate.iso8601String):\(recurrence.endsOn.iso8601String)"), + displayName: "Imported \(startDate.iso8601String) to \(recurrence.endsOn.iso8601String)", + startsOn: startDate, + endsOn: recurrence.endsOn + ) + + do { + let meeting = try CourseMeeting( + campus: campus, + courseCode: descriptor.courseCode, + courseTitle: descriptor.courseTitle, + term: term, + meetingType: descriptor.meetingType, + meetingSection: descriptor.meetingSection, + days: recurrence.days, + startTime: startTime, + endTime: endTime, + location: location, + instructor: descriptor.instructor, + sourceIdentifier: uid, + origin: .calendarImport(sourceIdentifier: sourceIdentifier) + ) + meetings.append(meeting) + } catch { + skippedEvents.append(skipped(event, fallbackID: fallbackIdentifier, reason: .invalidTimeRange)) + continue + } + + warnings.append( + contentsOf: warningsForEvent(event, descriptor: descriptor, location: location, campus: campus)) + } + + guard !meetings.isEmpty else { throw TimetableImportError.noSupportedEvents } + + return TimetableImportDraft( + sourceIdentifier: sourceIdentifier, + sourceName: document.calendarName, + meetings: meetings, + warnings: warnings.removingDuplicateIDs(), + skippedEvents: skippedEvents, + ignoredEventCount: ignoredEventCount, + totalEventCount: document.events.count + ) + } + + private func warningsForEvent( + _ event: ICalendarEvent, + descriptor: ParsedCourseDescriptor, + location: MeetingLocation?, + campus: Campus + ) -> [TimetableImportWarning] { + let eventIdentifier = event.uid ?? descriptor.meetingSection + var warnings: [TimetableImportWarning] = [] + + if event.temporalValues.contains(where: { $0.isFloating }) { + warnings.append( + TimetableImportWarning( + kind: .assumedTorontoTime, + courseCode: descriptor.courseCode, + eventIdentifier: eventIdentifier + ) + ) + } + if descriptor.courseTitle == nil { + warnings.append( + TimetableImportWarning( + kind: .missingCourseTitle, + courseCode: descriptor.courseCode, + eventIdentifier: eventIdentifier + ) + ) + } + if location == nil { + warnings.append( + TimetableImportWarning( + kind: .missingLocation, + courseCode: descriptor.courseCode, + eventIdentifier: eventIdentifier + ) + ) + } + if descriptor.meetingType == .other { + warnings.append( + TimetableImportWarning( + kind: .unknownMeetingType(descriptor.rawMeetingType), + courseCode: descriptor.courseCode, + eventIdentifier: eventIdentifier + ) + ) + } + if campus == .unknown { + warnings.append( + TimetableImportWarning( + kind: .unknownCampus, + courseCode: descriptor.courseCode, + eventIdentifier: eventIdentifier + ) + ) + } + if location?.kind == .unknown { + warnings.append( + TimetableImportWarning( + kind: .unrecognizedLocation, + courseCode: descriptor.courseCode, + eventIdentifier: eventIdentifier + ) + ) + } + for issue in event.issues { + if let property = issue.optionalPropertyName { + warnings.append( + TimetableImportWarning( + kind: .malformedOptionalProperty(property), + courseCode: descriptor.courseCode, + eventIdentifier: eventIdentifier + ) + ) + } + } + return warnings + } + + private func skipped( + _ event: ICalendarEvent, + fallbackID: String, + reason: SkippedEventReason + ) -> SkippedCalendarEvent { + SkippedCalendarEvent( + id: "\(fallbackID)|\(event.uid ?? "missing-uid")", + title: event.summary ?? "Untitled event", + reason: reason + ) + } +} + +private extension [TimetableImportWarning] { + func removingDuplicateIDs() -> Self { + var seen: Set = [] + return filter { seen.insert($0.id).inserted } + } +} + +private enum CalendarImportSourceIdentifier { + static func make(calendarName: String?, productIdentifier: String?, suggestedFileName: String?) -> String { + let source = [productIdentifier, calendarName] + .compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() } + .filter { !$0.isEmpty } + .joined(separator: "|") + let fallback = suggestedFileName?.lowercased() ?? "calendar" + return "ics-\(fnv1a64(source.isEmpty ? fallback : source))" + } + + private static func fnv1a64(_ value: String) -> String { + var hash: UInt64 = 14_695_981_039_346_656_037 + for byte in value.utf8 { + hash ^= UInt64(byte) + hash = hash &* 1_099_511_628_211 + } + return String(hash, radix: 16) + } +} + +private extension LocalDate { + var iso8601String: String { + String(format: "%04d-%02d-%02d", year, month, day) + } +} + +private extension ICalendarEventIssue { + var blocksImport: Bool { + let requiredProperties = ["UID", "SUMMARY", "DTSTART", "DTEND", "RRULE", "STATUS"] + switch self { + case let .duplicateProperty(property): return requiredProperties.contains(property) + case let .malformedProperty(property): return property.map(requiredProperties.contains) ?? false + case .invalidDate, .unknownTimeZone: return false + } + } + + var isDateFailure: Bool { + switch self { + case .invalidDate, .unknownTimeZone: true + case .duplicateProperty, .malformedProperty: false + } + } + + var optionalPropertyName: String? { + switch self { + case let .duplicateProperty(property): property + case let .malformedProperty(property): property ?? "property" + case .invalidDate, .unknownTimeZone: nil + } + } +} + +private extension ICalendarTemporalValue { + var isFloating: Bool { + guard case let .dateTime(value) = self else { return false } + return value.isFloating + } +} + +private extension ICalendarEvent { + var temporalValues: [ICalendarTemporalValue] { + [start, end].compactMap { $0 } + } +} diff --git a/Gapwise/Core/Import/TimetableReconciler.swift b/Gapwise/Core/Import/TimetableReconciler.swift new file mode 100644 index 0000000..b073a1b --- /dev/null +++ b/Gapwise/Core/Import/TimetableReconciler.swift @@ -0,0 +1,80 @@ +import Foundation + +struct TimetableReconciler: Sendable { + func plan( + draft: TimetableImportDraft, + existingSnapshot: TimetableSnapshot, + importedAt: Date + ) -> TimetableImportPlan { + var meetings = existingSnapshot.meetings + var indexesByKey: [ImportedMeetingKey: Int] = [:] + + for (index, meeting) in meetings.enumerated() { + guard let key = ImportedMeetingKey(meeting) else { continue } + if indexesByKey[key] == nil { + indexesByKey[key] = index + } + } + + var added = 0 + var updated = 0 + var unchanged = 0 + var incomingKeys: Set = [] + + for meeting in draft.meetings { + guard let key = ImportedMeetingKey(meeting) else { continue } + incomingKeys.insert(key) + + if let existingIndex = indexesByKey[key] { + if meetings[existingIndex] == meeting { + unchanged += 1 + } else { + meetings[existingIndex] = meeting + updated += 1 + } + } else { + indexesByKey[key] = meetings.count + meetings.append(meeting) + added += 1 + } + } + + let retained = meetings.lazy.filter { meeting in + guard + meeting.origin.kind == .calendarImport, + meeting.origin.sourceIdentifier == draft.sourceIdentifier, + let key = ImportedMeetingKey(meeting) + else { + return false + } + return !incomingKeys.contains(key) + }.count + + return TimetableImportPlan( + draft: draft, + changes: TimetableImportChanges( + added: added, + updated: updated, + unchanged: unchanged, + retainedFromPreviousImport: retained + ), + resultingSnapshot: TimetableSnapshot(meetings: meetings, lastModified: importedAt) + ) + } +} + +private struct ImportedMeetingKey: Hashable { + let importSourceIdentifier: String + let eventUID: String + + init?(_ meeting: CourseMeeting) { + guard + meeting.origin.kind == .calendarImport, + let sourceIdentifier = meeting.origin.sourceIdentifier + else { + return nil + } + importSourceIdentifier = sourceIdentifier + eventUID = meeting.id.sourceIdentifier + } +} diff --git a/Gapwise/Core/Import/UniversityLocationParser.swift b/Gapwise/Core/Import/UniversityLocationParser.swift new file mode 100644 index 0000000..2640de2 --- /dev/null +++ b/Gapwise/Core/Import/UniversityLocationParser.swift @@ -0,0 +1,90 @@ +import Foundation + +enum UniversityBuildingCatalog { + // This is intentionally a small classroom-oriented subset of codes published by UTM. + // Source: https://www.utm.utoronto.ca/facilities/notices/by-building + // Campus map: https://www.utm.utoronto.ca/future-students/sites/files/future-students/documents/2025-03/MOH25_CampusMap.pdf + static let utmBuildingNames: [String: String] = [ + "AX": "Academic Annex", + "CC": "Communication, Culture, and Technology Building", + "CCT": "Communication, Culture, and Technology Building", + "DH": "Deerfield Hall", + "DV": "William G. Davis Building", + "DW": "Erindale Studio Theatre", + "HB": "Terrence Donnelly Health Sciences Complex", + "HSC": "Terrence Donnelly Health Sciences Complex", + "HM": "Hazel McCallion Academic Learning Centre", + "IB": "Instructional Centre", + "KN": "Kaneff Centre and Innovation Complex", + "MN": "Maanjiwe nendamowinan", + "RA": "Recreation, Athletics and Wellness Centre", + "RAWC": "Recreation, Athletics and Wellness Centre", + "SB": "Science Building", + "XR": "Student Centre", + ] + + static func isKnownUTMCode(_ code: String) -> Bool { + utmBuildingNames[code.uppercased()] != nil + } +} + +struct UniversityLocationParser: Sendable { + func parse(_ rawValue: String?) -> MeetingLocation? { + guard let rawValue else { return nil } + let trimmed = rawValue.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + + let normalized = trimmed.uppercased() + if containsAny(normalized, values: ["ONLINE", "REMOTE", "VIRTUAL", "ZOOM", "ASYNCHRONOUS"]) { + return MeetingLocation(displayName: "Online", rawLocation: trimmed, kind: .online) + } + if containsAny(normalized, values: ["TBA", "TBD", "TO BE ANNOUNCED", "TO BE DETERMINED"]) { + return MeetingLocation(displayName: "TBA", rawLocation: trimmed, kind: .toBeAnnounced) + } + + if let components = buildingAndRoom(in: trimmed) { + return MeetingLocation( + displayName: trimmed, + rawLocation: trimmed, + buildingCode: components.building, + room: components.room, + kind: .physical + ) + } + + return MeetingLocation(displayName: trimmed, rawLocation: trimmed, kind: .unknown) + } + + private func containsAny(_ value: String, values: [String]) -> Bool { + values.contains { value.contains($0) } + } + + private func buildingAndRoom(in location: String) -> (building: String, room: String)? { + let rawTokens = location.split(whereSeparator: { $0.isWhitespace || ",;/()-".contains($0) }).map(String.init) + + for (index, rawToken) in rawTokens.enumerated() { + let letterPrefix = rawToken.prefix(while: \.isLetter) + let token = letterPrefix.uppercased() + let suffix = rawToken.dropFirst(letterPrefix.count) + + if (2...4).contains(token.count), suffix.first?.isNumber == true { + return (token, String(suffix)) + } + + guard + rawToken == rawToken.uppercased() || UniversityBuildingCatalog.isKnownUTMCode(token), + (2...4).contains(token.count), + index + 1 < rawTokens.count + else { + continue + } + + let nextToken = rawTokens[index + 1].trimmingCharacters(in: .punctuationCharacters) + if nextToken.first?.isNumber == true { + return (token, nextToken) + } + } + + return nil + } +} diff --git a/Gapwise/Core/Models/Campus.swift b/Gapwise/Core/Models/Campus.swift index 7e464a7..564f874 100644 --- a/Gapwise/Core/Models/Campus.swift +++ b/Gapwise/Core/Models/Campus.swift @@ -4,14 +4,18 @@ enum Campus: String, CaseIterable, Codable, Hashable, Identifiable, Sendable { case utm case utsg case utsc + case unknown var id: Self { self } + static let selectableCases: [Self] = [.utm, .utsg, .utsc] + var shortName: String { switch self { case .utm: "UTM" case .utsg: "UTSG" case .utsc: "UTSC" + case .unknown: "Unknown" } } @@ -20,6 +24,7 @@ enum Campus: String, CaseIterable, Codable, Hashable, Identifiable, Sendable { case .utm: "University of Toronto Mississauga" case .utsg: "University of Toronto St. George" case .utsc: "University of Toronto Scarborough" + case .unknown: "Campus not determined" } } } diff --git a/Gapwise/Core/Models/CourseMeeting.swift b/Gapwise/Core/Models/CourseMeeting.swift index 93974b2..09a3bc5 100644 --- a/Gapwise/Core/Models/CourseMeeting.swift +++ b/Gapwise/Core/Models/CourseMeeting.swift @@ -51,26 +51,121 @@ struct MeetingIdentity: Codable, Hashable, Sendable { let campus: Campus let courseCode: CourseCode let termID: AcademicTerm.ID - let courseSection: String + let courseSection: String? let meetingType: MeetingType let meetingSection: String let sourceIdentifier: String + let importSourceIdentifier: String? + + init( + campus: Campus, + courseCode: CourseCode, + termID: AcademicTerm.ID, + courseSection: String?, + meetingType: MeetingType, + meetingSection: String, + sourceIdentifier: String, + importSourceIdentifier: String? = nil + ) { + self.campus = campus + self.courseCode = courseCode + self.termID = termID + self.courseSection = courseSection + self.meetingType = meetingType + self.meetingSection = meetingSection + self.sourceIdentifier = sourceIdentifier + self.importSourceIdentifier = importSourceIdentifier + } + + private enum CodingKeys: String, CodingKey { + case campus + case courseCode + case termID + case courseSection + case meetingType + case meetingSection + case sourceIdentifier + case importSourceIdentifier + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + campus = try container.decode(Campus.self, forKey: .campus) + courseCode = try container.decode(CourseCode.self, forKey: .courseCode) + termID = try container.decode(AcademicTerm.ID.self, forKey: .termID) + courseSection = try container.decodeIfPresent(String.self, forKey: .courseSection) + meetingType = try container.decode(MeetingType.self, forKey: .meetingType) + meetingSection = try container.decode(String.self, forKey: .meetingSection) + sourceIdentifier = try container.decode(String.self, forKey: .sourceIdentifier) + importSourceIdentifier = try container.decodeIfPresent(String.self, forKey: .importSourceIdentifier) + } +} + +enum MeetingLocationKind: String, Codable, Hashable, Sendable { + case physical + case online + case toBeAnnounced + case unknown } struct MeetingLocation: Codable, Hashable, Sendable { let displayName: String + let rawLocation: String let buildingCode: String? let room: String? + let kind: MeetingLocationKind - init(displayName: String, buildingCode: String? = nil, room: String? = nil) { + init( + displayName: String, + rawLocation: String? = nil, + buildingCode: String? = nil, + room: String? = nil, + kind: MeetingLocationKind = .unknown + ) { self.displayName = displayName + self.rawLocation = rawLocation ?? displayName self.buildingCode = buildingCode self.room = room + self.kind = kind + } + + private enum CodingKeys: String, CodingKey { + case displayName + case rawLocation + case buildingCode + case room + case kind + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + displayName = try container.decode(String.self, forKey: .displayName) + rawLocation = try container.decodeIfPresent(String.self, forKey: .rawLocation) ?? displayName + buildingCode = try container.decodeIfPresent(String.self, forKey: .buildingCode) + room = try container.decodeIfPresent(String.self, forKey: .room) + kind = try container.decodeIfPresent(MeetingLocationKind.self, forKey: .kind) ?? .unknown + } +} + +enum MeetingOriginKind: String, Codable, Hashable, Sendable { + case calendarImport + case manual + case legacy +} + +struct MeetingOrigin: Codable, Hashable, Sendable { + let kind: MeetingOriginKind + let sourceIdentifier: String? + + static let legacy = MeetingOrigin(kind: .legacy, sourceIdentifier: nil) + static let manual = MeetingOrigin(kind: .manual, sourceIdentifier: nil) + + static func calendarImport(sourceIdentifier: String) -> Self { + MeetingOrigin(kind: .calendarImport, sourceIdentifier: sourceIdentifier) } } enum CourseMeetingValidationError: Error, Equatable { - case emptyCourseSection case emptyMeetingSection case emptySourceIdentifier case endNotAfterStart @@ -86,18 +181,28 @@ struct CourseMeeting: Identifiable, Codable, Hashable, Sendable { let endTime: LocalTime let location: MeetingLocation? let instructor: String? + let origin: MeetingOrigin var campus: Campus { id.campus } var courseCode: CourseCode { id.courseCode } var meetingType: MeetingType { id.meetingType } var meetingSection: String { id.meetingSection } + var displaySection: String { + let normalizedSection = meetingSection.uppercased() + let knownPrefixes = ["LEC", "TUT", "PRA", "LAB"] + if knownPrefixes.contains(where: normalizedSection.hasPrefix) { + return normalizedSection + } + return "\(meetingType.shortName) \(meetingSection)" + } + init( campus: Campus, courseCode: CourseCode, courseTitle: String? = nil, term: AcademicTerm, - courseSection: String, + courseSection: String? = nil, meetingType: MeetingType, meetingSection: String, days: Set, @@ -105,15 +210,13 @@ struct CourseMeeting: Identifiable, Codable, Hashable, Sendable { endTime: LocalTime, location: MeetingLocation? = nil, instructor: String? = nil, - sourceIdentifier: String + sourceIdentifier: String, + origin: MeetingOrigin = .legacy ) throws { - let courseSection = courseSection.trimmingCharacters(in: .whitespacesAndNewlines) + let courseSection = courseSection?.trimmingCharacters(in: .whitespacesAndNewlines) let meetingSection = meetingSection.trimmingCharacters(in: .whitespacesAndNewlines) let sourceIdentifier = sourceIdentifier.trimmingCharacters(in: .whitespacesAndNewlines) - guard !courseSection.isEmpty else { - throw CourseMeetingValidationError.emptyCourseSection - } guard !meetingSection.isEmpty else { throw CourseMeetingValidationError.emptyMeetingSection } @@ -131,10 +234,11 @@ struct CourseMeeting: Identifiable, Codable, Hashable, Sendable { campus: campus, courseCode: courseCode, termID: term.id, - courseSection: courseSection, + courseSection: courseSection?.isEmpty == false ? courseSection : nil, meetingType: meetingType, meetingSection: meetingSection, - sourceIdentifier: sourceIdentifier + sourceIdentifier: sourceIdentifier, + importSourceIdentifier: origin.sourceIdentifier ) self.courseTitle = courseTitle?.trimmingCharacters(in: .whitespacesAndNewlines) self.term = term @@ -143,6 +247,7 @@ struct CourseMeeting: Identifiable, Codable, Hashable, Sendable { self.endTime = endTime self.location = location self.instructor = instructor?.trimmingCharacters(in: .whitespacesAndNewlines) + self.origin = origin } private enum CodingKeys: String, CodingKey { @@ -154,6 +259,7 @@ struct CourseMeeting: Identifiable, Codable, Hashable, Sendable { case endTime case location case instructor + case origin } init(from decoder: Decoder) throws { @@ -173,7 +279,8 @@ struct CourseMeeting: Identifiable, Codable, Hashable, Sendable { endTime: try container.decode(LocalTime.self, forKey: .endTime), location: try container.decodeIfPresent(MeetingLocation.self, forKey: .location), instructor: try container.decodeIfPresent(String.self, forKey: .instructor), - sourceIdentifier: id.sourceIdentifier + sourceIdentifier: id.sourceIdentifier, + origin: try container.decodeIfPresent(MeetingOrigin.self, forKey: .origin) ?? .legacy ) } } diff --git a/Gapwise/Core/Models/Weekday.swift b/Gapwise/Core/Models/Weekday.swift index f872bfd..45881a7 100644 --- a/Gapwise/Core/Models/Weekday.swift +++ b/Gapwise/Core/Models/Weekday.swift @@ -9,6 +9,20 @@ enum Weekday: Int, CaseIterable, Codable, Hashable, Sendable { case friday case saturday + static let ordered: [Self] = [.monday, .tuesday, .wednesday, .thursday, .friday, .saturday, .sunday] + + var shortName: String { + switch self { + case .sunday: "Sun" + case .monday: "Mon" + case .tuesday: "Tue" + case .wednesday: "Wed" + case .thursday: "Thu" + case .friday: "Fri" + case .saturday: "Sat" + } + } + init?(date: Date, calendar: Calendar) { self.init(rawValue: calendar.component(.weekday, from: date)) } diff --git a/Gapwise/Core/Utilities/ScheduleCalculator.swift b/Gapwise/Core/Utilities/ScheduleCalculator.swift index 6f81a48..b5c630b 100644 --- a/Gapwise/Core/Utilities/ScheduleCalculator.swift +++ b/Gapwise/Core/Utilities/ScheduleCalculator.swift @@ -120,7 +120,7 @@ extension Calendar { static var gapwiseToronto: Calendar { var calendar = Calendar(identifier: .gregorian) calendar.locale = Locale(identifier: "en_CA") - calendar.timeZone = TimeZone(identifier: "America/Toronto") ?? .current + calendar.timeZone = TimeZone(identifier: "America/Toronto") ?? .gmt return calendar } } diff --git a/Gapwise/DesignSystem/GapwiseFormatters.swift b/Gapwise/DesignSystem/GapwiseFormatters.swift index d4b9664..824adc9 100644 --- a/Gapwise/DesignSystem/GapwiseFormatters.swift +++ b/Gapwise/DesignSystem/GapwiseFormatters.swift @@ -10,7 +10,30 @@ enum GapwiseFormatters { return String(format: "%02d:%02d", time.hour, time.minute) } - return value.formatted(date: .omitted, time: .shortened) + let format = Date.FormatStyle( + date: .omitted, + time: .shortened, + locale: .current, + calendar: calendar, + timeZone: calendar.timeZone + ) + return format.format(value) + } + + static func fullDate(_ date: Date, calendar: Calendar) -> String { + dateFormat(calendar: calendar) + .weekday(.wide) + .month(.wide) + .day() + .format(date) + } + + static func narrowWeekday(_ date: Date, calendar: Calendar) -> String { + dateFormat(calendar: calendar).weekday(.narrow).format(date) + } + + static func dayNumber(_ date: Date, calendar: Calendar) -> String { + dateFormat(calendar: calendar).day().format(date) } static func weekRange(_ dates: [Date], calendar: Calendar) -> String { @@ -19,12 +42,12 @@ enum GapwiseFormatters { let lastMonth = calendar.component(.month, from: last) if firstMonth == lastMonth { - return - "\(first.formatted(.dateTime.month(.wide))) \(first.formatted(.dateTime.day()))-\(last.formatted(.dateTime.day()))" + let format = dateFormat(calendar: calendar) + return "\(format.month(.wide).format(first)) \(format.day().format(first))-\(format.day().format(last))" } - return - "\(first.formatted(.dateTime.month(.abbreviated).day())) - \(last.formatted(.dateTime.month(.abbreviated).day()))" + let format = dateFormat(calendar: calendar).month(.abbreviated).day() + return "\(format.format(first)) - \(format.format(last))" } static func relativeMinutes(_ minutes: Int) -> String { @@ -39,4 +62,14 @@ enum GapwiseFormatters { } return "in \(hours) hr \(remainingMinutes) min" } + + private static func dateFormat(calendar: Calendar) -> Date.FormatStyle { + Date.FormatStyle( + date: .omitted, + time: .omitted, + locale: .current, + calendar: calendar, + timeZone: calendar.timeZone + ) + } } diff --git a/Gapwise/DesignSystem/MeetingLocationStyle.swift b/Gapwise/DesignSystem/MeetingLocationStyle.swift new file mode 100644 index 0000000..a8c85c7 --- /dev/null +++ b/Gapwise/DesignSystem/MeetingLocationStyle.swift @@ -0,0 +1,10 @@ +extension MeetingLocation { + var systemImageName: String { + switch kind { + case .physical: "mappin" + case .online: "video" + case .toBeAnnounced: "questionmark.circle" + case .unknown: "mappin.slash" + } + } +} diff --git a/Gapwise/Features/Campus/CampusView.swift b/Gapwise/Features/Campus/CampusView.swift index 9cbb04c..a54f7d7 100644 --- a/Gapwise/Features/Campus/CampusView.swift +++ b/Gapwise/Features/Campus/CampusView.swift @@ -13,7 +13,7 @@ struct CampusView: View { set: { appModel.setCampusContext($0) } ) ) { - ForEach(Campus.allCases) { campus in + ForEach(Campus.selectableCases) { campus in Text(campus.shortName).tag(campus) } } diff --git a/Gapwise/Features/Settings/SettingsView.swift b/Gapwise/Features/Settings/SettingsView.swift index 2a2a591..eb4436d 100644 --- a/Gapwise/Features/Settings/SettingsView.swift +++ b/Gapwise/Features/Settings/SettingsView.swift @@ -1,8 +1,10 @@ import SwiftUI +import UniformTypeIdentifiers struct SettingsView: View { @Environment(AppModel.self) private var appModel @State private var isConfirmingTimetableRemoval = false + @State private var isSelectingCalendar = false var body: some View { Form { @@ -21,6 +23,20 @@ struct SettingsView: View { } Section("Timetable") { + Button { + isSelectingCalendar = true + } label: { + if appModel.isPreparingImport { + HStack { + ProgressView() + Text("Reading Calendar") + } + } else { + Label("Import Calendar File", systemImage: "square.and.arrow.down") + } + } + .disabled(appModel.isLoading || appModel.isPreparingImport || appModel.isSavingImport) + LabeledContent("Saved meetings", value: "\(appModel.timetable.meetings.count)") if let lastModified = appModel.timetable.lastModified { @@ -44,7 +60,7 @@ struct SettingsView: View { set: { appModel.setCampusContext($0) } ) ) { - ForEach(Campus.allCases) { campus in + ForEach(Campus.selectableCases) { campus in Text(campus.shortName).tag(campus) } } @@ -89,6 +105,36 @@ struct SettingsView: View { } } .navigationTitle("Settings") + .fileImporter( + isPresented: $isSelectingCalendar, + allowedContentTypes: [.gapwiseICalendar] + ) { result in + switch result { + case let .success(url): + Task { @MainActor in + await appModel.prepareTimetableImport(from: url) + } + case let .failure(error): + let cocoaError = error as NSError + guard cocoaError.domain != NSCocoaErrorDomain || cocoaError.code != NSUserCancelledError else { return } + Task { @MainActor in + appModel.alertMessage = "The calendar picker could not open the selected file." + } + } + } + .sheet( + isPresented: Binding( + get: { appModel.pendingImportPlan != nil }, + set: { isPresented in + if !isPresented { appModel.cancelPendingImport() } + } + ) + ) { + if let plan = appModel.pendingImportPlan { + TimetableImportPreviewView(plan: plan) + .environment(appModel) + } + } .confirmationDialog( "Remove your saved timetable?", isPresented: $isConfirmingTimetableRemoval, diff --git a/Gapwise/Features/Timetable/MeetingCard.swift b/Gapwise/Features/Timetable/MeetingCard.swift index eb710d9..c47c8bd 100644 --- a/Gapwise/Features/Timetable/MeetingCard.swift +++ b/Gapwise/Features/Timetable/MeetingCard.swift @@ -36,14 +36,21 @@ struct MeetingCard: View { .background(Color.gapwiseAccent.opacity(0.12), in: Capsule()) } - Text("\(meeting.meetingType.shortName) \(meeting.meetingSection)") + if let courseTitle = meeting.courseTitle { + Text(courseTitle) + .font(.subheadline.weight(.medium)) + .lineLimit(2) + } + + Text(meeting.displaySection) .font(.subheadline) .foregroundStyle(.secondary) if let location = meeting.location { - Label(location.displayName, systemImage: "mappin") + Label(location.displayName, systemImage: location.systemImageName) .font(.subheadline) .foregroundStyle(.secondary) + .lineLimit(2) } } } @@ -57,7 +64,7 @@ struct MeetingCard: View { let end = GapwiseFormatters.time(meeting.endTime, on: date, calendar: calendar) let location = meeting.location.map { ", \($0.displayName)" } ?? "" return - "\(meeting.courseCode.rawValue), \(meeting.meetingType.shortName) \(meeting.meetingSection), \(start) to \(end), \(meeting.campus.shortName)\(location)" + "\(meeting.courseCode.rawValue), \(meeting.displaySection), \(start) to \(end), \(meeting.campus.shortName)\(location)" } } diff --git a/Gapwise/Features/Timetable/TimetableView.swift b/Gapwise/Features/Timetable/TimetableView.swift index 141d500..c674b9e 100644 --- a/Gapwise/Features/Timetable/TimetableView.swift +++ b/Gapwise/Features/Timetable/TimetableView.swift @@ -24,7 +24,7 @@ struct TimetableView: View { Divider() - Text(selectedDate.formatted(.dateTime.weekday(.wide).month(.wide).day())) + Text(GapwiseFormatters.fullDate(selectedDate, calendar: calculator.calendar)) .font(.title3.weight(.semibold)) .accessibilityAddTraits(.isHeader) diff --git a/Gapwise/Features/Timetable/WeekdayStrip.swift b/Gapwise/Features/Timetable/WeekdayStrip.swift index 04cff95..e4e331b 100644 --- a/Gapwise/Features/Timetable/WeekdayStrip.swift +++ b/Gapwise/Features/Timetable/WeekdayStrip.swift @@ -26,9 +26,9 @@ struct WeekdayStrip: View { onSelect(date) } label: { VStack(spacing: 5) { - Text(date.formatted(.dateTime.weekday(.narrow))) + Text(GapwiseFormatters.narrowWeekday(date, calendar: calendar)) .font(.caption.weight(.medium)) - Text(date.formatted(.dateTime.day())) + Text(GapwiseFormatters.dayNumber(date, calendar: calendar)) .font(.body.weight(isSelected ? .bold : .regular)) Circle() .fill(isToday ? Color.gapwiseAccent : .clear) @@ -43,7 +43,7 @@ struct WeekdayStrip: View { ) } .buttonStyle(.plain) - .accessibilityLabel(date.formatted(.dateTime.weekday(.wide).month(.wide).day())) + .accessibilityLabel(GapwiseFormatters.fullDate(date, calendar: calendar)) .accessibilityAddTraits(isSelected ? .isSelected : []) } } diff --git a/Gapwise/Features/TimetableImport/CalendarContentType.swift b/Gapwise/Features/TimetableImport/CalendarContentType.swift new file mode 100644 index 0000000..0e9dbdf --- /dev/null +++ b/Gapwise/Features/TimetableImport/CalendarContentType.swift @@ -0,0 +1,5 @@ +import UniformTypeIdentifiers + +extension UTType { + static let gapwiseICalendar = UTType(filenameExtension: "ics", conformingTo: .calendarEvent) ?? .calendarEvent +} diff --git a/Gapwise/Features/TimetableImport/ImportedDocumentReader.swift b/Gapwise/Features/TimetableImport/ImportedDocumentReader.swift new file mode 100644 index 0000000..773a901 --- /dev/null +++ b/Gapwise/Features/TimetableImport/ImportedDocumentReader.swift @@ -0,0 +1,51 @@ +import Foundation + +enum ImportedDocumentReadError: Error, LocalizedError, Sendable { + case notAFile + case tooLarge(maximumBytes: Int) + case unreadable + + var errorDescription: String? { + switch self { + case .notAFile: + "The selected item is not a calendar file." + case let .tooLarge(maximumBytes): + "The selected file is larger than the \(maximumBytes / 1_048_576) MB import limit." + case .unreadable: + "The selected calendar could not be read." + } + } +} + +enum ImportedDocumentReader { + static func read(url: URL, maximumByteCount: Int) async throws -> Data { + try await Task.detached(priority: .userInitiated) { + let accessedSecurityScopedResource = url.startAccessingSecurityScopedResource() + defer { + if accessedSecurityScopedResource { + url.stopAccessingSecurityScopedResource() + } + } + + do { + let values = try url.resourceValues(forKeys: [.fileSizeKey, .isRegularFileKey]) + if values.isRegularFile == false { + throw ImportedDocumentReadError.notAFile + } + if let fileSize = values.fileSize, fileSize > maximumByteCount { + throw ImportedDocumentReadError.tooLarge(maximumBytes: maximumByteCount) + } + + let data = try Data(contentsOf: url) + guard data.count <= maximumByteCount else { + throw ImportedDocumentReadError.tooLarge(maximumBytes: maximumByteCount) + } + return data + } catch let error as ImportedDocumentReadError { + throw error + } catch { + throw ImportedDocumentReadError.unreadable + } + }.value + } +} diff --git a/Gapwise/Features/TimetableImport/TimetableImportPreviewView.swift b/Gapwise/Features/TimetableImport/TimetableImportPreviewView.swift new file mode 100644 index 0000000..5ee0cc5 --- /dev/null +++ b/Gapwise/Features/TimetableImport/TimetableImportPreviewView.swift @@ -0,0 +1,197 @@ +import SwiftUI + +struct TimetableImportPreviewView: View { + @Environment(AppModel.self) private var appModel + + let plan: TimetableImportPlan + + var body: some View { + NavigationStack { + List { + summarySection + changesSection + meetingsSection + + if !plan.draft.warnings.isEmpty { + warningsSection + } + if !plan.draft.skippedEvents.isEmpty || plan.draft.ignoredEventCount > 0 { + skippedSection + } + } + .navigationTitle("Review Import") + .navigationBarTitleDisplayMode(.inline) + .interactiveDismissDisabled(appModel.isSavingImport) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + appModel.cancelPendingImport() + } + .disabled(appModel.isSavingImport) + } + + ToolbarItem(placement: .confirmationAction) { + Button { + Task { + await appModel.confirmPendingImport() + } + } label: { + if appModel.isSavingImport { + ProgressView() + .accessibilityLabel("Saving timetable") + } else { + Text("Import") + .bold() + } + } + .disabled(appModel.isSavingImport) + } + } + } + } + + private var summarySection: some View { + Section { + LabeledContent("Courses", value: "\(plan.draft.courseCount)") + LabeledContent("Meetings", value: "\(plan.draft.meetings.count)") + LabeledContent("Campuses", value: campusSummary) + + if plan.draft.unresolvedCampusCount > 0 { + Label( + "\(plan.draft.unresolvedCampusCount) meeting\(plan.draft.unresolvedCampusCount == 1 ? "" : "s") need campus review.", + systemImage: "questionmark.circle" + ) + .foregroundStyle(.secondary) + } + } header: { + Text(plan.draft.sourceName ?? "Calendar") + .lineLimit(2) + } footer: { + Text("The calendar is processed on this device. Its original contents are not retained.") + } + } + + private var changesSection: some View { + Section("Changes") { + ChangeRow(title: "New", count: plan.changes.added, systemImage: "plus.circle.fill", tint: .gapwiseSuccess) + ChangeRow( + title: "Updated", count: plan.changes.updated, systemImage: "arrow.triangle.2.circlepath", + tint: .gapwiseAccent) + ChangeRow( + title: "Unchanged", count: plan.changes.unchanged, systemImage: "checkmark.circle", tint: .secondary) + + if plan.changes.retainedFromPreviousImport > 0 { + ChangeRow( + title: "Previously imported", + count: plan.changes.retainedFromPreviousImport, + systemImage: "archivebox", + tint: .secondary + ) + } + } footer: { + if plan.changes.retainedFromPreviousImport > 0 { + Text("Meetings missing from this file will remain saved until explicit removal is available.") + } + } + } + + private var meetingsSection: some View { + Section("Ready to Import") { + ForEach(plan.draft.meetings) { meeting in + VStack(alignment: .leading, spacing: GapwiseSpacing.compact) { + HStack(alignment: .firstTextBaseline) { + Text(meeting.courseCode.rawValue) + .font(.headline) + Spacer() + Text(meeting.campus.shortName) + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + } + + if let courseTitle = meeting.courseTitle { + Text(courseTitle) + .font(.subheadline.weight(.medium)) + .lineLimit(2) + } + Text(meeting.displaySection) + .font(.subheadline) + Text(scheduleSummary(for: meeting)) + .font(.subheadline) + .foregroundStyle(.secondary) + + if let location = meeting.location { + Label(location.displayName, systemImage: location.systemImageName) + .font(.subheadline) + .foregroundStyle(.secondary) + .lineLimit(2) + } + } + .padding(.vertical, 2) + .accessibilityElement(children: .combine) + } + } + } + + private var warningsSection: some View { + Section("Review") { + ForEach(plan.draft.warnings) { warning in + Label(warning.message, systemImage: "exclamationmark.triangle") + .foregroundStyle(.secondary) + } + } + } + + private var skippedSection: some View { + Section("Not Imported") { + if !plan.draft.skippedEvents.isEmpty { + LabeledContent("Course events skipped", value: "\(plan.draft.skippedEvents.count)") + } + + ForEach(plan.draft.skippedEvents) { event in + VStack(alignment: .leading, spacing: 3) { + Text(event.title) + .font(.subheadline.weight(.semibold)) + .lineLimit(2) + Text(event.reason.message) + .font(.footnote) + .foregroundStyle(.secondary) + } + .accessibilityElement(children: .combine) + } + + if plan.draft.ignoredEventCount > 0 { + LabeledContent("Non-course events", value: "\(plan.draft.ignoredEventCount)") + } + } + } + + private var campusSummary: String { + plan.draft.campuses + .sorted { $0.shortName < $1.shortName } + .map(\.shortName) + .joined(separator: ", ") + } + + private func scheduleSummary(for meeting: CourseMeeting) -> String { + let days = Weekday.ordered.compactMap { meeting.days.contains($0) ? $0.shortName : nil }.joined(separator: ", ") + let start = GapwiseFormatters.time(meeting.startTime, on: .now, calendar: .gapwiseToronto) + let end = GapwiseFormatters.time(meeting.endTime, on: .now, calendar: .gapwiseToronto) + return "\(days) · \(start)–\(end)" + } +} + +private struct ChangeRow: View { + let title: String + let count: Int + let systemImage: String + let tint: Color + + var body: some View { + Label { + LabeledContent(title, value: "\(count)") + } icon: { + Image(systemName: systemImage) + .foregroundStyle(tint) + } + } +} diff --git a/Gapwise/Features/Today/TodayView.swift b/Gapwise/Features/Today/TodayView.swift index 4370462..820322a 100644 --- a/Gapwise/Features/Today/TodayView.swift +++ b/Gapwise/Features/Today/TodayView.swift @@ -19,7 +19,7 @@ struct TodayView: View { return ScrollView { LazyVStack(alignment: .leading, spacing: GapwiseSpacing.standard) { - Text(now.formatted(.dateTime.weekday(.wide).month(.wide).day())) + Text(GapwiseFormatters.fullDate(now, calendar: calculator.calendar)) .font(.title2.weight(.bold)) .accessibilityAddTraits(.isHeader) @@ -75,19 +75,26 @@ struct TodayView: View { VStack(alignment: .leading, spacing: 4) { Text(meeting.courseCode.rawValue) .font(.title2.weight(.bold)) - Text("\(meeting.meetingType.shortName) \(meeting.meetingSection)") + if let courseTitle = meeting.courseTitle { + Text(courseTitle) + .font(.subheadline.weight(.medium)) + .lineLimit(2) + } + Text(meeting.displaySection) .font(.subheadline) .foregroundStyle(.secondary) } HStack(spacing: GapwiseSpacing.standard) { Label( - GapwiseFormatters.time(meeting.startTime, on: now, calendar: calculator.calendar), + timeRange(for: meeting, on: now), systemImage: "clock" ) if let location = meeting.location { - Label(location.displayName, systemImage: "mappin") + Label(location.displayName, systemImage: location.systemImageName) .lineLimit(2) + } else { + Label("Location unavailable", systemImage: "mappin.slash") } } .font(.subheadline) @@ -96,6 +103,12 @@ struct TodayView: View { .accessibilityElement(children: .combine) } + private func timeRange(for meeting: CourseMeeting, on date: Date) -> String { + let start = GapwiseFormatters.time(meeting.startTime, on: date, calendar: calculator.calendar) + let end = GapwiseFormatters.time(meeting.endTime, on: date, calendar: calculator.calendar) + return "\(start)–\(end)" + } + private var classesCompleteCard: some View { Label("Classes complete for today", systemImage: "checkmark.circle.fill") .font(.headline) diff --git a/GapwiseTests/Core/CampusAndLocationTests.swift b/GapwiseTests/Core/CampusAndLocationTests.swift new file mode 100644 index 0000000..fe03c5d --- /dev/null +++ b/GapwiseTests/Core/CampusAndLocationTests.swift @@ -0,0 +1,97 @@ +import Foundation +import XCTest + +#if canImport(Gapwise) + @testable import Gapwise +#else + @testable import GapwiseCore +#endif + +final class CampusAndLocationTests: XCTestCase, @unchecked Sendable { + func testRecognizesSupportedLocationKinds() { + let parser = UniversityLocationParser() + + XCTAssertEqual(parser.parse("MN1210")?.kind, .physical) + XCTAssertEqual(parser.parse("MN1210")?.buildingCode, "MN") + XCTAssertEqual(parser.parse("MN1210")?.room, "1210") + XCTAssertEqual(parser.parse("online synchronous")?.kind, .online) + XCTAssertEqual(parser.parse("Room TBA")?.kind, .toBeAnnounced) + XCTAssertEqual(parser.parse("Location pending")?.kind, .unknown) + XCTAssertNil(parser.parse(" ")) + } + + func testKnownUTMBuildingCodesProvideExplainableCampusEvidence() throws { + let location = try XCTUnwrap(UniversityLocationParser().parse("DV 2072")) + let detection = CampusDetector().detect( + calendarName: nil, + productIdentifier: nil, + location: location, + description: nil + ) + + XCTAssertEqual(detection, CampusDetection(campus: .utm, evidence: .buildingCode("DV"))) + } + + func testMetadataAndExplicitLocationWordingDetectAllCampuses() { + let detector = CampusDetector() + + XCTAssertEqual( + detector.detect( + calendarName: "University of Toronto Mississauga", + productIdentifier: nil, + location: nil, + description: nil + ).campus, + .utm + ) + XCTAssertEqual( + detector.detect( + calendarName: "St. George timetable", + productIdentifier: nil, + location: nil, + description: nil + ).campus, + .utsg + ) + XCTAssertEqual( + detector.detect( + calendarName: nil, + productIdentifier: nil, + location: nil, + description: "University of Toronto Scarborough" + ).campus, + .utsc + ) + } + + func testConflictingAndInsufficientSignalsStayUnknown() { + let detector = CampusDetector() + let conflict = detector.detect( + calendarName: "UTM timetable", + productIdentifier: nil, + location: MeetingLocation(displayName: "UTSG", rawLocation: "UTSG"), + description: nil + ) + let unresolved = detector.detect( + calendarName: "Student timetable", + productIdentifier: nil, + location: MeetingLocation(displayName: "Room pending"), + description: nil + ) + + XCTAssertEqual(conflict, CampusDetection(campus: .unknown, evidence: .conflictingSignals)) + XCTAssertEqual(unresolved, CampusDetection(campus: .unknown, evidence: .unresolved)) + } + + func testExplicitImportContextTakesPrecedence() { + let detection = CampusDetector().detect( + calendarName: nil, + productIdentifier: nil, + location: nil, + description: nil, + importContext: .utsc + ) + + XCTAssertEqual(detection, CampusDetection(campus: .utsc, evidence: .importContext)) + } +} diff --git a/GapwiseTests/Core/CourseDescriptorParserTests.swift b/GapwiseTests/Core/CourseDescriptorParserTests.swift new file mode 100644 index 0000000..06096cb --- /dev/null +++ b/GapwiseTests/Core/CourseDescriptorParserTests.swift @@ -0,0 +1,53 @@ +import XCTest + +#if canImport(Gapwise) + @testable import Gapwise +#else + @testable import GapwiseCore +#endif + +final class CourseDescriptorParserTests: XCTestCase, @unchecked Sendable { + func testParsesCommonCourseAndMeetingVariants() throws { + let cases: [(String, String, MeetingType, String)] = [ + ("MAT157Y5 LEC0101 - Analysis I", "MAT157Y5", .lecture, "LEC0101"), + ("CSC110Y1 TUT 0201", "CSC110Y1", .tutorial, "TUT0201"), + ("CSC110Y5 PRA-0301", "CSC110Y5", .practical, "PRA0301"), + ("PSYA01H3 LAB:0102", "PSYA01H3", .practical, "LAB0102"), + ] + + for (summary, expectedCode, expectedType, expectedSection) in cases { + let parsed = try XCTUnwrap(CourseDescriptorParser().parse(summary: summary, description: nil)) + XCTAssertEqual(parsed.courseCode.rawValue, expectedCode) + XCTAssertEqual(parsed.meetingType, expectedType) + XCTAssertEqual(parsed.meetingSection, expectedSection) + } + } + + func testUnknownMeetingTypeIsPreservedAsOther() throws { + let parsed = try XCTUnwrap( + CourseDescriptorParser().parse(summary: "MGT120H5 SEM0401 - Management", description: nil) + ) + + XCTAssertEqual(parsed.meetingType, .other) + XCTAssertEqual(parsed.rawMeetingType, "SEM") + XCTAssertEqual(parsed.meetingSection, "SEM0401") + } + + func testDoesNotCombineUnrelatedCodesAndSectionsAcrossFields() { + let parser = CourseDescriptorParser() + let parsed = parser.parse( + summary: "MAT157Y5 study group", + description: "Meet near CSC108H5 LEC0101 after class" + ) + + XCTAssertEqual(parsed?.courseCode.rawValue, "CSC108H5") + XCTAssertNil(parser.parse(summary: "MAT157Y5 study group", description: "Meet near LEC0101 after class")) + } + + func testCourseWithoutMeetingSectionIsNotAValidDescriptor() { + let parser = CourseDescriptorParser() + + XCTAssertNil(parser.parse(summary: "MAT157Y5 - Analysis I", description: "Instructor: Test Person")) + XCTAssertTrue(parser.containsCourseCode(summary: "MAT157Y5 - Analysis I", description: nil)) + } +} diff --git a/GapwiseTests/Core/FixtureLoader.swift b/GapwiseTests/Core/FixtureLoader.swift new file mode 100644 index 0000000..83c8ae0 --- /dev/null +++ b/GapwiseTests/Core/FixtureLoader.swift @@ -0,0 +1,20 @@ +import Foundation +import XCTest + +private final class FixtureBundleToken {} + +enum FixtureLoader { + static func data(named name: String) throws -> Data { + let bundle: Bundle + #if SWIFT_PACKAGE + bundle = .module + #else + bundle = Bundle(for: FixtureBundleToken.self) + #endif + + let url = + bundle.url(forResource: name, withExtension: "ics", subdirectory: "Fixtures") + ?? bundle.url(forResource: name, withExtension: "ics") + return try Data(contentsOf: XCTUnwrap(url, "Missing fixture \(name).ics")) + } +} diff --git a/GapwiseTests/Core/Fixtures/empty.ics b/GapwiseTests/Core/Fixtures/empty.ics new file mode 100644 index 0000000..f34bb44 --- /dev/null +++ b/GapwiseTests/Core/Fixtures/empty.ics @@ -0,0 +1,5 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Gapwise Tests//Empty Calendar//EN +X-WR-CALNAME:Empty Timetable +END:VCALENDAR diff --git a/GapwiseTests/Core/Fixtures/folded-escaped.ics b/GapwiseTests/Core/Fixtures/folded-escaped.ics new file mode 100644 index 0000000..c7c9845 --- /dev/null +++ b/GapwiseTests/Core/Fixtures/folded-escaped.ics @@ -0,0 +1,16 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Gapwise Tests//Text Handling//EN +X-WR-CALNAME:UTM Student Calendar +BEGIN:VEVENT +UID:text-event@example.invalid +DTSTART;TZID=America/Toronto:20260918T110000 +DTEND;TZID=America/Toronto:20260918T120000 +RRULE:FREQ=WEEKLY;BYDAY=FR;COUNT=2 +SUMMARY:ENG100H5 LEC0101 - Writing\, Rhetoric\; and Comm + unication – Café +LOCATION:CC 1080\, presentation room +DESCRIPTION:Instructor: Grace Hopper\nBring notes\; laptop +X-UNKNOWN-PROPERTY:ignored safely +END:VEVENT +END:VCALENDAR diff --git a/GapwiseTests/Core/Fixtures/malformed.ics b/GapwiseTests/Core/Fixtures/malformed.ics new file mode 100644 index 0000000..2f50360 --- /dev/null +++ b/GapwiseTests/Core/Fixtures/malformed.ics @@ -0,0 +1,29 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Gapwise Tests//Malformed Events//EN +X-WR-CALNAME:UTM Timetable +BEGIN:VEVENT +UID:malformed-date@example.invalid +DTSTART;TZID=America/Toronto:not-a-date +DTEND;TZID=America/Toronto:20260914T110000 +RRULE:FREQ=WEEKLY;BYDAY=MO;COUNT=12 +SUMMARY:MAT157Y5 LEC0101 - Analysis I +LOCATION:MN 1210 +END:VEVENT +BEGIN:VEVENT +UID:unsupported-rule@example.invalid +DTSTART;TZID=America/Toronto:20260915T120000 +DTEND;TZID=America/Toronto:20260915T130000 +RRULE:FREQ=DAILY;COUNT=10 +SUMMARY:CSC108H5 TUT0201 - Introduction to Programming +LOCATION:IB 120 +END:VEVENT +BEGIN:VEVENT +UID:valid-event@example.invalid +DTSTART;TZID=America/Toronto:20260916T140000 +DTEND;TZID=America/Toronto:20260916T150000 +RRULE:FREQ=WEEKLY;BYDAY=WE;COUNT=10 +SUMMARY:STA107H5 LEC0101 - Data Science +LOCATION:DV 2072 +END:VEVENT +END:VCALENDAR diff --git a/GapwiseTests/Core/Fixtures/mixed-campus.ics b/GapwiseTests/Core/Fixtures/mixed-campus.ics new file mode 100644 index 0000000..a4e9d15 --- /dev/null +++ b/GapwiseTests/Core/Fixtures/mixed-campus.ics @@ -0,0 +1,43 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Gapwise Tests//Synthetic Mixed Schedule//EN +X-WR-CALNAME:Student Timetable +BEGIN:VEVENT +UID:mixed-mat157-utm@example.invalid +DTSTART;TZID=America/Toronto:20260914T100000 +DTEND;TZID=America/Toronto:20260914T110000 +RRULE:FREQ=WEEKLY;BYDAY=MO;COUNT=12 +SUMMARY:MAT157Y5 LEC0101 - Analysis I +LOCATION:MN 1210, UTM +END:VEVENT +BEGIN:VEVENT +UID:mixed-mat157-utsg@example.invalid +DTSTART;TZID=America/Toronto:20260914T120000 +DTEND;TZID=America/Toronto:20260914T130000 +RRULE:FREQ=WEEKLY;BYDAY=MO;COUNT=12 +SUMMARY:MAT157Y5 LEC0101 - Analysis I +LOCATION:St. George campus +END:VEVENT +BEGIN:VEVENT +UID:mixed-psya01-utsc@example.invalid +DTSTART;TZID=America/Toronto:20260915T090000 +DTEND;TZID=America/Toronto:20260915T110000 +RRULE:FREQ=WEEKLY;BYDAY=TU;COUNT=12 +SUMMARY:PSYA01H3 LEC0201 - Introduction to Psychology +LOCATION:University of Toronto Scarborough +END:VEVENT +BEGIN:VEVENT +UID:mixed-eco101-unknown@example.invalid +DTSTART;TZID=America/Toronto:20260916T160000 +DTEND;TZID=America/Toronto:20260916T170000 +RRULE:FREQ=WEEKLY;BYDAY=WE;COUNT=12 +SUMMARY:ECO101H1 TUT0301 - Principles of Microeconomics +LOCATION:Room pending +END:VEVENT +BEGIN:VEVENT +UID:mixed-personal@example.invalid +DTSTART;TZID=America/Toronto:20260916T180000 +DTEND;TZID=America/Toronto:20260916T190000 +SUMMARY:Dinner with a friend +END:VEVENT +END:VCALENDAR diff --git a/GapwiseTests/Core/Fixtures/normal-utm.ics b/GapwiseTests/Core/Fixtures/normal-utm.ics new file mode 100644 index 0000000..a1c6754 --- /dev/null +++ b/GapwiseTests/Core/Fixtures/normal-utm.ics @@ -0,0 +1,30 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Gapwise Tests//Synthetic UTM Schedule//EN +X-WR-CALNAME:University of Toronto Mississauga Timetable +BEGIN:VEVENT +UID:utm-mat157-lec@example.invalid +DTSTART;TZID=America/Toronto:20260914T100000 +DTEND;TZID=America/Toronto:20260914T110000 +RRULE:FREQ=WEEKLY;BYDAY=MO,WE;UNTIL=20261207T235959 +SUMMARY:MAT157Y5 LEC0101 - Analysis I +LOCATION:MN 1210 +DESCRIPTION:Instructor: Ada Lovelace +END:VEVENT +BEGIN:VEVENT +UID:utm-csc108-tut@example.invalid +DTSTART:20260915T120000 +DTEND:20260915T130000 +RRULE:FREQ=WEEKLY;BYDAY=TU;COUNT=12 +SUMMARY:CSC108H5 TUT0201 - Introduction to Computer Programming +LOCATION:TBA +END:VEVENT +BEGIN:VEVENT +UID:utm-csc108-pra@example.invalid +DTSTART:20260917T140000Z +DTEND:20260917T150000Z +RRULE:FREQ=WEEKLY;BYDAY=TH;UNTIL=20261203T220000Z +SUMMARY:CSC108H5 PRA0301 - Introduction to Computer Programming +LOCATION:Online synchronous +END:VEVENT +END:VCALENDAR diff --git a/GapwiseTests/Core/Fixtures/update-v1.ics b/GapwiseTests/Core/Fixtures/update-v1.ics new file mode 100644 index 0000000..2270143 --- /dev/null +++ b/GapwiseTests/Core/Fixtures/update-v1.ics @@ -0,0 +1,21 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Gapwise Tests//Update Schedule//EN +X-WR-CALNAME:UTM Update Fixture +BEGIN:VEVENT +UID:update-primary@example.invalid +DTSTART;TZID=America/Toronto:20260914T100000 +DTEND;TZID=America/Toronto:20260914T110000 +RRULE:FREQ=WEEKLY;BYDAY=MO;COUNT=12 +SUMMARY:MAT157Y5 LEC0101 - Analysis I +LOCATION:MN 1210 +END:VEVENT +BEGIN:VEVENT +UID:update-removed@example.invalid +DTSTART;TZID=America/Toronto:20260915T120000 +DTEND;TZID=America/Toronto:20260915T130000 +RRULE:FREQ=WEEKLY;BYDAY=TU;COUNT=12 +SUMMARY:CSC108H5 TUT0201 - Introduction to Programming +LOCATION:IB 120 +END:VEVENT +END:VCALENDAR diff --git a/GapwiseTests/Core/Fixtures/update-v2.ics b/GapwiseTests/Core/Fixtures/update-v2.ics new file mode 100644 index 0000000..172ceb6 --- /dev/null +++ b/GapwiseTests/Core/Fixtures/update-v2.ics @@ -0,0 +1,13 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Gapwise Tests//Update Schedule//EN +X-WR-CALNAME:UTM Update Fixture +BEGIN:VEVENT +UID:update-primary@example.invalid +DTSTART;TZID=America/Toronto:20260914T103000 +DTEND;TZID=America/Toronto:20260914T113000 +RRULE:FREQ=WEEKLY;BYDAY=MO;COUNT=12 +SUMMARY:MAT157Y5 LEC0101 - Analysis I +LOCATION:MN 2110 +END:VEVENT +END:VCALENDAR diff --git a/GapwiseTests/Core/Fixtures/utsc.ics b/GapwiseTests/Core/Fixtures/utsc.ics new file mode 100644 index 0000000..36af65a --- /dev/null +++ b/GapwiseTests/Core/Fixtures/utsc.ics @@ -0,0 +1,13 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Gapwise Tests//Synthetic Scarborough Schedule//EN +X-WR-CALNAME:University of Toronto Scarborough +BEGIN:VEVENT +UID:utsc-csc110@example.invalid +DTSTART;TZID=America/Toronto:20260916T130000 +DTEND;TZID=America/Toronto:20260916T150000 +RRULE:FREQ=WEEKLY;BYDAY=WE;COUNT=11 +SUMMARY:CSC110Y3 LAB0102 - Foundations of Computer Science +LOCATION:IC 204 +END:VEVENT +END:VCALENDAR diff --git a/GapwiseTests/Core/Fixtures/utsg.ics b/GapwiseTests/Core/Fixtures/utsg.ics new file mode 100644 index 0000000..0801bcc --- /dev/null +++ b/GapwiseTests/Core/Fixtures/utsg.ics @@ -0,0 +1,13 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Gapwise Tests//Synthetic St George Schedule//EN +X-WR-CALNAME:University of Toronto St. George +BEGIN:VEVENT +UID:utsg-mat157@example.invalid +DTSTART;TZID=America/Toronto:20260914T090000 +DTEND;TZID=America/Toronto:20260914T100000 +RRULE:FREQ=WEEKLY;BYDAY=MO;UNTIL=20261207T235959 +SUMMARY:MAT157Y1 LEC 0101 - Analysis I +LOCATION:BA 1130 +END:VEVENT +END:VCALENDAR diff --git a/GapwiseTests/Core/ICalendarParserTests.swift b/GapwiseTests/Core/ICalendarParserTests.swift new file mode 100644 index 0000000..13d41e9 --- /dev/null +++ b/GapwiseTests/Core/ICalendarParserTests.swift @@ -0,0 +1,159 @@ +import Foundation +import XCTest + +#if canImport(Gapwise) + @testable import Gapwise +#else + @testable import GapwiseCore +#endif + +final class ICalendarParserTests: XCTestCase, @unchecked Sendable { + func testParsesCRLFAndLFCalendars() throws { + let lf = minimalCalendar(lineEnding: "\n") + let crlf = minimalCalendar(lineEnding: "\r\n") + + let lfDocument = try ICalendarParser().parse(Data(lf.utf8)) + let crlfDocument = try ICalendarParser().parse(Data(crlf.utf8)) + + XCTAssertEqual(lfDocument, crlfDocument) + XCTAssertEqual(lfDocument.events.count, 1) + } + + func testUnfoldsLinesAndUnescapesText() throws { + let document = try ICalendarParser().parse(FixtureLoader.data(named: "folded-escaped")) + let event = try XCTUnwrap(document.events.first) + + XCTAssertEqual(event.summary, "ENG100H5 LEC0101 - Writing, Rhetoric; and Communication – Café") + XCTAssertEqual(event.location, "CC 1080, presentation room") + XCTAssertEqual(event.description, "Instructor: Grace Hopper\nBring notes; laptop") + } + + func testUnknownFieldsAreIgnored() throws { + let document = try ICalendarParser().parse(FixtureLoader.data(named: "folded-escaped")) + + XCTAssertEqual(document.events.count, 1) + XCTAssertTrue(try XCTUnwrap(document.events.first).issues.isEmpty) + } + + func testRejectsInvalidCalendarStructures() { + let cases = [ + "not a calendar", + "BEGIN:VCALENDAR\nBEGIN:VEVENT\nEND:VCALENDAR", + "BEGIN:VCALENDAR\nEND:VEVENT\nEND:VCALENDAR", + "BEGIN:VCALENDAR\nBEGIN:VEVENT\nBEGIN:VEVENT\nEND:VEVENT\nEND:VEVENT\nEND:VCALENDAR", + "BEGIN;X-INVALID=1:VCALENDAR\nEND:VCALENDAR", + "BEGIN:VCALENDAR\nEND:VCALENDAR\nBEGIN:VCALENDAR\nEND:VCALENDAR", + ] + + for value in cases { + XCTAssertThrowsError(try ICalendarParser().parse(Data(value.utf8)), "Input: \(value)") + } + } + + func testRejectsInvalidUTF8AndNULBytes() { + XCTAssertThrowsError(try ICalendarParser().parse(Data([0xFF, 0xFE]))) { error in + XCTAssertEqual(error as? TimetableImportError, .invalidTextEncoding) + } + XCTAssertThrowsError(try ICalendarParser().parse(Data("BEGIN:VCALENDAR\0END:VCALENDAR".utf8))) { error in + XCTAssertEqual(error as? TimetableImportError, .invalidTextEncoding) + } + } + + func testRejectsOversizedDocumentsAndLines() { + let oversized = Data(repeating: 65, count: ICalendarParser.maximumByteCount + 1) + XCTAssertThrowsError(try ICalendarParser().parse(oversized)) { error in + XCTAssertEqual( + error as? TimetableImportError, + .documentTooLarge(maximumBytes: ICalendarParser.maximumByteCount) + ) + } + + let longLine = "BEGIN:VCALENDAR\nX-NOTE:\(String(repeating: "a", count: 65_537))\nEND:VCALENDAR" + XCTAssertThrowsError(try ICalendarParser().parse(Data(longLine.utf8))) { error in + XCTAssertEqual(error as? TimetableImportError, .malformedCalendar) + } + } + + func testRejectsPathologicalEventCounts() { + let event = "BEGIN:VEVENT\nUID:x\nEND:VEVENT\n" + let calendar = + "BEGIN:VCALENDAR\n" + String(repeating: event, count: ICalendarParser.maximumEventCount + 1) + + "END:VCALENDAR" + + XCTAssertThrowsError(try ICalendarParser().parse(Data(calendar.utf8))) { error in + XCTAssertEqual( + error as? TimetableImportError, + .tooManyEvents(maximum: ICalendarParser.maximumEventCount) + ) + } + } + + func testTorontoAndUTCTimestampsRepresentExpectedLocalTimeAcrossDST() throws { + let summer = try parsedDate("20260914T100000", parameters: ["TZID": "America/Toronto"]) + let summerUTC = try parsedDate("20260914T140000Z", parameters: [:]) + let winter = try parsedDate("20260112T100000", parameters: ["TZID": "America/Toronto"]) + let winterUTC = try parsedDate("20260112T150000Z", parameters: [:]) + + XCTAssertEqual(summer, summerUTC) + XCTAssertEqual(winter, winterUTC) + } + + func testUnknownTimezoneBecomesAnEventIssue() throws { + let calendar = """ + BEGIN:VCALENDAR + VERSION:2.0 + BEGIN:VEVENT + UID:unknown-zone@example.invalid + DTSTART;TZID=Mars/Olympus:20260914T100000 + DTEND;TZID=Mars/Olympus:20260914T110000 + SUMMARY:MAT157Y5 LEC0101 + END:VEVENT + END:VCALENDAR + """ + + let document = try ICalendarParser().parse(Data(calendar.utf8)) + let event = try XCTUnwrap(document.events.first) + + XCTAssertNil(event.start) + XCTAssertEqual(event.issues, [.unknownTimeZone("Mars/Olympus"), .unknownTimeZone("Mars/Olympus")]) + } + + func testRejectsConflictingOrInvalidDateParameters() { + let cases: [(String, [String: String])] = [ + ("20260914T140000Z", ["TZID": "America/Toronto"]), + ("20260914", ["VALUE": "DATE", "TZID": "America/Toronto"]), + ("20260914", ["VALUE": "DATE-TIME"]), + ("20260914T100000", ["VALUE": "TEXT"]), + ] + + for (value, parameters) in cases { + guard case .failure = CalendarDateParser.parse(value: value, parameters: parameters) else { + return XCTFail("Expected \(value) with \(parameters) to fail") + } + } + } + + private func parsedDate(_ value: String, parameters: [String: String]) throws -> Date { + let parsed = CalendarDateParser.parse(value: value, parameters: parameters) + switch parsed { + case let .success(.dateTime(dateTime)): + return dateTime.instant + case .success(.date), .failure: + return try XCTUnwrap(nil as Date?) + } + } + + private func minimalCalendar(lineEnding: String) -> String { + [ + "BEGIN:VCALENDAR", + "VERSION:2.0", + "BEGIN:VEVENT", + "UID:test@example.invalid", + "DTSTART;TZID=America/Toronto:20260914T100000", + "DTEND;TZID=America/Toronto:20260914T110000", + "SUMMARY:MAT157Y5 LEC0101", + "END:VEVENT", + "END:VCALENDAR", + ].joined(separator: lineEnding) + } +} diff --git a/GapwiseTests/Core/RecurrenceRuleInterpreterTests.swift b/GapwiseTests/Core/RecurrenceRuleInterpreterTests.swift new file mode 100644 index 0000000..879cb8b --- /dev/null +++ b/GapwiseTests/Core/RecurrenceRuleInterpreterTests.swift @@ -0,0 +1,49 @@ +import Foundation +import XCTest + +#if canImport(Gapwise) + @testable import Gapwise +#else + @testable import GapwiseCore +#endif + +final class RecurrenceRuleInterpreterTests: XCTestCase, @unchecked Sendable { + private let calendar = Calendar.gapwiseToronto + + func testCountAcrossMultipleWeekdaysEndsOnExactOccurrence() throws { + let recurrence = try RecurrenceRuleInterpreter(calendar: calendar).interpret( + "FREQ=WEEKLY;BYDAY=MO,WE;COUNT=4", + startingAt: date(year: 2026, month: 9, day: 14, hour: 10) + ) + + XCTAssertEqual(recurrence.days, [.monday, .wednesday]) + XCTAssertEqual(recurrence.endsOn, LocalDate(year: 2026, month: 9, day: 23)) + } + + func testUntilBeforeMeetingTimeDoesNotIncludeAnExtraOccurrence() throws { + let recurrence = try RecurrenceRuleInterpreter(calendar: calendar).interpret( + "FREQ=WEEKLY;BYDAY=MO;UNTIL=20260921T130000Z", + startingAt: date(year: 2026, month: 9, day: 14, hour: 10) + ) + + XCTAssertEqual(recurrence.endsOn, LocalDate(year: 2026, month: 9, day: 20)) + } + + func testUnboundedDuplicateAndNonWeeklyRulesAreRejected() { + let start = date(year: 2026, month: 9, day: 14, hour: 10) + let rules = [ + "FREQ=WEEKLY;BYDAY=MO", + "FREQ=WEEKLY;BYDAY=MO;BYDAY=TU;COUNT=10", + "FREQ=DAILY;COUNT=10", + "FREQ=WEEKLY;INTERVAL=2;COUNT=10", + ] + + for rule in rules { + XCTAssertThrowsError(try RecurrenceRuleInterpreter(calendar: calendar).interpret(rule, startingAt: start)) + } + } + + private func date(year: Int, month: Int, day: Int, hour: Int) -> Date { + calendar.date(from: DateComponents(year: year, month: month, day: day, hour: hour)) ?? .distantPast + } +} diff --git a/GapwiseTests/Core/TimetableImporterTests.swift b/GapwiseTests/Core/TimetableImporterTests.swift new file mode 100644 index 0000000..0e81726 --- /dev/null +++ b/GapwiseTests/Core/TimetableImporterTests.swift @@ -0,0 +1,237 @@ +import Foundation +import XCTest + +#if canImport(Gapwise) + @testable import Gapwise +#else + @testable import GapwiseCore +#endif + +final class TimetableImporterTests: XCTestCase, @unchecked Sendable { + func testNormalUTMImportBuildsStructuredMeetings() throws { + let draft = try importFixture("normal-utm") + + XCTAssertEqual(draft.courseCount, 2) + XCTAssertEqual(draft.meetings.count, 3) + XCTAssertEqual(draft.campuses, [.utm]) + XCTAssertTrue(draft.skippedEvents.isEmpty) + XCTAssertTrue(draft.warnings.contains { $0.kind == .assumedTorontoTime }) + + let lecture = try meeting(code: "MAT157Y5", type: .lecture, in: draft) + XCTAssertEqual(lecture.meetingSection, "LEC0101") + XCTAssertEqual(lecture.days, [.monday, .wednesday]) + XCTAssertEqual(lecture.startTime, LocalTime(hour: 10, minute: 0)) + XCTAssertEqual(lecture.endTime, LocalTime(hour: 11, minute: 0)) + XCTAssertEqual(lecture.location?.buildingCode, "MN") + XCTAssertEqual(lecture.location?.room, "1210") + XCTAssertEqual(lecture.instructor, "Ada Lovelace") + + let tutorial = try meeting(code: "CSC108H5", type: .tutorial, in: draft) + XCTAssertEqual(tutorial.location?.kind, .toBeAnnounced) + + let practical = try meeting(code: "CSC108H5", type: .practical, in: draft) + XCTAssertEqual(practical.location?.kind, .online) + XCTAssertEqual(practical.startTime, LocalTime(hour: 10, minute: 0)) + } + + func testImportedMeetingsDriveTodayAndNextClassCalculations() throws { + let draft = try importFixture("normal-utm") + let calendar = Calendar.gapwiseToronto + let beforeLecture = try XCTUnwrap( + calendar.date(from: DateComponents(year: 2026, month: 9, day: 14, hour: 9, minute: 30)) + ) + let calculator = ScheduleCalculator(calendar: calendar) + + let meetings = calculator.meetings(on: beforeLecture, in: draft.meetings) + let nextClass = calculator.currentOrNextClass(at: beforeLecture, in: draft.meetings) + + XCTAssertEqual(meetings.map(\.courseCode.rawValue), ["MAT157Y5"]) + XCTAssertEqual(nextClass?.occurrence.meeting.id, meetings.first?.id) + XCTAssertEqual(nextClass?.timing, .upcoming(minutesUntil: 30)) + } + + func testCampusSpecificSchedulesRemainCampusQualified() throws { + let utsg = try importFixture("utsg") + let utsc = try importFixture("utsc") + + XCTAssertEqual(utsg.meetings.map(\.campus), [.utsg]) + XCTAssertEqual(utsc.meetings.map(\.campus), [.utsc]) + XCTAssertEqual(try XCTUnwrap(utsc.meetings.first).meetingType, .practical) + } + + func testMixedCampusImportPreservesIdenticalCourseCodes() throws { + let draft = try importFixture("mixed-campus") + let matchingMeetings = draft.meetings.filter { $0.courseCode.rawValue == "MAT157Y5" } + + XCTAssertEqual(draft.meetings.count, 4) + XCTAssertEqual(draft.ignoredEventCount, 1) + XCTAssertEqual(draft.unresolvedCampusCount, 1) + XCTAssertEqual(Set(matchingMeetings.map(\.campus)), [.utm, .utsg]) + XCTAssertEqual(Set(matchingMeetings.map(\.id)).count, 2) + } + + func testMalformedAndUnsupportedEventsAreSkippedWithoutDiscardingValidMeetings() throws { + let draft = try importFixture("malformed") + + XCTAssertEqual(draft.meetings.map(\.courseCode.rawValue), ["STA107H5"]) + XCTAssertEqual(draft.skippedEvents.count, 2) + XCTAssertTrue(draft.skippedEvents.contains { $0.reason == .malformedDate }) + XCTAssertTrue( + draft.skippedEvents.contains { + if case .unsupportedRecurrence = $0.reason { return true } + return false + } + ) + } + + func testFoldedUnicodeAndEscapedFixtureIsInterpreted() throws { + let draft = try importFixture("folded-escaped") + let meeting = try XCTUnwrap(draft.meetings.first) + + XCTAssertEqual(meeting.courseTitle, "Writing, Rhetoric; and Communication – Café") + XCTAssertEqual(meeting.location?.displayName, "CC 1080, presentation room") + XCTAssertEqual(meeting.instructor, "Grace Hopper") + } + + func testEmptyCalendarProducesUnderstandableBlockingError() throws { + XCTAssertThrowsError(try importFixture("empty")) { error in + XCTAssertEqual(error as? TimetableImportError, .noSupportedEvents) + XCTAssertEqual( + (error as? LocalizedError)?.errorDescription, + "The selected file does not contain any supported timetable events." + ) + } + } + + func testUnboundedAndExceptionRecurrencesAreSkipped() throws { + let data = Data( + calendarWithEvents([ + event( + uid: "unbounded", + codeAndSection: "MAT157Y5 LEC0101", + recurrence: "RRULE:FREQ=WEEKLY;BYDAY=MO" + ), + event( + uid: "exception", + codeAndSection: "CSC108H5 TUT0101", + recurrence: "RRULE:FREQ=WEEKLY;BYDAY=MO;COUNT=10\nEXDATE:20260921T100000" + ), + event(uid: "valid", codeAndSection: "STA107H5 PRA0101", recurrence: ""), + ]).utf8 + ) + + let document = try ICalendarParser().parse(data) + let draft = try TimetableImporter().interpret(document, suggestedFileName: "recurrence.ics") + + XCTAssertEqual(draft.meetings.map(\.courseCode.rawValue), ["STA107H5"]) + XCTAssertEqual(draft.skippedEvents.count, 2) + } + + func testDuplicateUIDIsIgnoredWithWarning() throws { + let duplicate = event(uid: "same", codeAndSection: "MAT157Y5 LEC0101", recurrence: "") + let document = try ICalendarParser().parse(Data(calendarWithEvents([duplicate, duplicate]).utf8)) + let draft = try TimetableImporter().interpret(document, suggestedFileName: "duplicate.ics") + + XCTAssertEqual(draft.meetings.count, 1) + XCTAssertTrue(draft.warnings.contains { $0.kind == .duplicateUID }) + } + + func testDuplicateRequiredPropertiesSkipOnlyTheAffectedEvent() throws { + let duplicateStart = event( + uid: "duplicate-start", + codeAndSection: "MAT157Y5 LEC0101", + recurrence: "DTSTART;TZID=America/Toronto:20260914T120000" + ) + let valid = event(uid: "valid", codeAndSection: "CSC108H5 TUT0101", recurrence: "") + let document = try ICalendarParser().parse(Data(calendarWithEvents([duplicateStart, valid]).utf8)) + let draft = try TimetableImporter().interpret(document, suggestedFileName: "duplicate-property.ics") + + XCTAssertEqual(draft.meetings.map(\.courseCode.rawValue), ["CSC108H5"]) + XCTAssertEqual(draft.skippedEvents.map(\.reason), [.malformedRequiredProperty]) + } + + func testUnknownMeetingTypeProducesAReviewWarning() throws { + let unknownType = event(uid: "seminar", codeAndSection: "MGT120H5 SEM0401", recurrence: "") + let document = try ICalendarParser().parse(Data(calendarWithEvents([unknownType]).utf8)) + let draft = try TimetableImporter().interpret(document, suggestedFileName: "unknown-type.ics") + + XCTAssertEqual(draft.meetings.first?.meetingType, .other) + XCTAssertTrue(draft.warnings.contains { $0.kind == .unknownMeetingType("SEM") }) + } + + func testMissingLocationProducesAReviewWarning() throws { + let event = """ + BEGIN:VEVENT + UID:no-location + DTSTART;TZID=America/Toronto:20260914T100000 + DTEND;TZID=America/Toronto:20260914T110000 + SUMMARY:MAT157Y5 LEC0101 - Analysis I + END:VEVENT + """ + let document = try ICalendarParser().parse(Data(calendarWithEvents([event]).utf8)) + let draft = try TimetableImporter().interpret(document, suggestedFileName: "no-location.ics") + + XCTAssertNil(draft.meetings.first?.location) + XCTAssertTrue(draft.warnings.contains { $0.kind == .missingLocation }) + } + + func testUnsafeOrIncompleteCourseEventsAreSkippedWithTypedReasons() throws { + let allDay = """ + BEGIN:VEVENT + UID:all-day + DTSTART;VALUE=DATE:20260914 + DTEND;VALUE=DATE:20260915 + SUMMARY:MAT157Y5 LEC0101 + END:VEVENT + """ + let cancelled = event(uid: "cancelled", codeAndSection: "CSC108H5 TUT0101", recurrence: "STATUS:CANCELLED") + let missingUID = event(uid: "", codeAndSection: "STA107H5 PRA0101", recurrence: "") + let missingEnd = """ + BEGIN:VEVENT + UID:missing-end + DTSTART;TZID=America/Toronto:20260914T100000 + SUMMARY:MGT120H5 LEC0101 + END:VEVENT + """ + let valid = event(uid: "valid", codeAndSection: "ECO101H5 LEC0101", recurrence: "") + let document = try ICalendarParser().parse( + Data(calendarWithEvents([allDay, cancelled, missingUID, missingEnd, valid]).utf8) + ) + let draft = try TimetableImporter().interpret(document, suggestedFileName: "invalid-events.ics") + let reasons = Set(draft.skippedEvents.map(\.reason)) + + XCTAssertEqual(draft.meetings.map(\.courseCode.rawValue), ["ECO101H5"]) + XCTAssertEqual(reasons, [.allDayEvent, .cancelled, .missingUID, .missingEndTime]) + } + + private func importFixture(_ name: String) throws -> TimetableImportDraft { + let document = try ICalendarParser().parse(FixtureLoader.data(named: name)) + return try TimetableImporter().interpret(document, suggestedFileName: "\(name).ics") + } + + private func meeting( + code: String, + type: MeetingType, + in draft: TimetableImportDraft + ) throws -> CourseMeeting { + try XCTUnwrap(draft.meetings.first { $0.courseCode.rawValue == code && $0.meetingType == type }) + } + + private func calendarWithEvents(_ events: [String]) -> String { + (["BEGIN:VCALENDAR", "VERSION:2.0", "X-WR-CALNAME:UTM Timetable"] + events + ["END:VCALENDAR"]) + .joined(separator: "\n") + } + + private func event(uid: String, codeAndSection: String, recurrence: String) -> String { + [ + "BEGIN:VEVENT", + "UID:\(uid)", + "DTSTART;TZID=America/Toronto:20260914T100000", + "DTEND;TZID=America/Toronto:20260914T110000", + recurrence, + "SUMMARY:\(codeAndSection)", + "LOCATION:MN 1210", + "END:VEVENT", + ].filter { !$0.isEmpty }.joined(separator: "\n") + } +} diff --git a/GapwiseTests/Core/TimetableReconcilerTests.swift b/GapwiseTests/Core/TimetableReconcilerTests.swift new file mode 100644 index 0000000..15b2d4a --- /dev/null +++ b/GapwiseTests/Core/TimetableReconcilerTests.swift @@ -0,0 +1,157 @@ +import Foundation +import XCTest + +#if canImport(Gapwise) + @testable import Gapwise +#else + @testable import GapwiseCore +#endif + +final class TimetableReconcilerTests: XCTestCase, @unchecked Sendable { + func testImportingSameTimetableTwiceDoesNotDuplicateMeetings() throws { + let service = TimetableImportService() + let data = try FixtureLoader.data(named: "normal-utm") + let first = try service.prepareImport( + data: data, + suggestedFileName: "schedule.ics", + existingSnapshot: .empty, + importedAt: date(hour: 9) + ) + let second = try service.prepareImport( + data: data, + suggestedFileName: "schedule.ics", + existingSnapshot: first.resultingSnapshot, + importedAt: date(hour: 10) + ) + + XCTAssertEqual(first.changes.added, 3) + XCTAssertEqual(second.changes.added, 0) + XCTAssertEqual(second.changes.updated, 0) + XCTAssertEqual(second.changes.unchanged, 3) + XCTAssertEqual(second.resultingSnapshot.meetings.count, 3) + } + + func testNewerImportUpdatesMatchingUIDAndConservativelyRetainsMissingMeeting() throws { + let service = TimetableImportService() + let first = try service.prepareImport( + data: FixtureLoader.data(named: "update-v1"), + suggestedFileName: "schedule.ics", + existingSnapshot: .empty, + importedAt: date(hour: 9) + ) + let second = try service.prepareImport( + data: FixtureLoader.data(named: "update-v2"), + suggestedFileName: "schedule.ics", + existingSnapshot: first.resultingSnapshot, + importedAt: date(hour: 10) + ) + + XCTAssertEqual(second.changes.updated, 1) + XCTAssertEqual(second.changes.retainedFromPreviousImport, 1) + XCTAssertEqual(second.resultingSnapshot.meetings.count, 2) + + let updated = try XCTUnwrap( + second.resultingSnapshot.meetings.first { $0.id.sourceIdentifier == "update-primary@example.invalid" } + ) + XCTAssertEqual(updated.startTime, LocalTime(hour: 10, minute: 30)) + XCTAssertEqual(updated.location?.room, "2110") + } + + func testImportedAndLegacyMeetingsRemainSeparate() throws { + let imported = try TimetableImportService().prepareImport( + data: FixtureLoader.data(named: "normal-utm"), + suggestedFileName: "schedule.ics", + existingSnapshot: .empty + ) + let legacy = try makeMeeting(sourceIdentifier: "local-entry", origin: .manual) + let existing = TimetableSnapshot(meetings: imported.resultingSnapshot.meetings + [legacy]) + let repeated = try TimetableImportService().prepareImport( + data: FixtureLoader.data(named: "normal-utm"), + suggestedFileName: "schedule.ics", + existingSnapshot: existing + ) + + XCTAssertEqual(repeated.resultingSnapshot.meetings.count, 4) + XCTAssertTrue(repeated.resultingSnapshot.meetings.contains(legacy)) + } + + func testSameUIDFromDifferentImportSourcesHasDistinctIdentity() throws { + let first = try makeMeeting( + sourceIdentifier: "shared-uid", origin: .calendarImport(sourceIdentifier: "source-a")) + let second = try makeMeeting( + sourceIdentifier: "shared-uid", origin: .calendarImport(sourceIdentifier: "source-b")) + + XCTAssertNotEqual(first.id, second.id) + XCTAssertEqual(Set([first, second]).count, 2) + } + + func testLegacySnapshotWithoutNewMetadataStillDecodes() throws { + let original = try makeMeeting(sourceIdentifier: "legacy-id", origin: .legacy) + let encoded = try JSONEncoder().encode(original) + var object = try XCTUnwrap(JSONSerialization.jsonObject(with: encoded) as? [String: Any]) + object.removeValue(forKey: "origin") + var identifier = try XCTUnwrap(object["id"] as? [String: Any]) + identifier.removeValue(forKey: "importSourceIdentifier") + object["id"] = identifier + if var location = object["location"] as? [String: Any] { + location.removeValue(forKey: "rawLocation") + location.removeValue(forKey: "kind") + object["location"] = location + } + + let decoded = try JSONDecoder().decode(CourseMeeting.self, from: JSONSerialization.data(withJSONObject: object)) + + XCTAssertEqual(decoded.origin, .legacy) + XCTAssertNil(decoded.id.importSourceIdentifier) + XCTAssertEqual(decoded.location?.rawLocation, decoded.location?.displayName) + XCTAssertEqual(decoded.location?.kind, .unknown) + } + + func testImportedSnapshotPersistsThroughRepositoryRelaunch() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("GapwiseImportTests-\(UUID().uuidString)", isDirectory: true) + let fileURL = directory.appendingPathComponent("timetable.json") + let repository = JSONTimetableRepository(fileURL: fileURL) + defer { try? FileManager.default.removeItem(at: directory) } + + let plan = try TimetableImportService().prepareImport( + data: FixtureLoader.data(named: "mixed-campus"), + suggestedFileName: "schedule.ics", + existingSnapshot: .empty, + importedAt: date(hour: 9) + ) + try await repository.save(plan.resultingSnapshot) + + let relaunchedRepository = JSONTimetableRepository(fileURL: fileURL) + let loaded = try await relaunchedRepository.load() + + XCTAssertEqual(loaded, plan.resultingSnapshot) + } + + private func makeMeeting(sourceIdentifier: String, origin: MeetingOrigin) throws -> CourseMeeting { + try CourseMeeting( + campus: .utm, + courseCode: XCTUnwrap(CourseCode(rawValue: "MAT157Y5")), + term: AcademicTerm( + id: .init(rawValue: "2026-fall"), + displayName: "Fall 2026", + startsOn: LocalDate(year: 2026, month: 9, day: 1), + endsOn: LocalDate(year: 2026, month: 12, day: 31) + ), + meetingType: .lecture, + meetingSection: "LEC0101", + days: [.monday], + startTime: XCTUnwrap(LocalTime(hour: 10, minute: 0)), + endTime: XCTUnwrap(LocalTime(hour: 11, minute: 0)), + location: MeetingLocation(displayName: "MN 1210"), + sourceIdentifier: sourceIdentifier, + origin: origin + ) + } + + private func date(hour: Int) -> Date { + Calendar.gapwiseToronto.date( + from: DateComponents(year: 2026, month: 9, day: 14, hour: hour) + ) ?? .distantPast + } +} diff --git a/Package.swift b/Package.swift index 1dda82a..bd6d918 100644 --- a/Package.swift +++ b/Package.swift @@ -19,7 +19,8 @@ let package = Package( .testTarget( name: "GapwiseCoreTests", dependencies: ["GapwiseCore"], - path: "GapwiseTests/Core" + path: "GapwiseTests/Core", + resources: [.process("Fixtures")] ), ] ) diff --git a/README.md b/README.md index a8205ba..92ade98 100644 --- a/README.md +++ b/README.md @@ -35,9 +35,9 @@ The goal is a real native iOS application rather than a WebView wrapper: navigat ## Current status -The repository now contains the first native application foundation: an iPhone SwiftUI target, local timetable persistence, campus-qualified schedule models, date-aware Today and Timetable views, an honest UTM-first Campus boundary, Settings, portable domain tests, and macOS CI validation. +The repository contains a working early native application: an iPhone SwiftUI target, on-device `.ics` timetable import with a review-before-save flow, local timetable persistence, campus-qualified schedule models, date-aware Today and Timetable views, an honest UTM-first Campus boundary, Settings, portable domain tests, and macOS CI configuration. -This remains an **early implementation**, not a shipped client. ACORN import, timetable editing, gaps, maps, routing, account continuity, and remote integrations are still planned work. The preview schedule is development-only and the production app starts with an empty local timetable. +This remains an **early implementation**, not a shipped client. Import compatibility has been validated against synthetic standards-based fixtures, not every calendar producer or a live ACORN export. Timetable editing, gaps, maps, routing, account continuity, and remote integrations are still planned work. The preview schedule is development-only and the production app starts with an empty local timetable. --- @@ -63,12 +63,13 @@ The current foundation includes: - **Timetable**, with week/day navigation and campus-aware meeting cards; - **Campus**, with a selectable campus context and an explicit UTM-first availability state; - **Settings**, with appearance, local timetable removal, campus context, privacy, version, and ecosystem links; +- a native document-picker and import preview for compatible `.ics` timetable/calendar exports; - atomic local JSON persistence behind an async repository boundary; - light/dark appearance, Dynamic Type-friendly layouts, VoiceOver labels, and native navigation. The native iOS client is intended to grow toward the remaining Gapwise experience: -- local ACORN `.ics` import across U of T campuses; +- broader timetable-import compatibility and user-controlled editing; - **Gaps** for deterministic time-between-class planning; - **Map** for UTM-focused campus navigation and route context; - timetable editing, exports, and integrations; @@ -89,7 +90,26 @@ Gapwise for iOS uses modern native Apple tooling: - a native map stack compatible with canonical Gapwise UTM data and routing semantics - optional Gapwise account integration only after its security boundary is implemented and reviewed -The architecture keeps portable domain models and schedule arithmetic separate from persistence and SwiftUI features. Parsing, account/sync, and map integration will remain separate boundaries as they are introduced. +The architecture keeps portable domain models, iCalendar parsing, schedule interpretation, reconciliation, and schedule arithmetic separate from persistence and SwiftUI features. Account/sync and map integration will remain separate boundaries as they are introduced. + +### Timetable import + +Gapwise accepts user-selected `.ics` files through the system document picker. Parsing, interpretation, preview, and persistence happen locally. The original calendar file is not copied into the app container and timetable contents are not uploaded. + +The current parser intentionally supports the timetable-oriented iCalendar subset used by the app: `VCALENDAR`, `VEVENT`, `DTSTART`, `DTEND`, `SUMMARY`, `LOCATION`, `DESCRIPTION`, `UID`, bounded weekly `RRULE` values, folded lines, escaped text, UTF-8 input, and UTC, numeric-offset, `America/Toronto`, or floating timestamps. Floating times are interpreted in Toronto time and surfaced as review warnings. + +Course meetings are imported only when a full U of T-style course code and meeting section can be identified. Campus remains explicit in the model: calendar/event wording can identify UTM, UTSG, or UTSC, and a small centralized mapping of published UTM building codes can provide UTM evidence. Conflicting or insufficient evidence produces `unknown`, never a course-code guess. + +Repeated imports reconcile by calendar-source identity and event `UID`. Existing matches are updated, unchanged events are not duplicated, and entries from other imports or future manual sources remain untouched. A meeting missing from a newer export is conservatively retained until Gapwise has an explicit removal review. + +Current limitations: + +- recurring events must be weekly, use an interval of one, and be bounded by `COUNT` or `UNTIL`; +- recurrence exceptions (`EXDATE`, `RDATE`, and `RECURRENCE-ID`) and non-weekly recurrence are reported and skipped; +- timezone identifiers other than recognized system zones and `America/Toronto` aliases are not guessed; +- campus/building recognition is intentionally incomplete, with UTM as the initial building-code focus; +- importing is not ACORN login, credential access, scraping, or an account/API integration; +- imported meetings can be reviewed and the whole saved timetable can be removed, but individual editing is not implemented yet. --- @@ -105,7 +125,7 @@ The iOS client follows the same security posture as the wider Gapwise ecosystem: - preserve campus/source identity rather than inventing location certainty; - make permissions narrow, understandable, and revocable. -The current timetable store writes an atomically replaced JSON snapshot inside Application Support and applies iOS file protection. No account sync, end-to-end encryption, analytics, or map capability is implemented. +The current timetable store writes an atomically replaced JSON snapshot inside Application Support and applies iOS file protection. Calendar import is performed on-device without networking, telemetry, or retention of the source file. No account sync, end-to-end encryption, analytics, or map capability is implemented. --- @@ -120,7 +140,7 @@ cd ios Open `Gapwise.xcodeproj` in Xcode 16 or later, select the `Gapwise` scheme, and run it on an iPhone simulator or device. The shared scheme builds the app and runs `GapwiseTests`. -The Foundation-only core and its tests also build on Linux: +The Foundation-only core, import pipeline, and tests also build on Linux: ```bash swift test @@ -128,6 +148,8 @@ swift test GitHub Actions runs those portable tests on Ubuntu and runs the complete unsigned iOS build and unit-test suite on a dynamically selected iPhone simulator on `macos-15`. +Linux cannot compile or exercise SwiftUI, the document picker, Apple security-scoped URL behavior, or the iOS app target. Those remain Xcode/macOS validation responsibilities; Linux coverage is limited to the portable Foundation core and fixture-driven tests. + --- ## Gapwise ecosystem From b7fec54610c9547523b9d8da80b937ac5b03deb7 Mon Sep 17 00:00:00 2001 From: andrewmuratov Date: Tue, 15 Sep 2026 07:26:04 -0400 Subject: [PATCH 03/12] feat: build native iOS foundation --- .github/workflows/ios.yml | 5 + Gapwise/App/AppDependencies.swift | 4 +- Gapwise/App/AppModel.swift | 85 +++- Gapwise/App/RootView.swift | 44 +- Gapwise/Core/Import/CalendarDateParser.swift | 133 +++-- Gapwise/Core/Import/CampusDetector.swift | 17 +- Gapwise/Core/Import/ICalendarParser.swift | 21 +- .../Import/RecurrenceRuleInterpreter.swift | 2 +- .../Core/Import/TimetableImportModels.swift | 16 +- .../Core/Import/TimetableImportService.swift | 2 +- Gapwise/Core/Import/TimetableImporter.swift | 71 ++- Gapwise/Core/Import/TimetableReconciler.swift | 107 ++-- .../Import/UniversityLocationParser.swift | 66 ++- .../Core/Management/TimetableManager.swift | 120 +++++ .../Management/TimetableReviewService.swift | 104 ++++ Gapwise/Core/Models/AcademicTerm.swift | 45 ++ Gapwise/Core/Models/Campus.swift | 11 +- Gapwise/Core/Models/CourseMeeting.swift | 459 ++++++++++++++++-- Gapwise/Core/Models/TimetableSnapshot.swift | 118 ++++- Gapwise/Core/Models/TimetableSource.swift | 63 +++ Gapwise/Core/Models/Weekday.swift | 12 + .../Persistence/JSONTimetableRepository.swift | 27 +- .../Persistence/TimetableRepository.swift | 19 +- Gapwise/Core/Utilities/GapCalculator.swift | 66 +++ .../Core/Utilities/ScheduleCalculator.swift | 2 +- Gapwise/Development/PreviewData.swift | 4 +- Gapwise/Features/Campus/CampusView.swift | 69 --- Gapwise/Features/Gaps/GapsView.swift | 65 +++ Gapwise/Features/Map/CampusMapView.swift | 23 + Gapwise/Features/Settings/SettingsView.swift | 61 +-- .../Timetable/MeetingDetailView.swift | 70 +++ .../Timetable/TimetableAvailabilityView.swift | 30 ++ .../Features/Timetable/TimetableView.swift | 18 +- .../ImportedDocumentReader.swift | 38 +- .../TimetableImportPreviewView.swift | 29 +- Gapwise/Features/Today/TodayView.swift | 14 +- .../Integrations/UTMCampusIntegration.swift | 7 + GapwiseTests/App/AppModelTests.swift | 187 +++++++ .../Core/CampusAndLocationTests.swift | 19 + GapwiseTests/Core/DomainTestSupport.swift | 69 +++ GapwiseTests/Core/Fixtures/mixed-campus.ics | 4 +- GapwiseTests/Core/Fixtures/normal-utm.ics | 4 +- GapwiseTests/Core/GapCalculatorTests.swift | 184 +++++++ GapwiseTests/Core/ICalendarParserTests.swift | 23 + .../Core/RecurrenceRuleInterpreterTests.swift | 4 + .../Core/TimetableImporterTests.swift | 91 +++- .../Core/TimetableModelValidationTests.swift | 115 +++++ .../Core/TimetableReconcilerTests.swift | 54 ++- 48 files changed, 2377 insertions(+), 424 deletions(-) create mode 100644 Gapwise/Core/Management/TimetableManager.swift create mode 100644 Gapwise/Core/Management/TimetableReviewService.swift create mode 100644 Gapwise/Core/Models/TimetableSource.swift create mode 100644 Gapwise/Core/Utilities/GapCalculator.swift delete mode 100644 Gapwise/Features/Campus/CampusView.swift create mode 100644 Gapwise/Features/Gaps/GapsView.swift create mode 100644 Gapwise/Features/Map/CampusMapView.swift create mode 100644 Gapwise/Features/Timetable/MeetingDetailView.swift create mode 100644 Gapwise/Features/Timetable/TimetableAvailabilityView.swift create mode 100644 Gapwise/Integrations/UTMCampusIntegration.swift create mode 100644 GapwiseTests/App/AppModelTests.swift create mode 100644 GapwiseTests/Core/DomainTestSupport.swift create mode 100644 GapwiseTests/Core/GapCalculatorTests.swift create mode 100644 GapwiseTests/Core/TimetableModelValidationTests.swift diff --git a/.github/workflows/ios.yml b/.github/workflows/ios.yml index aa0c47a..4506205 100644 --- a/.github/workflows/ios.yml +++ b/.github/workflows/ios.yml @@ -13,10 +13,15 @@ jobs: portable-core: name: Portable core tests runs-on: ubuntu-24.04 + container: swift:6.3.3-noble steps: - uses: actions/checkout@v4 - name: Show Swift version run: swift --version + - name: Inspect portable package + run: swift package describe + - name: Check whitespace + run: git diff --check - name: Test core run: swift test diff --git a/Gapwise/App/AppDependencies.swift b/Gapwise/App/AppDependencies.swift index 11d038f..978833c 100644 --- a/Gapwise/App/AppDependencies.swift +++ b/Gapwise/App/AppDependencies.swift @@ -9,8 +9,8 @@ enum AppDependencies { do { timetableRepository = try JSONTimetableRepository.applicationSupport() } catch { - timetableRepository = InMemoryTimetableRepository() - startupMessage = "Local storage is temporarily unavailable. Changes made in this session may not persist." + timetableRepository = UnavailableTimetableRepository() + startupMessage = "Local storage is unavailable. Import will be available after storage can be opened." } return AppModel( diff --git a/Gapwise/App/AppModel.swift b/Gapwise/App/AppModel.swift index 43263db..10c69a1 100644 --- a/Gapwise/App/AppModel.swift +++ b/Gapwise/App/AppModel.swift @@ -2,33 +2,54 @@ import Foundation import Observation import SwiftUI +enum AppTab: Hashable { + case today, timetable, gaps, map, settings +} + +enum TimetableLoadState: Equatable { + case notLoaded, loading, ready, failed +} + @MainActor @Observable final class AppModel { private(set) var timetable: TimetableSnapshot + let campusIntegration: UTMCampusIntegration private(set) var preferences: UserPreferences - private(set) var isLoading = false + private(set) var loadState: TimetableLoadState = .notLoaded private(set) var isPreparingImport = false private(set) var isSavingImport = false + private(set) var isChangingTimetable = false private(set) var pendingImportPlan: TimetableImportPlan? var selectedTimetableDate: Date + var selectedTab: AppTab = .today + var isSelectingCalendar = false var alertMessage: String? @ObservationIgnored private let timetableRepository: any TimetableRepository @ObservationIgnored private let preferencesRepository: any PreferencesRepository - @ObservationIgnored private var hasLoaded = false + @ObservationIgnored private let readDocument: @Sendable (URL, Int) async throws -> Data + @ObservationIgnored private let now: @Sendable () -> Date @ObservationIgnored private var preferencesSaveTask: Task? init( timetableRepository: any TimetableRepository, preferencesRepository: any PreferencesRepository, + campusIntegration: UTMCampusIntegration = .notIntegrated, timetable: TimetableSnapshot = .empty, preferences: UserPreferences = .defaults, selectedTimetableDate: Date = .now, - startupMessage: String? = nil + startupMessage: String? = nil, + now: @escaping @Sendable () -> Date = { .now }, + readDocument: @escaping @Sendable (URL, Int) async throws -> Data = { + try await ImportedDocumentReader.read(url: $0, maximumByteCount: $1) + } ) { self.timetableRepository = timetableRepository + self.campusIntegration = campusIntegration self.preferencesRepository = preferencesRepository + self.readDocument = readDocument + self.now = now self.timetable = timetable self.preferences = preferences self.selectedTimetableDate = selectedTimetableDate @@ -43,17 +64,26 @@ final class AppModel { } } + var isLoading: Bool { loadState == .notLoaded || loadState == .loading } + var isBusy: Bool { isLoading || isPreparingImport || isSavingImport || isChangingTimetable } + var canImport: Bool { loadState == .ready && !isBusy && pendingImportPlan == nil } + var canRemoveTimetable: Bool { + !isBusy && pendingImportPlan == nil + && (loadState == .failed || !timetable.meetings.isEmpty || !timetable.sources.isEmpty + || !timetable.suppressedImportedMeetings.isEmpty) + } + func load() async { - guard !hasLoaded else { return } - hasLoaded = true - isLoading = true - defer { isLoading = false } + guard loadState == .notLoaded || loadState == .failed, !isChangingTimetable else { return } + loadState = .loading preferences = await preferencesRepository.load() do { timetable = try await timetableRepository.load() + loadState = .ready } catch { + loadState = .failed alertMessage = "Your saved timetable could not be loaded. The stored data has not been changed." } } @@ -63,37 +93,40 @@ final class AppModel { persistPreferences() } - func setCampusContext(_ campus: Campus) { - preferences.campusContext = campus - persistPreferences() + func requestTimetableImport() { + guard canImport else { return } + isSelectingCalendar = true } func clearTimetable() async { + guard canRemoveTimetable else { return } + isChangingTimetable = true + defer { isChangingTimetable = false } do { try await timetableRepository.clear() timetable = .empty + loadState = .ready } catch { alertMessage = "Your saved timetable could not be removed." } } func prepareTimetableImport(from url: URL) async { - guard !isLoading, !isPreparingImport, !isSavingImport, pendingImportPlan == nil else { return } + guard canImport else { return } isPreparingImport = true defer { isPreparingImport = false } do { - let data = try await ImportedDocumentReader.read( - url: url, - maximumByteCount: TimetableImportService.maximumDocumentSize - ) + let data = try await readDocument(url, TimetableImportService.maximumDocumentSize) let existingSnapshot = timetable let suggestedFileName = url.lastPathComponent + let importedAt = now() pendingImportPlan = try await Task.detached(priority: .userInitiated) { try TimetableImportService().prepareImport( data: data, suggestedFileName: suggestedFileName, - existingSnapshot: existingSnapshot + existingSnapshot: existingSnapshot, + importedAt: importedAt ) }.value } catch { @@ -104,7 +137,7 @@ final class AppModel { } func confirmPendingImport() async { - guard let plan = pendingImportPlan, !isSavingImport else { return } + guard let plan = pendingImportPlan, loadState == .ready, !isBusy else { return } isSavingImport = true defer { isSavingImport = false } @@ -112,11 +145,25 @@ final class AppModel { try await timetableRepository.save(plan.resultingSnapshot) timetable = plan.resultingSnapshot pendingImportPlan = nil + selectedTab = .timetable } catch { alertMessage = "The imported timetable could not be saved. Your existing timetable has not been changed." } } + func removeMeeting(id: MeetingIdentity) async { + guard canImport else { return } + isChangingTimetable = true + defer { isChangingTimetable = false } + do { + let updated = try TimetableManager().removeMeeting(id: id, from: timetable, modifiedAt: now()) + try await timetableRepository.save(updated) + timetable = updated + } catch { + alertMessage = "The meeting could not be removed. Your saved timetable has not been changed." + } + } + func cancelPendingImport() { guard !isSavingImport else { return } pendingImportPlan = nil @@ -129,9 +176,9 @@ final class AppModel { private func persistPreferences() { let preferences = preferences let repository = preferencesRepository - preferencesSaveTask?.cancel() + let previousSave = preferencesSaveTask preferencesSaveTask = Task { - guard !Task.isCancelled else { return } + await previousSave?.value await repository.save(preferences) } } diff --git a/Gapwise/App/RootView.swift b/Gapwise/App/RootView.swift index 99f820f..320198a 100644 --- a/Gapwise/App/RootView.swift +++ b/Gapwise/App/RootView.swift @@ -4,13 +4,16 @@ struct RootView: View { @Environment(AppModel.self) private var appModel var body: some View { - TabView { + @Bindable var appModel = appModel + + TabView(selection: $appModel.selectedTab) { NavigationStack { TodayView() } .tabItem { Label("Today", systemImage: "sun.max") } + .tag(AppTab.today) NavigationStack { TimetableView() @@ -18,13 +21,23 @@ struct RootView: View { .tabItem { Label("Timetable", systemImage: "calendar") } + .tag(AppTab.timetable) NavigationStack { - CampusView() + GapsView() } .tabItem { - Label("Campus", systemImage: "map") + Label("Gaps", systemImage: "hourglass") } + .tag(AppTab.gaps) + + NavigationStack { + CampusMapView() + } + .tabItem { + Label("Map", systemImage: "map") + } + .tag(AppTab.map) NavigationStack { SettingsView() @@ -32,8 +45,33 @@ struct RootView: View { .tabItem { Label("Settings", systemImage: "gearshape") } + .tag(AppTab.settings) } .tint(.gapwiseAccent) + .fileImporter( + isPresented: $appModel.isSelectingCalendar, + allowedContentTypes: [.gapwiseICalendar] + ) { result in + switch result { + case let .success(url): + Task { await appModel.prepareTimetableImport(from: url) } + case let .failure(error): + let cocoaError = error as NSError + guard cocoaError.domain != NSCocoaErrorDomain || cocoaError.code != NSUserCancelledError else { return } + appModel.alertMessage = "The calendar picker could not open the selected file." + } + } + .sheet( + isPresented: Binding( + get: { appModel.pendingImportPlan != nil }, + set: { if !$0 { appModel.cancelPendingImport() } } + ) + ) { + if let plan = appModel.pendingImportPlan { + TimetableImportPreviewView(plan: plan) + .environment(appModel) + } + } .alert( "Gapwise", isPresented: Binding( diff --git a/Gapwise/Core/Import/CalendarDateParser.swift b/Gapwise/Core/Import/CalendarDateParser.swift index 6f2577a..b61b848 100644 --- a/Gapwise/Core/Import/CalendarDateParser.swift +++ b/Gapwise/Core/Import/CalendarDateParser.swift @@ -9,103 +9,82 @@ enum CalendarDateParser { guard valueType == nil || valueType == "DATE" || valueType == "DATE-TIME" else { return .failure(.invalidDate(value)) } - if valueType == "DATE" || (valueType == nil && !value.contains("T") && value.count == 8) { - guard parameters["TZID"] == nil else { return .failure(.invalidDate(value)) } - guard let date = localDate(value) else { return .failure(.invalidDate(value)) } - return .success(.date(date)) + if valueType == "DATE" || (valueType == nil && value.count == 8) { + guard parameters["TZID"] == nil, let components = dateComponents(value), + let date = validatedDate(components, timeZone: .gmt) else { + return .failure(.invalidDate(value)) + } + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = .gmt + return .success(.date(LocalDate(date: date, calendar: calendar))) } let timeZoneIdentifier = parameters["TZID"] - guard timeZoneIdentifier == nil || (!value.hasSuffix("Z") && !hasNumericOffset(value)) else { - return .failure(.invalidDate(value)) - } + let isUTC = value.hasSuffix("Z") + guard !isUTC || timeZoneIdentifier == nil else { return .failure(.invalidDate(value)) } let timeZone: TimeZone - let isFloating: Bool - - if value.hasSuffix("Z") { - timeZone = TimeZone(secondsFromGMT: 0) ?? .gmt - isFloating = false + if isUTC { + timeZone = .gmt } else if let timeZoneIdentifier { - guard let resolved = resolveTimeZone(timeZoneIdentifier) else { + guard let resolved = TimeZone(identifier: timeZoneIdentifier) else { return .failure(.unknownTimeZone(timeZoneIdentifier)) } timeZone = resolved - isFloating = false - } else if hasNumericOffset(value) { - timeZone = TimeZone(secondsFromGMT: 0) ?? .gmt - isFloating = false } else { timeZone = Calendar.gapwiseToronto.timeZone - isFloating = true } - - guard let date = dateTime(value, timeZone: timeZone) else { + let localValue = isUTC ? String(value.dropLast()) : value + // Numeric offsets are not an iCalendar DATE-TIME representation (RFC 5545 §3.3.5). + guard let components = dateTimeComponents(localValue), + let instant = validatedDate(components, timeZone: timeZone) else { return .failure(.invalidDate(value)) } - return .success( - .dateTime( - ICalendarDateTime( - instant: date, - timeZoneIdentifier: timeZoneIdentifier, - isFloating: isFloating - ) - ) - ) - } - - private static func localDate(_ value: String) -> LocalDate? { - guard value.count == 8 else { return nil } - let formatter = formatter(format: "yyyyMMdd", timeZone: .gmt) - guard let date = formatter.date(from: value) else { return nil } - var calendar = Calendar(identifier: .gregorian) - calendar.timeZone = .gmt - let components = calendar.dateComponents([.year, .month, .day], from: date) - guard let year = components.year, let month = components.month, let day = components.day else { return nil } - return LocalDate(year: year, month: month, day: day) + return .success(.dateTime(ICalendarDateTime( + instant: instant, + timeZoneIdentifier: timeZoneIdentifier, + isFloating: !isUTC && timeZoneIdentifier == nil + ))) } - private static func dateTime(_ value: String, timeZone: TimeZone) -> Date? { - let formats: [String] - if hasNumericOffset(value) { - formats = ["yyyyMMdd'T'HHmmssZ", "yyyyMMdd'T'HHmmZ"] - } else if value.hasSuffix("Z") { - formats = ["yyyyMMdd'T'HHmmss'Z'", "yyyyMMdd'T'HHmm'Z'"] - } else { - formats = ["yyyyMMdd'T'HHmmss", "yyyyMMdd'T'HHmm"] - } - - for format in formats { - if let date = formatter(format: format, timeZone: timeZone).date(from: value) { - return date - } - } - return nil + private static func dateComponents(_ value: String) -> DateComponents? { + let bytes = Array(value.utf8) + guard bytes.count == 8, bytes.allSatisfy({ (48...57).contains($0) }) else { return nil } + return DateComponents( + year: number(bytes[0..<4]), month: number(bytes[4..<6]), day: number(bytes[6..<8]) + ) } - private static func formatter(format: String, timeZone: TimeZone) -> DateFormatter { - let formatter = DateFormatter() - formatter.locale = Locale(identifier: "en_US_POSIX") - formatter.calendar = Calendar(identifier: .gregorian) - formatter.timeZone = timeZone - formatter.dateFormat = format - formatter.isLenient = false - return formatter + private static func dateTimeComponents(_ value: String) -> DateComponents? { + let bytes = Array(value.utf8) + guard (bytes.count == 15 || bytes.count == 13), bytes[8] == 84, + let prefix = String(bytes: bytes[0..<8], encoding: .utf8), + var components = dateComponents(prefix), + bytes[9...].allSatisfy({ (48...57).contains($0) }) else { return nil } + components.hour = number(bytes[9..<11]) + components.minute = number(bytes[11..<13]) + components.second = bytes.count == 15 ? number(bytes[13..<15]) : 0 + return components } - private static func resolveTimeZone(_ identifier: String) -> TimeZone? { - if let timeZone = TimeZone(identifier: identifier) { - return timeZone - } - if identifier.contains("America/Toronto") { - return TimeZone(identifier: "America/Toronto") - } - return nil + private static func number(_ digits: ArraySlice) -> Int { + digits.reduce(0) { $0 * 10 + Int($1 - 48) } } - private static func hasNumericOffset(_ value: String) -> Bool { - guard value.count >= 5 else { return false } - let suffix = value.suffix(5) - guard suffix.first == "+" || suffix.first == "-" else { return false } - return suffix.dropFirst().allSatisfy(\.isNumber) + private static func validatedDate(_ components: DateComponents, timeZone: TimeZone) -> Date? { + guard let year = components.year, (1...9999).contains(year), + let month = components.month, (1...12).contains(month), + let day = components.day, (1...31).contains(day), + (0...23).contains(components.hour ?? 0), + (0...59).contains(components.minute ?? 0), + (0...59).contains(components.second ?? 0) else { return nil } + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = timeZone + guard let date = calendar.date(from: components) else { return nil } + // Calendar normalizes e.g. February 30 and times inside a DST gap. Reject both. + let actual = calendar.dateComponents([.year, .month, .day, .hour, .minute, .second], from: date) + guard actual.year == year, actual.month == month, actual.day == day, + actual.hour == (components.hour ?? 0), actual.minute == (components.minute ?? 0), + actual.second == (components.second ?? 0) else { return nil } + return date } } diff --git a/Gapwise/Core/Import/CampusDetector.swift b/Gapwise/Core/Import/CampusDetector.swift index 96504a8..2e11ab8 100644 --- a/Gapwise/Core/Import/CampusDetector.swift +++ b/Gapwise/Core/Import/CampusDetector.swift @@ -1,6 +1,7 @@ import Foundation enum CampusDetectionEvidence: Equatable, Sendable { + case courseCodeSuffix case buildingCode(String) case calendarMetadata case conflictingSignals @@ -20,8 +21,22 @@ struct CampusDetector: Sendable { productIdentifier: String?, location: MeetingLocation?, description: String?, - importContext: Campus? = nil + importContext: Campus? = nil, + courseCode: CourseCode? = nil ) -> CampusDetection { + // Gapwise core timetable-types.ts owns this U of T course-code convention. + // A UTM import preference cannot relabel a known non-UTM course. + if let suffix = courseCode?.rawValue.last { + let campus: Campus? + switch suffix { + case "5": campus = .utm + case "1": campus = .utsg + case "3": campus = .utsc + default: campus = nil + } + if let campus { return CampusDetection(campus: campus, evidence: .courseCodeSuffix) } + return CampusDetection(campus: .unknown, evidence: .unresolved) + } if let importContext, importContext != .unknown { return CampusDetection(campus: importContext, evidence: .importContext) } diff --git a/Gapwise/Core/Import/ICalendarParser.swift b/Gapwise/Core/Import/ICalendarParser.swift index 7a144f8..b9e0d0d 100644 --- a/Gapwise/Core/Import/ICalendarParser.swift +++ b/Gapwise/Core/Import/ICalendarParser.swift @@ -1,8 +1,8 @@ import Foundation struct ICalendarParser: Sendable { - static let maximumByteCount = 5 * 1_048_576 - static let maximumEventCount = 10_000 + static let maximumByteCount = 2 * 1_048_576 + static let maximumEventCount = 2_000 private static let maximumComponentDepth = 32 private static let maximumUnfoldedLineByteCount = 65_536 @@ -49,6 +49,8 @@ struct ICalendarParser: Sendable { throw TimetableImportError.malformedCalendar } sawCalendar = true + } else if stack.isEmpty { + throw TimetableImportError.malformedCalendar } else if component == "VEVENT" { guard stack == ["VCALENDAR"], eventBuilder == nil else { throw TimetableImportError.malformedCalendar @@ -81,8 +83,16 @@ struct ICalendarParser: Sendable { if stack == ["VCALENDAR"] { switch line.name { - case "X-WR-CALNAME": calendarName = Self.unescapeText(line.value) - case "PRODID": productIdentifier = line.value.trimmingCharacters(in: .whitespacesAndNewlines) + case "X-WR-CALNAME": + guard calendarName == nil, !line.hasMalformedParameters else { + throw TimetableImportError.malformedCalendar + } + calendarName = Self.unescapeText(line.value) + case "PRODID": + guard productIdentifier == nil, !line.hasMalformedParameters else { + throw TimetableImportError.malformedCalendar + } + productIdentifier = line.value.trimmingCharacters(in: .whitespacesAndNewlines) default: break } } else if stack == ["VCALENDAR", "VEVENT"] { @@ -286,6 +296,9 @@ private struct EventBuilder { case "DTSTART": start = uniqueTemporalValue(current: start, line: line) case "DTEND": end = uniqueTemporalValue(current: end, line: line) case "RRULE": + if line.value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + issues.append(.malformedProperty(line.name)) + } recurrenceRule = uniqueValue(current: recurrenceRule, proposed: line.value, propertyName: line.name) case "EXDATE", "RDATE", "RECURRENCE-ID": unsupportedRecurrenceProperties.insert(line.name) case "STATUS": diff --git a/Gapwise/Core/Import/RecurrenceRuleInterpreter.swift b/Gapwise/Core/Import/RecurrenceRuleInterpreter.swift index 0f06214..fce1ec0 100644 --- a/Gapwise/Core/Import/RecurrenceRuleInterpreter.swift +++ b/Gapwise/Core/Import/RecurrenceRuleInterpreter.swift @@ -70,7 +70,7 @@ struct RecurrenceRuleInterpreter: Sendable { private func parseDays(_ value: String, rawRule: String) throws -> Set { var days: Set = [] - for token in value.split(separator: ",") { + for token in value.split(separator: ",", omittingEmptySubsequences: false) { let day: Weekday? switch token.uppercased() { case "SU": day = .sunday diff --git a/Gapwise/Core/Import/TimetableImportModels.swift b/Gapwise/Core/Import/TimetableImportModels.swift index 11368af..b8970dc 100644 --- a/Gapwise/Core/Import/TimetableImportModels.swift +++ b/Gapwise/Core/Import/TimetableImportModels.swift @@ -79,6 +79,9 @@ struct TimetableImportWarning: Hashable, Identifiable, Sendable { enum SkippedEventReason: Error, Hashable, Sendable { case allDayEvent + case conflictingUID + case unsupportedCampus + case unsupportedTimeZoneRecurrence case cancelled case invalidTimeRange case malformedDate @@ -92,6 +95,10 @@ enum SkippedEventReason: Error, Hashable, Sendable { var message: String { switch self { case .allDayEvent: "All-day events are not timetable meetings." + case .conflictingUID: "Conflicting events share a UID; the whole series needs review." + case .unsupportedCampus: "This event is outside UTM, the campus currently supported by Gapwise." + case .unsupportedTimeZoneRecurrence: + "Repeating events must use Toronto or floating local time to preserve their time across daylight saving changes." case .cancelled: "A cancelled event was ignored." case .invalidTimeRange: "An event ends before it starts or crosses a day boundary." case .malformedDate: "An event contains a date that could not be read." @@ -119,6 +126,9 @@ struct TimetableImportDraft: Sendable { let skippedEvents: [SkippedCalendarEvent] let ignoredEventCount: Int let totalEventCount: Int + // Rejected UIDs are evidence of unreadable updates, not evidence of deletion. + var retainedEventUIDs: Set = [] + var allowsMissingEventRemoval = true var courseCount: Int { Set(meetings.map { ImportedCourseIdentity(campus: $0.campus, courseCode: $0.courseCode) }).count @@ -142,7 +152,10 @@ struct TimetableImportChanges: Equatable, Sendable { let added: Int let updated: Int let unchanged: Int - let retainedFromPreviousImport: Int + let removedFromSource: Int + let suppressed: Int + let unresolved: Int + var retainedForReview: Int = 0 } struct TimetableImportPlan: Identifiable, Sendable { @@ -150,5 +163,6 @@ struct TimetableImportPlan: Identifiable, Sendable { let draft: TimetableImportDraft let changes: TimetableImportChanges + let previewMeetings: [CourseMeeting] let resultingSnapshot: TimetableSnapshot } diff --git a/Gapwise/Core/Import/TimetableImportService.swift b/Gapwise/Core/Import/TimetableImportService.swift index 4e320dc..e1e9e24 100644 --- a/Gapwise/Core/Import/TimetableImportService.swift +++ b/Gapwise/Core/Import/TimetableImportService.swift @@ -11,6 +11,6 @@ struct TimetableImportService: Sendable { ) throws -> TimetableImportPlan { let document = try ICalendarParser().parse(data) let draft = try TimetableImporter().interpret(document, suggestedFileName: suggestedFileName) - return TimetableReconciler().plan(draft: draft, existingSnapshot: existingSnapshot, importedAt: importedAt) + return try TimetableReconciler().plan(draft: draft, existingSnapshot: existingSnapshot, importedAt: importedAt) } } diff --git a/Gapwise/Core/Import/TimetableImporter.swift b/Gapwise/Core/Import/TimetableImporter.swift index 0493c42..823702c 100644 --- a/Gapwise/Core/Import/TimetableImporter.swift +++ b/Gapwise/Core/Import/TimetableImporter.swift @@ -25,11 +25,31 @@ struct TimetableImporter: Sendable { var skippedEvents: [SkippedCalendarEvent] = [] var ignoredEventCount = 0 var seenUIDs: Set = [] + var acceptedOrRemovedUIDs: Set = [] + var allowsMissingEventRemoval = document.calendarName != nil + let eventsByUID = Dictionary(grouping: document.events.compactMap { event in + event.uid.map { ($0, event) } + }, by: { $0.0 }) + let conflictingUIDs = Set(eventsByUID.compactMap { uid, entries in + entries.contains { $0.1 != entries[0].1 } ? uid : nil + }) for (index, event) in document.events.enumerated() { let fallbackIdentifier = "event-\(index + 1)" let eventIdentifier = event.uid ?? fallbackIdentifier + if let uid = event.uid, conflictingUIDs.contains(uid) { + skippedEvents.append(skipped(event, fallbackID: fallbackIdentifier, reason: .conflictingUID)) + continue + } + // A standalone cancellation may omit SUMMARY and timing properties. + if event.status == "CANCELLED", event.issues.isEmpty, + event.unsupportedRecurrenceProperties.isEmpty, let uid = event.uid { + acceptedOrRemovedUIDs.insert(uid) + skippedEvents.append(skipped(event, fallbackID: fallbackIdentifier, reason: .cancelled)) + continue + } + guard let descriptor = courseParser.parse(summary: event.summary, description: event.description) else { if courseParser.containsCourseCode(summary: event.summary, description: event.description) { skippedEvents.append( @@ -41,11 +61,6 @@ struct TimetableImporter: Sendable { continue } - guard event.status != "CANCELLED" else { - skippedEvents.append(skipped(event, fallbackID: fallbackIdentifier, reason: .cancelled)) - continue - } - guard event.issues.allSatisfy({ !$0.blocksImport }) else { skippedEvents.append(skipped(event, fallbackID: fallbackIdentifier, reason: .malformedRequiredProperty)) continue @@ -55,6 +70,7 @@ struct TimetableImporter: Sendable { continue } guard let uid = event.uid else { + allowsMissingEventRemoval = false skippedEvents.append(skipped(event, fallbackID: fallbackIdentifier, reason: .missingUID)) continue } @@ -73,10 +89,11 @@ struct TimetableImporter: Sendable { let startDate = LocalDate(date: startDateTime.instant, calendar: calendar) let endDate = LocalDate(date: endDateTime.instant, calendar: calendar) - let startComponents = calendar.dateComponents([.hour, .minute], from: startDateTime.instant) - let endComponents = calendar.dateComponents([.hour, .minute], from: endDateTime.instant) + let startComponents = calendar.dateComponents([.hour, .minute, .second], from: startDateTime.instant) + let endComponents = calendar.dateComponents([.hour, .minute, .second], from: endDateTime.instant) guard startDate == endDate, + startComponents.second == 0, endComponents.second == 0, endDateTime.instant > startDateTime.instant, let startTime = LocalTime(hour: startComponents.hour ?? -1, minute: startComponents.minute ?? -1), let endTime = LocalTime(hour: endComponents.hour ?? -1, minute: endComponents.minute ?? -1), @@ -99,6 +116,12 @@ struct TimetableImporter: Sendable { let recurrence: WeeklyRecurrence if let rule = event.recurrenceRule { + guard [startDateTime, endDateTime].allSatisfy({ + $0.isFloating || $0.timeZoneIdentifier == calendar.timeZone.identifier + }) else { + skippedEvents.append(skipped(event, fallbackID: fallbackIdentifier, reason: .unsupportedTimeZoneRecurrence)) + continue + } do { recurrence = try RecurrenceRuleInterpreter(calendar: calendar).interpret( rule, @@ -132,8 +155,14 @@ struct TimetableImporter: Sendable { productIdentifier: document.productIdentifier, location: location, description: event.description, - importContext: campusContext + importContext: campusContext, + courseCode: descriptor.courseCode ).campus + guard campus == .utm || campus == .unknown else { + acceptedOrRemovedUIDs.insert(uid) + skippedEvents.append(skipped(event, fallbackID: fallbackIdentifier, reason: .unsupportedCampus)) + continue + } let term = AcademicTerm( id: .init(rawValue: "ics:\(startDate.iso8601String):\(recurrence.endsOn.iso8601String)"), displayName: "Imported \(startDate.iso8601String) to \(recurrence.endsOn.iso8601String)", @@ -155,9 +184,11 @@ struct TimetableImporter: Sendable { location: location, instructor: descriptor.instructor, sourceIdentifier: uid, - origin: .calendarImport(sourceIdentifier: sourceIdentifier) + origin: .calendarImport(sourceIdentifier: sourceIdentifier), + isReservedAssessmentWindow: isReservedAssessmentWindow(event) ) meetings.append(meeting) + acceptedOrRemovedUIDs.insert(uid) } catch { skippedEvents.append(skipped(event, fallbackID: fallbackIdentifier, reason: .invalidTimeRange)) continue @@ -171,15 +202,26 @@ struct TimetableImporter: Sendable { return TimetableImportDraft( sourceIdentifier: sourceIdentifier, - sourceName: document.calendarName, + sourceName: document.calendarName ?? suggestedFileName, meetings: meetings, warnings: warnings.removingDuplicateIDs(), skippedEvents: skippedEvents, ignoredEventCount: ignoredEventCount, - totalEventCount: document.events.count + totalEventCount: document.events.count, + retainedEventUIDs: Set(document.events.compactMap(\.uid)).subtracting(acceptedOrRemovedUIDs), + allowsMissingEventRemoval: allowsMissingEventRemoval ) } + private func isReservedAssessmentWindow(_ event: ICalendarEvent) -> Bool { + // Exact ACORN evidence convention from Gapwise core ics-parser.ts. + guard let location = event.location?.trimmingCharacters(in: .whitespacesAndNewlines), + location.range(of: #"^ZZ\s+TBA$"#, options: [.regularExpression, .caseInsensitive]) != nil else { return false } + return event.description?.split(separator: "\n", omittingEmptySubsequences: false).contains { + $0.count >= 6 && $0.allSatisfy { $0 == "*" } + } ?? false + } + private func warningsForEvent( _ event: ICalendarEvent, descriptor: ParsedCourseDescriptor, @@ -283,8 +325,9 @@ private enum CalendarImportSourceIdentifier { .compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() } .filter { !$0.isEmpty } .joined(separator: "|") - let fallback = suggestedFileName?.lowercased() ?? "calendar" - return "ics-\(fnv1a64(source.isEmpty ? fallback : source))" + // File-provider renames (e.g. "timetable (2).ics") must not create a new source. + // Metadata-less files share the local UTM import slot; filenames are presentation only. + return "ics-\(fnv1a64(source.isEmpty ? "utm-calendar" : source))" } private static func fnv1a64(_ value: String) -> String { @@ -305,7 +348,7 @@ private extension LocalDate { private extension ICalendarEventIssue { var blocksImport: Bool { - let requiredProperties = ["UID", "SUMMARY", "DTSTART", "DTEND", "RRULE", "STATUS"] + let requiredProperties = ["UID", "SUMMARY", "DTSTART", "DTEND", "RRULE", "STATUS", "EXDATE", "RDATE", "RECURRENCE-ID"] switch self { case let .duplicateProperty(property): return requiredProperties.contains(property) case let .malformedProperty(property): return property.map(requiredProperties.contains) ?? false diff --git a/Gapwise/Core/Import/TimetableReconciler.swift b/Gapwise/Core/Import/TimetableReconciler.swift index b073a1b..b29018c 100644 --- a/Gapwise/Core/Import/TimetableReconciler.swift +++ b/Gapwise/Core/Import/TimetableReconciler.swift @@ -1,54 +1,74 @@ import Foundation struct TimetableReconciler: Sendable { + // Only an exact source + UID match is reconciled. A changed UID remains a new meeting, + // avoiding destructive guesses based on mutable course labels, rooms, or times. func plan( draft: TimetableImportDraft, existingSnapshot: TimetableSnapshot, importedAt: Date - ) -> TimetableImportPlan { - var meetings = existingSnapshot.meetings - var indexesByKey: [ImportedMeetingKey: Int] = [:] - - for (index, meeting) in meetings.enumerated() { - guard let key = ImportedMeetingKey(meeting) else { continue } - if indexesByKey[key] == nil { - indexesByKey[key] = index - } + ) throws -> TimetableImportPlan { + let existingSourceMeetings = existingSnapshot.meetings.filter { + $0.origin.kind == .calendarImport && $0.origin.sourceIdentifier == draft.sourceIdentifier + } + let meetingsFromOtherSources = existingSnapshot.meetings.filter { + $0.origin.kind != .calendarImport || $0.origin.sourceIdentifier != draft.sourceIdentifier + } + let existingByKey = existingSourceMeetings.reduce(into: [ImportedMeetingIdentity: CourseMeeting]()) { + result, meeting in + guard let key = ImportedMeetingIdentity(meeting), result[key] == nil else { return } + result[key] = meeting } var added = 0 var updated = 0 var unchanged = 0 - var incomingKeys: Set = [] + var suppressed = 0 + var incomingKeys: Set = [] + var previewMeetings: [CourseMeeting] = [] + + for incoming in draft.meetings { + guard let key = ImportedMeetingIdentity(incoming) else { continue } + guard incomingKeys.insert(key).inserted else { continue } - for meeting in draft.meetings { - guard let key = ImportedMeetingKey(meeting) else { continue } - incomingKeys.insert(key) + if existingSnapshot.suppressedImportedMeetings.contains(key) { + suppressed += 1 + continue + } - if let existingIndex = indexesByKey[key] { - if meetings[existingIndex] == meeting { + if let existing = existingByKey[key] { + let merged = try existing.mergingImportedSource(incoming) + previewMeetings.append(merged) + if existing.hasSameImportedSource(as: incoming) { unchanged += 1 } else { - meetings[existingIndex] = meeting updated += 1 } } else { - indexesByKey[key] = meetings.count - meetings.append(meeting) + previewMeetings.append(incoming) added += 1 } } - let retained = meetings.lazy.filter { meeting in - guard - meeting.origin.kind == .calendarImport, - meeting.origin.sourceIdentifier == draft.sourceIdentifier, - let key = ImportedMeetingKey(meeting) - else { - return false - } - return !incomingKeys.contains(key) + let retainedMeetings = existingSourceMeetings.filter { meeting in + guard let key = ImportedMeetingIdentity(meeting), !incomingKeys.contains(key) else { return false } + return !draft.allowsMissingEventRemoval || draft.retainedEventUIDs.contains(key.eventUID) + } + let retainedKeys = Set(retainedMeetings.compactMap(ImportedMeetingIdentity.init)) + let removedFromSource = existingByKey.keys.lazy.filter { + !incomingKeys.contains($0) && !retainedKeys.contains($0) }.count + let sources = upsertingSource( + TimetableSource( + id: draft.sourceIdentifier, + kind: .calendarFile, + displayName: draft.sourceName, + lastImportedAt: importedAt + ), + into: existingSnapshot.sources + ) + let resultingMeetings = meetingsFromOtherSources + previewMeetings + retainedMeetings + let unresolved = TimetableReviewService().unresolvedCount(in: previewMeetings) return TimetableImportPlan( draft: draft, @@ -56,25 +76,28 @@ struct TimetableReconciler: Sendable { added: added, updated: updated, unchanged: unchanged, - retainedFromPreviousImport: retained + removedFromSource: removedFromSource, + suppressed: suppressed, + unresolved: unresolved, + retainedForReview: retainedMeetings.count ), - resultingSnapshot: TimetableSnapshot(meetings: meetings, lastModified: importedAt) + previewMeetings: previewMeetings, + resultingSnapshot: TimetableSnapshot( + meetings: resultingMeetings, + sources: sources, + suppressedImportedMeetings: existingSnapshot.suppressedImportedMeetings, + lastModified: importedAt + ) ) } -} - -private struct ImportedMeetingKey: Hashable { - let importSourceIdentifier: String - let eventUID: String - init?(_ meeting: CourseMeeting) { - guard - meeting.origin.kind == .calendarImport, - let sourceIdentifier = meeting.origin.sourceIdentifier - else { - return nil + private func upsertingSource(_ source: TimetableSource, into sources: [TimetableSource]) -> [TimetableSource] { + var updated = sources.filter { $0.id != source.id } + updated.append(source) + return updated.sorted { lhs, rhs in + if lhs.lastImportedAt != rhs.lastImportedAt { return lhs.lastImportedAt > rhs.lastImportedAt } + if lhs.displayName != rhs.displayName { return lhs.displayName < rhs.displayName } + return lhs.id < rhs.id } - importSourceIdentifier = sourceIdentifier - eventUID = meeting.id.sourceIdentifier } } diff --git a/Gapwise/Core/Import/UniversityLocationParser.swift b/Gapwise/Core/Import/UniversityLocationParser.swift index 2640de2..e1bea75 100644 --- a/Gapwise/Core/Import/UniversityLocationParser.swift +++ b/Gapwise/Core/Import/UniversityLocationParser.swift @@ -1,30 +1,50 @@ import Foundation enum UniversityBuildingCatalog { - // This is intentionally a small classroom-oriented subset of codes published by UTM. - // Source: https://www.utm.utoronto.ca/facilities/notices/by-building - // Campus map: https://www.utm.utoronto.ca/future-students/sites/files/future-students/documents/2025-03/MOH25_CampusMap.pdf + // Recognition-only snapshot of Gapwise Data. No geometry, entrance, floor or access claims. + // https://github.com/Gapwise-for-UTM/data/blob/4325a2fa05e54c5ae69355c5fca2d2312485850c/data/utm/building-registry.ts + // Public code aliases come from normalizePublicBuildingCode, not officialCodes evidence. static let utmBuildingNames: [String: String] = [ - "AX": "Academic Annex", - "CC": "Communication, Culture, and Technology Building", - "CCT": "Communication, Culture, and Technology Building", + "MN": "Maanjiwe nendamowinan", "DH": "Deerfield Hall", + "IB": "Instructional Centre", "DV": "William G. Davis Building", - "DW": "Erindale Studio Theatre", - "HB": "Terrence Donnelly Health Sciences Complex", - "HSC": "Terrence Donnelly Health Sciences Complex", + "CCT": "Communication, Culture and Technology Building", "HM": "Hazel McCallion Academic Learning Centre", - "IB": "Instructional Centre", - "KN": "Kaneff Centre and Innovation Complex", - "MN": "Maanjiwe nendamowinan", - "RA": "Recreation, Athletics and Wellness Centre", + "KN": "Kaneff Centre", + "IC": "Innovation Complex", "RAWC": "Recreation, Athletics and Wellness Centre", - "SB": "Science Building", "XR": "Student Centre", + "HB": "Terrence Donnelly Health Sciences Complex", + "AX": "Academic Annex", + "WC": "Alumni House", + "CUP": "Central Utilities Plant", + "DW": "Erindale Studio Theatre", + "FCSH": "Forensic Crime Scene House", + "GF": "Grounds Building", + "NSB": "New Science Building", + "PL": "Paleomagnetism Lab", + "BG": "Research Greenhouse", + "LH": "The Principal's Residence: Lislehurst", + "EH": "Erindale Hall", + "LL": "Leacock Lane", + "MV": "MaGrath Valley", + "MC": "McLuhan Court", + "OPH": "Oscar Peterson Hall", + "PP": "Putnam Place", + "RIH": "Roy Ivor Hall", + "SW": "Schreiberwood", + "NRB": "New Residence Building", ] + private static let publicAliases = ["CC": "CCT", "RA": "RAWC", "R": "LL", "SB": "NSB"] + + static func canonicalCode(_ code: String) -> String? { + let normalized = code.uppercased() + return utmBuildingNames[normalized] != nil ? normalized : publicAliases[normalized] + } static func isKnownUTMCode(_ code: String) -> Bool { - utmBuildingNames[code.uppercased()] != nil + canonicalCode(code) != nil } } @@ -38,7 +58,8 @@ struct UniversityLocationParser: Sendable { if containsAny(normalized, values: ["ONLINE", "REMOTE", "VIRTUAL", "ZOOM", "ASYNCHRONOUS"]) { return MeetingLocation(displayName: "Online", rawLocation: trimmed, kind: .online) } - if containsAny(normalized, values: ["TBA", "TBD", "TO BE ANNOUNCED", "TO BE DETERMINED"]) { + if normalized.hasPrefix("ZZ") || normalized == "N/A" + || containsAny(normalized, values: ["TBA", "TBD", "TO BE ANNOUNCED", "TO BE DETERMINED"]) { return MeetingLocation(displayName: "TBA", rawLocation: trimmed, kind: .toBeAnnounced) } @@ -56,7 +77,8 @@ struct UniversityLocationParser: Sendable { } private func containsAny(_ value: String, values: [String]) -> Bool { - values.contains { value.contains($0) } + let words = Set(value.split(whereSeparator: { !$0.isLetter && !$0.isNumber }).map(String.init)) + return values.contains { $0.contains(" ") ? value.contains($0) : words.contains($0) } } private func buildingAndRoom(in location: String) -> (building: String, room: String)? { @@ -67,13 +89,13 @@ struct UniversityLocationParser: Sendable { let token = letterPrefix.uppercased() let suffix = rawToken.dropFirst(letterPrefix.count) - if (2...4).contains(token.count), suffix.first?.isNumber == true { - return (token, String(suffix)) + if let canonicalCode = UniversityBuildingCatalog.canonicalCode(token), suffix.first?.isNumber == true { + return (canonicalCode, String(suffix)) } guard - rawToken == rawToken.uppercased() || UniversityBuildingCatalog.isKnownUTMCode(token), - (2...4).contains(token.count), + let canonicalCode = UniversityBuildingCatalog.canonicalCode(token), + suffix.isEmpty, index + 1 < rawTokens.count else { continue @@ -81,7 +103,7 @@ struct UniversityLocationParser: Sendable { let nextToken = rawTokens[index + 1].trimmingCharacters(in: .punctuationCharacters) if nextToken.first?.isNumber == true { - return (token, nextToken) + return (canonicalCode, nextToken) } } diff --git a/Gapwise/Core/Management/TimetableManager.swift b/Gapwise/Core/Management/TimetableManager.swift new file mode 100644 index 0000000..ac529a1 --- /dev/null +++ b/Gapwise/Core/Management/TimetableManager.swift @@ -0,0 +1,120 @@ +import Foundation + +enum TimetableManagementError: Error, Equatable, LocalizedError { + case meetingNotFound + case courseNotFound + case sourceNotFound + + var errorDescription: String? { + switch self { + case .meetingNotFound: "That meeting is no longer in your timetable." + case .courseNotFound: "That course is no longer in your timetable." + case .sourceNotFound: "That import source is no longer available." + } + } +} + +struct TimetableManager: Sendable { + func updateMeeting( + id: MeetingIdentity, + with edit: CourseMeetingEdit, + in snapshot: TimetableSnapshot, + modifiedAt: Date + ) throws -> TimetableSnapshot { + try replacingMeeting(id: id, in: snapshot, modifiedAt: modifiedAt) { meeting in + try meeting.applying(edit) + } + } + + func overrideCampus( + id: MeetingIdentity, + campus: Campus, + in snapshot: TimetableSnapshot, + modifiedAt: Date + ) throws -> TimetableSnapshot { + try replacingMeeting(id: id, in: snapshot, modifiedAt: modifiedAt) { meeting in + try meeting.overridingCampus(campus) + } + } + + func acknowledgeMeetingType( + id: MeetingIdentity, + in snapshot: TimetableSnapshot, + modifiedAt: Date + ) throws -> TimetableSnapshot { + try replacingMeeting(id: id, in: snapshot, modifiedAt: modifiedAt) { meeting in + try meeting.acknowledgingMeetingType() + } + } + + func removeMeeting( + id: MeetingIdentity, + from snapshot: TimetableSnapshot, + modifiedAt: Date + ) throws -> TimetableSnapshot { + guard let meeting = snapshot.meetings.first(where: { $0.id == id }) else { + throw TimetableManagementError.meetingNotFound + } + return removing(meetings: [meeting], from: snapshot, modifiedAt: modifiedAt) + } + + func removeCourse( + _ course: CourseIdentity, + from snapshot: TimetableSnapshot, + modifiedAt: Date + ) throws -> TimetableSnapshot { + let matchingMeetings = snapshot.meetings.filter { CourseIdentity($0) == course } + guard !matchingMeetings.isEmpty else { throw TimetableManagementError.courseNotFound } + return removing(meetings: matchingMeetings, from: snapshot, modifiedAt: modifiedAt) + } + + func removeSource( + id: String, + from snapshot: TimetableSnapshot, + modifiedAt: Date + ) throws -> TimetableSnapshot { + let hasSource = snapshot.sources.contains { $0.id == id } + || snapshot.meetings.contains { $0.origin.sourceIdentifier == id } + guard hasSource else { throw TimetableManagementError.sourceNotFound } + + return TimetableSnapshot( + meetings: snapshot.meetings.filter { $0.origin.sourceIdentifier != id }, + sources: snapshot.sources.filter { $0.id != id }, + suppressedImportedMeetings: snapshot.suppressedImportedMeetings.filter { + $0.sourceIdentifier != id + }, + lastModified: modifiedAt + ) + } + + private func replacingMeeting( + id: MeetingIdentity, + in snapshot: TimetableSnapshot, + modifiedAt: Date, + transform: (CourseMeeting) throws -> CourseMeeting + ) throws -> TimetableSnapshot { + guard let index = snapshot.meetings.firstIndex(where: { $0.id == id }) else { + throw TimetableManagementError.meetingNotFound + } + var updated = snapshot + let replacement = try transform(updated.meetings[index]) + guard replacement != updated.meetings[index] else { return snapshot } + updated.meetings[index] = replacement + updated.lastModified = modifiedAt + return updated + } + + private func removing( + meetings removedMeetings: [CourseMeeting], + from snapshot: TimetableSnapshot, + modifiedAt: Date + ) -> TimetableSnapshot { + let removedIDs = Set(removedMeetings.map(\.id)) + let suppressions = Set(removedMeetings.compactMap(ImportedMeetingIdentity.init)) + var updated = snapshot + updated.meetings.removeAll { removedIDs.contains($0.id) } + updated.suppressedImportedMeetings.formUnion(suppressions) + updated.lastModified = modifiedAt + return updated + } +} diff --git a/Gapwise/Core/Management/TimetableReviewService.swift b/Gapwise/Core/Management/TimetableReviewService.swift new file mode 100644 index 0000000..cbbc97e --- /dev/null +++ b/Gapwise/Core/Management/TimetableReviewService.swift @@ -0,0 +1,104 @@ +import Foundation + +enum MeetingReviewSeverity: Int, Comparable, Sendable { + case information + case needsCorrection + + static func < (lhs: Self, rhs: Self) -> Bool { + lhs.rawValue < rhs.rawValue + } +} + +enum MeetingReviewIssueKind: Hashable, Sendable { + case missingCourseTitle + case missingLocation + case unknownCampus + case unknownMeetingType + case unrecognizedLocation +} + +struct MeetingReviewIssue: Hashable, Identifiable, Sendable { + var id: MeetingReviewIssueKind { kind } + + let kind: MeetingReviewIssueKind + let severity: MeetingReviewSeverity + let message: String +} + +struct MeetingReviewItem: Identifiable, Sendable { + var id: MeetingIdentity { meeting.id } + var needsCorrection: Bool { issues.contains { $0.severity == .needsCorrection } } + + let meeting: CourseMeeting + let issues: [MeetingReviewIssue] +} + +struct TimetableReviewService: Sendable { + func items(in meetings: [CourseMeeting]) -> [MeetingReviewItem] { + meetings.compactMap { meeting in + let issues = issues(for: meeting) + guard !issues.isEmpty else { return nil } + return MeetingReviewItem(meeting: meeting, issues: issues) + }.sorted { lhs, rhs in + if lhs.needsCorrection != rhs.needsCorrection { return lhs.needsCorrection } + if lhs.meeting.courseCode != rhs.meeting.courseCode { + return lhs.meeting.courseCode < rhs.meeting.courseCode + } + if lhs.meeting.startTime != rhs.meeting.startTime { + return lhs.meeting.startTime < rhs.meeting.startTime + } + return lhs.meeting.id < rhs.meeting.id + } + } + + func unresolvedCount(in meetings: [CourseMeeting]) -> Int { + items(in: meetings).lazy.filter(\.needsCorrection).count + } + + func issues(for meeting: CourseMeeting) -> [MeetingReviewIssue] { + var issues: [MeetingReviewIssue] = [] + + if meeting.campus == .unknown, !meeting.overriddenFields.contains(.campus) { + issues.append( + MeetingReviewIssue( + kind: .unknownCampus, + severity: .needsCorrection, + message: "Campus could not be determined." + )) + } + if meeting.meetingType == .other, !meeting.overriddenFields.contains(.meetingType) { + issues.append( + MeetingReviewIssue( + kind: .unknownMeetingType, + severity: .needsCorrection, + message: "Confirm this meeting type." + )) + } + if meeting.location == nil, !meeting.overriddenFields.contains(.location) { + issues.append( + MeetingReviewIssue( + kind: .missingLocation, + severity: .information, + message: "No location was provided." + )) + } else if meeting.location?.kind == .unknown, !meeting.overriddenFields.contains(.location) { + issues.append( + MeetingReviewIssue( + kind: .unrecognizedLocation, + severity: .information, + message: "Location was preserved but not recognized." + )) + } + if meeting.courseTitle == nil, !meeting.overriddenFields.contains(.courseTitle) { + issues.append( + MeetingReviewIssue( + kind: .missingCourseTitle, + severity: .information, + message: "No course title was provided." + )) + } + // Preserve the explicit field order within each severity. + return issues.filter { $0.severity == .needsCorrection } + + issues.filter { $0.severity == .information } + } +} diff --git a/Gapwise/Core/Models/AcademicTerm.swift b/Gapwise/Core/Models/AcademicTerm.swift index 748a925..a2ad2ec 100644 --- a/Gapwise/Core/Models/AcademicTerm.swift +++ b/Gapwise/Core/Models/AcademicTerm.swift @@ -18,6 +18,29 @@ struct LocalDate: Codable, Hashable, Comparable, Sendable { day = components.day ?? 1 } + var isValid: Bool { + guard (1...9999).contains(year), (1...12).contains(month), day >= 1 else { return false } + let isLeapYear = year.isMultiple(of: 4) && (!year.isMultiple(of: 100) || year.isMultiple(of: 400)) + let monthLengths = [31, isLeapYear ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31] + return day <= monthLengths[month - 1] + } + + private enum CodingKeys: String, CodingKey { case year, month, day } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.init( + year: try container.decode(Int.self, forKey: .year), + month: try container.decode(Int.self, forKey: .month), + day: try container.decode(Int.self, forKey: .day) + ) + guard isValid else { + throw DecodingError.dataCorruptedError( + forKey: .day, in: container, debugDescription: "A local date must be a valid Gregorian date." + ) + } + } + static func < (lhs: Self, rhs: Self) -> Bool { (lhs.year, lhs.month, lhs.day) < (rhs.year, rhs.month, rhs.day) } @@ -53,4 +76,26 @@ struct AcademicTerm: Codable, Hashable, Sendable { let localDate = LocalDate(date: date, calendar: calendar) return startsOn <= localDate && localDate <= endsOn } + + var isValid: Bool { + !id.rawValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + && startsOn.isValid && endsOn.isValid && startsOn <= endsOn + } + + private enum CodingKeys: String, CodingKey { case id, displayName, startsOn, endsOn } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.init( + id: try container.decode(ID.self, forKey: .id), + displayName: try container.decode(String.self, forKey: .displayName), + startsOn: try container.decode(LocalDate.self, forKey: .startsOn), + endsOn: try container.decode(LocalDate.self, forKey: .endsOn) + ) + guard isValid else { + throw DecodingError.dataCorruptedError( + forKey: .endsOn, in: container, debugDescription: "A term needs an identity and an ordered date range." + ) + } + } } diff --git a/Gapwise/Core/Models/Campus.swift b/Gapwise/Core/Models/Campus.swift index 564f874..2df383d 100644 --- a/Gapwise/Core/Models/Campus.swift +++ b/Gapwise/Core/Models/Campus.swift @@ -8,7 +8,9 @@ enum Campus: String, CaseIterable, Codable, Hashable, Identifiable, Sendable { var id: Self { self } - static let selectableCases: [Self] = [.utm, .utsg, .utsc] + // Legacy campus cases remain decodable, but the native product currently supports UTM only. + static let selectableCases: [Self] = [.utm] + static let editableCases: [Self] = [.utm, .unknown] var shortName: String { switch self { @@ -27,4 +29,11 @@ enum Campus: String, CaseIterable, Codable, Hashable, Identifiable, Sendable { case .unknown: "Campus not determined" } } + + var selectionName: String { + switch self { + case .unknown: "Unknown / Unspecified" + default: shortName + } + } } diff --git a/Gapwise/Core/Models/CourseMeeting.swift b/Gapwise/Core/Models/CourseMeeting.swift index 09a3bc5..379dc62 100644 --- a/Gapwise/Core/Models/CourseMeeting.swift +++ b/Gapwise/Core/Models/CourseMeeting.swift @@ -1,11 +1,13 @@ import Foundation -enum MeetingType: String, Codable, CaseIterable, Hashable, Sendable { +enum MeetingType: String, Codable, CaseIterable, Hashable, Identifiable, Sendable { case lecture case tutorial case practical case other + var id: Self { self } + var shortName: String { switch self { case .lecture: "LEC" @@ -14,6 +16,15 @@ enum MeetingType: String, Codable, CaseIterable, Hashable, Sendable { case .other: "Other" } } + + var displayName: String { + switch self { + case .lecture: "Lecture" + case .tutorial: "Tutorial" + case .practical: "Practical / Lab" + case .other: "Other" + } + } } struct CourseCode: RawRepresentable, Codable, Hashable, Comparable, Sendable { @@ -47,7 +58,7 @@ struct CourseCode: RawRepresentable, Codable, Hashable, Comparable, Sendable { } } -struct MeetingIdentity: Codable, Hashable, Sendable { +struct MeetingIdentity: Codable, Hashable, Comparable, Sendable { let campus: Campus let courseCode: CourseCode let termID: AcademicTerm.ID @@ -77,6 +88,15 @@ struct MeetingIdentity: Codable, Hashable, Sendable { self.importSourceIdentifier = importSourceIdentifier } + static func < (lhs: Self, rhs: Self) -> Bool { + lhs.sortComponents.lexicographicallyPrecedes(rhs.sortComponents) + } + + private var sortComponents: [String] { + [campus.rawValue, courseCode.rawValue, termID.rawValue, courseSection ?? "", + meetingType.rawValue, meetingSection, importSourceIdentifier ?? "", sourceIdentifier] + } + private enum CodingKeys: String, CodingKey { case campus case courseCode @@ -165,28 +185,217 @@ struct MeetingOrigin: Codable, Hashable, Sendable { } } -enum CourseMeetingValidationError: Error, Equatable { +enum CourseMeetingValidationError: Error, Equatable, LocalizedError { + case emptyCourseCode case emptyMeetingSection case emptySourceIdentifier case endNotAfterStart case noMeetingDays + case invalidTerm + case inconsistentIdentity + case invalidImportSource + case differentImportedMeeting + + var errorDescription: String? { + switch self { + case .emptyCourseCode: "Enter a course code." + case .emptyMeetingSection: "Enter a meeting section." + case .emptySourceIdentifier: "The meeting has no stable source identity." + case .endNotAfterStart: "End time must be after start time." + case .noMeetingDays: "Select at least one meeting day." + case .invalidTerm: "The meeting has an invalid term date range." + case .inconsistentIdentity: "The saved meeting does not match its source identity." + case .invalidImportSource: "The imported meeting has no valid calendar source identity." + case .differentImportedMeeting: "Only matching imported meetings can be reconciled." + } + } } -struct CourseMeeting: Identifiable, Codable, Hashable, Sendable { - let id: MeetingIdentity +enum MeetingEditableField: String, Codable, CaseIterable, Hashable, Sendable { + case campus + case courseCode + case courseTitle + case meetingType + case meetingSection + case days + case startTime + case endTime + case location +} + +struct MeetingValues: Codable, Hashable, Sendable { + let campus: Campus + let courseCode: CourseCode let courseTitle: String? - let term: AcademicTerm + let meetingType: MeetingType + let meetingSection: String let days: Set let startTime: LocalTime let endTime: LocalTime let location: MeetingLocation? + + init( + campus: Campus, + courseCode: CourseCode, + courseTitle: String?, + meetingType: MeetingType, + meetingSection: String, + days: Set, + startTime: LocalTime, + endTime: LocalTime, + location: MeetingLocation? + ) throws { + let meetingSection = meetingSection.trimmingCharacters(in: .whitespacesAndNewlines) + guard !meetingSection.isEmpty else { throw CourseMeetingValidationError.emptyMeetingSection } + guard !days.isEmpty else { throw CourseMeetingValidationError.noMeetingDays } + guard endTime > startTime else { throw CourseMeetingValidationError.endNotAfterStart } + + self.campus = campus + self.courseCode = courseCode + self.courseTitle = courseTitle?.nilIfBlank + self.meetingType = meetingType + self.meetingSection = meetingSection.uppercased() + self.days = days + self.startTime = startTime + self.endTime = endTime + self.location = location + } + + private enum CodingKeys: String, CodingKey { + case campus + case courseCode + case courseTitle + case meetingType + case meetingSection + case days + case startTime + case endTime + case location + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + try self.init( + campus: container.decode(Campus.self, forKey: .campus), + courseCode: container.decode(CourseCode.self, forKey: .courseCode), + courseTitle: container.decodeIfPresent(String.self, forKey: .courseTitle), + meetingType: container.decode(MeetingType.self, forKey: .meetingType), + meetingSection: container.decode(String.self, forKey: .meetingSection), + days: container.decode(Set.self, forKey: .days), + startTime: container.decode(LocalTime.self, forKey: .startTime), + endTime: container.decode(LocalTime.self, forKey: .endTime), + location: container.decodeIfPresent(MeetingLocation.self, forKey: .location) + ) + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(campus, forKey: .campus) + try container.encode(courseCode, forKey: .courseCode) + try container.encodeIfPresent(courseTitle, forKey: .courseTitle) + try container.encode(meetingType, forKey: .meetingType) + try container.encode(meetingSection, forKey: .meetingSection) + try container.encode(days.sorted { $0.rawValue < $1.rawValue }, forKey: .days) + try container.encode(startTime, forKey: .startTime) + try container.encode(endTime, forKey: .endTime) + try container.encodeIfPresent(location, forKey: .location) + } + + func replacing(_ other: Self, fields: Set) throws -> Self { + try Self( + campus: fields.contains(.campus) ? other.campus : campus, + courseCode: fields.contains(.courseCode) ? other.courseCode : courseCode, + courseTitle: fields.contains(.courseTitle) ? other.courseTitle : courseTitle, + meetingType: fields.contains(.meetingType) ? other.meetingType : meetingType, + meetingSection: fields.contains(.meetingSection) ? other.meetingSection : meetingSection, + days: fields.contains(.days) ? other.days : days, + startTime: fields.contains(.startTime) ? other.startTime : startTime, + endTime: fields.contains(.endTime) ? other.endTime : endTime, + location: fields.contains(.location) ? other.location : location + ) + } + + func fieldsDiffering(from other: Self) -> Set { + var fields: Set = [] + if campus != other.campus { fields.insert(.campus) } + if courseCode != other.courseCode { fields.insert(.courseCode) } + if courseTitle != other.courseTitle { fields.insert(.courseTitle) } + if meetingType != other.meetingType { fields.insert(.meetingType) } + if meetingSection != other.meetingSection { fields.insert(.meetingSection) } + if days != other.days { fields.insert(.days) } + if startTime != other.startTime { fields.insert(.startTime) } + if endTime != other.endTime { fields.insert(.endTime) } + if location != other.location { fields.insert(.location) } + return fields + } +} + +struct MeetingUserOverrides: Codable, Hashable, Sendable { + let values: MeetingValues + let fields: Set + + private enum CodingKeys: String, CodingKey { case values, fields } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(values, forKey: .values) + try container.encode(fields.sorted { $0.rawValue < $1.rawValue }, forKey: .fields) + } +} + +struct CourseMeetingEdit: Hashable, Sendable { + let values: MeetingValues + + init( + campus: Campus, + courseCode: String, + courseTitle: String?, + meetingType: MeetingType, + meetingSection: String, + days: Set, + startTime: LocalTime, + endTime: LocalTime, + location: String? + ) throws { + guard let courseCode = CourseCode(rawValue: courseCode) else { + throw CourseMeetingValidationError.emptyCourseCode + } + values = try MeetingValues( + campus: campus, + courseCode: courseCode, + courseTitle: courseTitle, + meetingType: meetingType, + meetingSection: meetingSection, + days: days, + startTime: startTime, + endTime: endTime, + location: UniversityLocationParser().parse(location) + ) + } +} + +struct CourseMeeting: Identifiable, Codable, Hashable, Sendable { + let id: MeetingIdentity + let term: AcademicTerm let instructor: String? let origin: MeetingOrigin + let isReservedAssessmentWindow: Bool + let sourceValues: MeetingValues + let userOverrides: MeetingUserOverrides? + // Validated once at construction so display never silently discards conflicting overrides. + let effectiveValues: MeetingValues - var campus: Campus { id.campus } - var courseCode: CourseCode { id.courseCode } - var meetingType: MeetingType { id.meetingType } - var meetingSection: String { id.meetingSection } + var campus: Campus { effectiveValues.campus } + var courseCode: CourseCode { effectiveValues.courseCode } + var courseTitle: String? { effectiveValues.courseTitle } + var meetingType: MeetingType { effectiveValues.meetingType } + var meetingSection: String { effectiveValues.meetingSection } + var days: Set { effectiveValues.days } + var startTime: LocalTime { effectiveValues.startTime } + var endTime: LocalTime { effectiveValues.endTime } + var location: MeetingLocation? { effectiveValues.location } + var overriddenFields: Set { userOverrides?.fields ?? [] } + var hasUserOverrides: Bool { !overriddenFields.isEmpty } var displaySection: String { let normalizedSection = meetingSection.uppercased() @@ -211,43 +420,147 @@ struct CourseMeeting: Identifiable, Codable, Hashable, Sendable { location: MeetingLocation? = nil, instructor: String? = nil, sourceIdentifier: String, - origin: MeetingOrigin = .legacy + origin: MeetingOrigin = .legacy, + isReservedAssessmentWindow: Bool = false ) throws { - let courseSection = courseSection?.trimmingCharacters(in: .whitespacesAndNewlines) - let meetingSection = meetingSection.trimmingCharacters(in: .whitespacesAndNewlines) let sourceIdentifier = sourceIdentifier.trimmingCharacters(in: .whitespacesAndNewlines) + guard !sourceIdentifier.isEmpty else { throw CourseMeetingValidationError.emptySourceIdentifier } - guard !meetingSection.isEmpty else { - throw CourseMeetingValidationError.emptyMeetingSection - } - guard !sourceIdentifier.isEmpty else { - throw CourseMeetingValidationError.emptySourceIdentifier - } - guard endTime > startTime else { - throw CourseMeetingValidationError.endNotAfterStart - } - guard !days.isEmpty else { - throw CourseMeetingValidationError.noMeetingDays - } - - id = MeetingIdentity( + let sourceValues = try MeetingValues( campus: campus, courseCode: courseCode, - termID: term.id, - courseSection: courseSection?.isEmpty == false ? courseSection : nil, + courseTitle: courseTitle, meetingType: meetingType, meetingSection: meetingSection, + days: days, + startTime: startTime, + endTime: endTime, + location: location + ) + let courseSection = courseSection?.nilIfBlank + let id = MeetingIdentity( + campus: campus, + courseCode: courseCode, + termID: term.id, + courseSection: courseSection, + meetingType: meetingType, + meetingSection: sourceValues.meetingSection, sourceIdentifier: sourceIdentifier, importSourceIdentifier: origin.sourceIdentifier ) - self.courseTitle = courseTitle?.trimmingCharacters(in: .whitespacesAndNewlines) + try self.init( + id: id, term: term, instructor: instructor?.nilIfBlank, origin: origin, + sourceValues: sourceValues, userOverrides: nil, + isReservedAssessmentWindow: isReservedAssessmentWindow + ) + } + + func applying(_ edit: CourseMeetingEdit) throws -> Self { + let acknowledgedFields = overriddenFields.subtracting(effectiveValues.fieldsDiffering(from: sourceValues)) + let fields = edit.values.fieldsDiffering(from: sourceValues).union(acknowledgedFields) + return try rebuilt(sourceValues: sourceValues, overrideValues: edit.values, overrideFields: fields) + } + + func overridingCampus(_ campus: Campus) throws -> Self { + let current = effectiveValues + let values = try MeetingValues( + campus: campus, + courseCode: current.courseCode, + courseTitle: current.courseTitle, + meetingType: current.meetingType, + meetingSection: current.meetingSection, + days: current.days, + startTime: current.startTime, + endTime: current.endTime, + location: current.location + ) + var fields = overriddenFields + fields.insert(.campus) + return try rebuilt(sourceValues: sourceValues, overrideValues: values, overrideFields: fields) + } + + func acknowledgingMeetingType() throws -> Self { + var fields = overriddenFields + fields.insert(.meetingType) + return try rebuilt(sourceValues: sourceValues, overrideValues: effectiveValues, overrideFields: fields) + } + + func mergingImportedSource(_ incoming: Self) throws -> Self { + guard let identity = ImportedMeetingIdentity(self), identity == ImportedMeetingIdentity(incoming) else { + throw CourseMeetingValidationError.differentImportedMeeting + } + return try Self( + id: incoming.id, + term: incoming.term, + instructor: incoming.instructor, + origin: incoming.origin, + sourceValues: incoming.sourceValues, + userOverrides: userOverrides, + isReservedAssessmentWindow: incoming.isReservedAssessmentWindow + ) + } + + func hasSameImportedSource(as other: Self) -> Bool { + id == other.id + && term == other.term + && instructor == other.instructor + && origin == other.origin + && sourceValues == other.sourceValues + && isReservedAssessmentWindow == other.isReservedAssessmentWindow + } + + private init( + id: MeetingIdentity, + term: AcademicTerm, + instructor: String?, + origin: MeetingOrigin, + sourceValues: MeetingValues, + userOverrides: MeetingUserOverrides?, + isReservedAssessmentWindow: Bool + ) throws { + guard !id.sourceIdentifier.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw CourseMeetingValidationError.emptySourceIdentifier + } + guard term.isValid else { throw CourseMeetingValidationError.invalidTerm } + guard id.termID == term.id, id.campus == sourceValues.campus, + id.courseCode == sourceValues.courseCode, id.meetingType == sourceValues.meetingType, + id.meetingSection == sourceValues.meetingSection + else { + throw CourseMeetingValidationError.inconsistentIdentity + } + if origin.kind == .calendarImport { + guard let source = origin.sourceIdentifier, source.nilIfBlank != nil, + id.importSourceIdentifier == source + else { throw CourseMeetingValidationError.invalidImportSource } + } else if origin.sourceIdentifier != nil || id.importSourceIdentifier != nil { + throw CourseMeetingValidationError.invalidImportSource + } + effectiveValues = try userOverrides.map { + try sourceValues.replacing($0.values, fields: $0.fields) + } ?? sourceValues + self.id = id self.term = term - self.days = days - self.startTime = startTime - self.endTime = endTime - self.location = location - self.instructor = instructor?.trimmingCharacters(in: .whitespacesAndNewlines) + self.instructor = instructor self.origin = origin + self.isReservedAssessmentWindow = isReservedAssessmentWindow + self.sourceValues = sourceValues + self.userOverrides = userOverrides?.fields.isEmpty == false ? userOverrides : nil + } + + private func rebuilt( + sourceValues: MeetingValues, + overrideValues: MeetingValues, + overrideFields: Set + ) throws -> Self { + try Self( + id: id, + term: term, + instructor: instructor, + origin: origin, + sourceValues: sourceValues, + userOverrides: MeetingUserOverrides(values: overrideValues, fields: overrideFields), + isReservedAssessmentWindow: isReservedAssessmentWindow + ) } private enum CodingKeys: String, CodingKey { @@ -260,27 +573,73 @@ struct CourseMeeting: Identifiable, Codable, Hashable, Sendable { case location case instructor case origin + case sourceValues + case userOverrides + case isReservedAssessmentWindow } init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) - let id = try container.decode(MeetingIdentity.self, forKey: .id) + let storedID = try container.decode(MeetingIdentity.self, forKey: .id) + let origin = try container.decodeIfPresent(MeetingOrigin.self, forKey: .origin) ?? .legacy + // The original snapshot format omitted importSourceIdentifier and preserved section case. + let id = MeetingIdentity( + campus: storedID.campus, courseCode: storedID.courseCode, termID: storedID.termID, + courseSection: storedID.courseSection?.nilIfBlank, meetingType: storedID.meetingType, + meetingSection: storedID.meetingSection.trimmingCharacters(in: .whitespacesAndNewlines).uppercased(), + sourceIdentifier: storedID.sourceIdentifier, + importSourceIdentifier: storedID.importSourceIdentifier ?? origin.sourceIdentifier + ) + let sourceValues: MeetingValues + + if let storedSourceValues = try container.decodeIfPresent(MeetingValues.self, forKey: .sourceValues) { + sourceValues = storedSourceValues + } else { + sourceValues = try MeetingValues( + campus: id.campus, + courseCode: id.courseCode, + courseTitle: try container.decodeIfPresent(String.self, forKey: .courseTitle), + meetingType: id.meetingType, + meetingSection: id.meetingSection, + days: try container.decode(Set.self, forKey: .days), + startTime: try container.decode(LocalTime.self, forKey: .startTime), + endTime: try container.decode(LocalTime.self, forKey: .endTime), + location: try container.decodeIfPresent(MeetingLocation.self, forKey: .location) + ) + } try self.init( - campus: id.campus, - courseCode: id.courseCode, - courseTitle: try container.decodeIfPresent(String.self, forKey: .courseTitle), - term: try container.decode(AcademicTerm.self, forKey: .term), - courseSection: id.courseSection, - meetingType: id.meetingType, - meetingSection: id.meetingSection, - days: try container.decode(Set.self, forKey: .days), - startTime: try container.decode(LocalTime.self, forKey: .startTime), - endTime: try container.decode(LocalTime.self, forKey: .endTime), - location: try container.decodeIfPresent(MeetingLocation.self, forKey: .location), - instructor: try container.decodeIfPresent(String.self, forKey: .instructor), - sourceIdentifier: id.sourceIdentifier, - origin: try container.decodeIfPresent(MeetingOrigin.self, forKey: .origin) ?? .legacy + id: id, + term: container.decode(AcademicTerm.self, forKey: .term), + instructor: container.decodeIfPresent(String.self, forKey: .instructor), + origin: origin, + sourceValues: sourceValues, + userOverrides: container.decodeIfPresent(MeetingUserOverrides.self, forKey: .userOverrides), + isReservedAssessmentWindow: container.decodeIfPresent(Bool.self, forKey: .isReservedAssessmentWindow) ?? false ) } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + let effective = effectiveValues + try container.encode(id, forKey: .id) + try container.encodeIfPresent(effective.courseTitle, forKey: .courseTitle) + try container.encode(term, forKey: .term) + try container.encode(effective.days.sorted { $0.rawValue < $1.rawValue }, forKey: .days) + try container.encode(effective.startTime, forKey: .startTime) + try container.encode(effective.endTime, forKey: .endTime) + try container.encodeIfPresent(effective.location, forKey: .location) + try container.encodeIfPresent(instructor, forKey: .instructor) + try container.encode(origin, forKey: .origin) + try container.encode(sourceValues, forKey: .sourceValues) + try container.encodeIfPresent(userOverrides, forKey: .userOverrides) + try container.encode(isReservedAssessmentWindow, forKey: .isReservedAssessmentWindow) + } +} + +private extension String { + var nilIfBlank: String? { + let trimmed = trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } } diff --git a/Gapwise/Core/Models/TimetableSnapshot.swift b/Gapwise/Core/Models/TimetableSnapshot.swift index 7677d2a..dfd8aa6 100644 --- a/Gapwise/Core/Models/TimetableSnapshot.swift +++ b/Gapwise/Core/Models/TimetableSnapshot.swift @@ -1,14 +1,128 @@ import Foundation struct TimetableSnapshot: Codable, Equatable, Sendable { - static let empty = TimetableSnapshot(meetings: [], lastModified: nil) + static let currentSchemaVersion = 2 + static let empty = TimetableSnapshot() var meetings: [CourseMeeting] + var sources: [TimetableSource] + var suppressedImportedMeetings: Set var lastModified: Date? + let schemaVersion: Int - init(meetings: [CourseMeeting], lastModified: Date? = nil) { + init( + meetings: [CourseMeeting] = [], + sources: [TimetableSource] = [], + suppressedImportedMeetings: Set = [], + lastModified: Date? = nil + ) { self.meetings = meetings + self.sources = sources + self.suppressedImportedMeetings = suppressedImportedMeetings self.lastModified = lastModified + schemaVersion = Self.currentSchemaVersion + } + + var courseCount: Int { + Set(meetings.map(CourseIdentity.init)).count + } + + var campuses: Set { + Set(meetings.map(\.campus)) + } + + private enum CodingKeys: String, CodingKey { + case meetings + case sources + case suppressedImportedMeetings + case lastModified + case schemaVersion + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let storedVersion = try container.decodeIfPresent(Int.self, forKey: .schemaVersion) ?? 1 + guard (1...Self.currentSchemaVersion).contains(storedVersion) else { + throw TimetableSnapshotError.unsupportedSchemaVersion(storedVersion) + } + let meetings = try container.decode([CourseMeeting].self, forKey: .meetings) + let lastModified = try container.decodeIfPresent(Date.self, forKey: .lastModified) + let storedSources = try container.decodeIfPresent([TimetableSource].self, forKey: .sources) + + self.meetings = meetings + sources = storedSources ?? Self.migratedSources(from: meetings, lastModified: lastModified) + suppressedImportedMeetings = + try container.decodeIfPresent(Set.self, forKey: .suppressedImportedMeetings) ?? [] + self.lastModified = lastModified + schemaVersion = Self.currentSchemaVersion + try validate() + } + + func encode(to encoder: Encoder) throws { + try validate() + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(meetings, forKey: .meetings) + try container.encode(sources, forKey: .sources) + try container.encode(suppressedImportedMeetings.sorted { + if $0.sourceIdentifier != $1.sourceIdentifier { return $0.sourceIdentifier < $1.sourceIdentifier } + return $0.eventUID < $1.eventUID + }, forKey: .suppressedImportedMeetings) + try container.encodeIfPresent(lastModified, forKey: .lastModified) + try container.encode(schemaVersion, forKey: .schemaVersion) + } + + func validate() throws { + guard Set(meetings.map(\.id)).count == meetings.count else { + throw TimetableSnapshotError.duplicateMeeting + } + let importedIdentities = meetings.compactMap(ImportedMeetingIdentity.init) + guard Set(importedIdentities).count == importedIdentities.count else { + throw TimetableSnapshotError.duplicateMeeting + } + guard Set(sources.map(\.id)).count == sources.count else { + throw TimetableSnapshotError.duplicateSource + } + guard sources.allSatisfy({ !$0.id.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }), + suppressedImportedMeetings.allSatisfy({ + !$0.sourceIdentifier.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + && !$0.eventUID.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + }) + else { throw TimetableSnapshotError.invalidSourceIdentity } + guard Set(importedIdentities).isDisjoint(with: suppressedImportedMeetings) else { + throw TimetableSnapshotError.suppressedMeetingPresent + } + } + + private static func migratedSources(from meetings: [CourseMeeting], lastModified: Date?) -> [TimetableSource] { + let identifiers = Set(meetings.compactMap { meeting -> String? in + guard meeting.origin.kind == .calendarImport else { return nil } + return meeting.origin.sourceIdentifier + }) + return identifiers.sorted().map { + TimetableSource( + id: $0, + kind: .calendarFile, + displayName: nil, + lastImportedAt: lastModified ?? .distantPast + ) + } + } +} + +enum TimetableSnapshotError: Error, Equatable, LocalizedError { + case unsupportedSchemaVersion(Int) + case duplicateMeeting + case duplicateSource + case invalidSourceIdentity + case suppressedMeetingPresent + + var errorDescription: String? { + switch self { + case .unsupportedSchemaVersion: + "This timetable was saved in an unsupported format. Its file has been preserved." + case .duplicateMeeting, .duplicateSource, .invalidSourceIdentity, .suppressedMeetingPresent: + "The saved timetable contains inconsistent records. Its file has been preserved." + } } } diff --git a/Gapwise/Core/Models/TimetableSource.swift b/Gapwise/Core/Models/TimetableSource.swift new file mode 100644 index 0000000..2ba389d --- /dev/null +++ b/Gapwise/Core/Models/TimetableSource.swift @@ -0,0 +1,63 @@ +import Foundation + +struct ImportedMeetingIdentity: Codable, Hashable, Sendable { + let sourceIdentifier: String + let eventUID: String + + init(sourceIdentifier: String, eventUID: String) { + self.sourceIdentifier = sourceIdentifier + self.eventUID = eventUID + } + + init?(_ meeting: CourseMeeting) { + guard + meeting.origin.kind == .calendarImport, + let sourceIdentifier = meeting.origin.sourceIdentifier + else { + return nil + } + self.init(sourceIdentifier: sourceIdentifier, eventUID: meeting.id.sourceIdentifier) + } +} + +enum TimetableSourceKind: String, Codable, Hashable, Sendable { + case calendarFile + + var displayName: String { + switch self { + case .calendarFile: "Calendar (.ics)" + } + } +} + +struct TimetableSource: Codable, Hashable, Identifiable, Sendable { + let id: String + let kind: TimetableSourceKind + let displayName: String + let lastImportedAt: Date + + init(id: String, kind: TimetableSourceKind, displayName: String?, lastImportedAt: Date) { + self.id = id + self.kind = kind + self.displayName = displayName?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty ?? "Calendar" + self.lastImportedAt = lastImportedAt + } +} + +struct CourseIdentity: Hashable, Sendable { + let campus: Campus + let courseCode: CourseCode + let termID: AcademicTerm.ID + + init(_ meeting: CourseMeeting) { + campus = meeting.campus + courseCode = meeting.courseCode + termID = meeting.term.id + } +} + +private extension String { + var nilIfEmpty: String? { + isEmpty ? nil : self + } +} diff --git a/Gapwise/Core/Models/Weekday.swift b/Gapwise/Core/Models/Weekday.swift index 45881a7..acca48d 100644 --- a/Gapwise/Core/Models/Weekday.swift +++ b/Gapwise/Core/Models/Weekday.swift @@ -23,6 +23,18 @@ enum Weekday: Int, CaseIterable, Codable, Hashable, Sendable { } } + var fullName: String { + switch self { + case .sunday: "Sunday" + case .monday: "Monday" + case .tuesday: "Tuesday" + case .wednesday: "Wednesday" + case .thursday: "Thursday" + case .friday: "Friday" + case .saturday: "Saturday" + } + } + init?(date: Date, calendar: Calendar) { self.init(rawValue: calendar.component(.weekday, from: date)) } diff --git a/Gapwise/Core/Persistence/JSONTimetableRepository.swift b/Gapwise/Core/Persistence/JSONTimetableRepository.swift index c4352e5..60cbf21 100644 --- a/Gapwise/Core/Persistence/JSONTimetableRepository.swift +++ b/Gapwise/Core/Persistence/JSONTimetableRepository.swift @@ -35,29 +35,34 @@ actor JSONTimetableRepository: TimetableRepository { } func load() async throws -> TimetableSnapshot { - guard fileManager.fileExists(atPath: fileURL.path) else { return .empty } - let data = try Data(contentsOf: fileURL) - return try decoder.decode(TimetableSnapshot.self, from: data) + do { + let data = try Data(contentsOf: fileURL) + return try decoder.decode(TimetableSnapshot.self, from: data) + } catch CocoaError.fileReadNoSuchFile { + return .empty + } } func save(_ snapshot: TimetableSnapshot) async throws { + let data = try encoder.encode(snapshot) try fileManager.createDirectory( at: fileURL.deletingLastPathComponent(), withIntermediateDirectories: true ) - let data = try encoder.encode(snapshot) - try data.write(to: fileURL, options: .atomic) - #if os(iOS) - try fileManager.setAttributes( - [.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication], - ofItemAtPath: fileURL.path + try data.write( + to: fileURL, options: [.atomic, .completeFileProtectionUntilFirstUserAuthentication] ) + #else + try data.write(to: fileURL, options: .atomic) #endif } func clear() async throws { - guard fileManager.fileExists(atPath: fileURL.path) else { return } - try fileManager.removeItem(at: fileURL) + do { + try fileManager.removeItem(at: fileURL) + } catch CocoaError.fileNoSuchFile { + return + } } } diff --git a/Gapwise/Core/Persistence/TimetableRepository.swift b/Gapwise/Core/Persistence/TimetableRepository.swift index 5a1a5bb..a068fa4 100644 --- a/Gapwise/Core/Persistence/TimetableRepository.swift +++ b/Gapwise/Core/Persistence/TimetableRepository.swift @@ -6,6 +6,23 @@ protocol TimetableRepository: Sendable { func clear() async throws } -enum TimetableRepositoryError: Error { +enum TimetableRepositoryError: Error, Equatable, LocalizedError { case applicationSupportUnavailable + + var errorDescription: String? { + "Local timetable storage is unavailable. Try opening the app again." + } +} + +// Fail visibly when the persistent location cannot be created; never report an in-memory save as durable. +struct UnavailableTimetableRepository: TimetableRepository { + let error: TimetableRepositoryError + + init(error: TimetableRepositoryError = .applicationSupportUnavailable) { + self.error = error + } + + func load() async throws -> TimetableSnapshot { throw error } + func save(_ snapshot: TimetableSnapshot) async throws { throw error } + func clear() async throws { throw error } } diff --git a/Gapwise/Core/Utilities/GapCalculator.swift b/Gapwise/Core/Utilities/GapCalculator.swift new file mode 100644 index 0000000..9f72ea2 --- /dev/null +++ b/Gapwise/Core/Utilities/GapCalculator.swift @@ -0,0 +1,66 @@ +import Foundation + +/// A raw interval between scheduled commitments. It contains no travel or activity estimate. +struct TimetableGap: Identifiable, Equatable, Sendable { + struct ID: Hashable, Sendable { + let date: LocalDate + let previousMeetingID: MeetingIdentity + let nextMeetingID: MeetingIdentity + } + + let id: ID + let previous: CourseMeeting + let next: CourseMeeting + + var startTime: LocalTime { previous.endTime } + var endTime: LocalTime { next.startTime } + var durationMinutes: Int { endTime.minutesSinceMidnight - startTime.minutesSinceMidnight } + + fileprivate init(date: LocalDate, previous: CourseMeeting, next: CourseMeeting) { + id = ID(date: date, previousMeetingID: previous.id, nextMeetingID: next.id) + self.previous = previous + self.next = next + } +} + +struct GapCalculator: Sendable { + let calendar: Calendar + + init(calendar: Calendar = .gapwiseToronto) { + self.calendar = calendar + } + + /// Mirrors gapwise/src/lib/gaps.ts at ec6f5ebeeffab8d6c2f663756d9a3f63cd833c83: + /// merge occupied intervals, require five minutes, and exclude reserved assessment windows. + /// Durations are timetable minute-of-day differences, without routing or usability claims. + func gaps(on date: Date, in schedule: [CourseMeeting]) -> [TimetableGap] { + let day = ScheduleCalculator(calendar: calendar).meetings(on: date, in: schedule) + let reserved = day.filter(\.isReservedAssessmentWindow) + let commitments = day.filter { !$0.isReservedAssessmentWindow }.sorted { lhs, rhs in + if lhs.startTime != rhs.startTime { return lhs.startTime < rhs.startTime } + if lhs.endTime != rhs.endTime { return lhs.endTime > rhs.endTime } + return lhs.id < rhs.id + } + guard var previous = commitments.first else { return [] } + + let localDate = LocalDate(date: date, calendar: calendar) + var gaps: [TimetableGap] = [] + + for next in commitments.dropFirst() { + if next.startTime <= previous.endTime { + if next.endTime > previous.endTime { previous = next } + continue + } + + let duration = next.startTime.minutesSinceMidnight - previous.endTime.minutesSinceMidnight + if duration >= 5, + !reserved.contains(where: { $0.startTime < next.startTime && $0.endTime > previous.endTime }) + { + gaps.append(TimetableGap(date: localDate, previous: previous, next: next)) + } + previous = next + } + + return gaps + } +} diff --git a/Gapwise/Core/Utilities/ScheduleCalculator.swift b/Gapwise/Core/Utilities/ScheduleCalculator.swift index b5c630b..cd8f229 100644 --- a/Gapwise/Core/Utilities/ScheduleCalculator.swift +++ b/Gapwise/Core/Utilities/ScheduleCalculator.swift @@ -112,7 +112,7 @@ struct ScheduleCalculator: Sendable { if lhs.endTime != rhs.endTime { return lhs.endTime < rhs.endTime } if lhs.courseCode != rhs.courseCode { return lhs.courseCode < rhs.courseCode } if lhs.campus != rhs.campus { return lhs.campus.rawValue < rhs.campus.rawValue } - return lhs.id.sourceIdentifier < rhs.id.sourceIdentifier + return lhs.id < rhs.id } } diff --git a/Gapwise/Development/PreviewData.swift b/Gapwise/Development/PreviewData.swift index b5b374e..655bbf1 100644 --- a/Gapwise/Development/PreviewData.swift +++ b/Gapwise/Development/PreviewData.swift @@ -43,7 +43,7 @@ import Foundation sourceID: "preview-utm-demo101-lec0101" ), meeting( - campus: .utsg, + campus: .utm, code: "DEMO204", title: "Sample Studio", term: term, @@ -54,7 +54,7 @@ import Foundation start: (13, 0), end: (15, 0), location: "Sample Lab B", - sourceID: "preview-utsg-demo204-pra0201" + sourceID: "preview-utm-demo204-pra0201" ), meeting( campus: .utm, diff --git a/Gapwise/Features/Campus/CampusView.swift b/Gapwise/Features/Campus/CampusView.swift deleted file mode 100644 index a54f7d7..0000000 --- a/Gapwise/Features/Campus/CampusView.swift +++ /dev/null @@ -1,69 +0,0 @@ -import SwiftUI - -struct CampusView: View { - @Environment(AppModel.self) private var appModel - - var body: some View { - ScrollView { - VStack(alignment: .leading, spacing: GapwiseSpacing.spacious) { - Picker( - "Campus context", - selection: Binding( - get: { appModel.preferences.campusContext }, - set: { appModel.setCampusContext($0) } - ) - ) { - ForEach(Campus.selectableCases) { campus in - Text(campus.shortName).tag(campus) - } - } - .pickerStyle(.segmented) - - VStack(alignment: .leading, spacing: GapwiseSpacing.compact) { - Text(appModel.preferences.campusContext.shortName) - .font(.largeTitle.weight(.bold)) - Text(appModel.preferences.campusContext.fullName) - .font(.body) - .foregroundStyle(.secondary) - } - - campusStatus(for: appModel.preferences.campusContext) - } - .padding(GapwiseSpacing.standard) - } - .background(Color(uiColor: .systemGroupedBackground)) - .navigationTitle("Campus") - } - - @ViewBuilder - private func campusStatus(for campus: Campus) -> some View { - if campus == .utm { - VStack(alignment: .leading, spacing: GapwiseSpacing.standard) { - Label("UTM is the first campus-intelligence focus", systemImage: "mappin.and.ellipse") - .font(.headline) - .foregroundStyle(.gapwiseAccent) - Text("Map and routing will arrive after verified campus data is integrated.") - .foregroundStyle(.secondary) - } - .frame(maxWidth: .infinity, alignment: .leading) - .gapwiseCard() - } else { - ContentUnavailableView( - "Campus Intelligence Not Available", - systemImage: "map", - description: Text( - "Timetable support remains campus-aware. The campus layer is currently focused on UTM.") - ) - .frame(maxWidth: .infinity, minHeight: 260) - } - } -} - -#if DEBUG - #Preview { - NavigationStack { - CampusView() - .environment(PreviewData.appModel()) - } - } -#endif diff --git a/Gapwise/Features/Gaps/GapsView.swift b/Gapwise/Features/Gaps/GapsView.swift new file mode 100644 index 0000000..238e1be --- /dev/null +++ b/Gapwise/Features/Gaps/GapsView.swift @@ -0,0 +1,65 @@ +import SwiftUI + +struct GapsView: View { + @Environment(AppModel.self) private var appModel + + private let schedule = ScheduleCalculator() + private let calculator = GapCalculator() + + var body: some View { + @Bindable var appModel = appModel + let date = appModel.selectedTimetableDate + let gaps = calculator.gaps(on: date, in: appModel.timetable.meetings) + + ScrollView { + LazyVStack(alignment: .leading, spacing: GapwiseSpacing.standard) { + DatePicker("Day", selection: $appModel.selectedTimetableDate, displayedComponents: .date) + .environment(\.timeZone, schedule.calendar.timeZone) + .environment(\.calendar, schedule.calendar) + + if appModel.loadState != .ready || appModel.timetable.meetings.isEmpty { + TimetableAvailabilityView() + } else { + Text("Time between classes") + .font(.title2.weight(.bold)) + .accessibilityAddTraits(.isHeader) + Text("Intervals of at least 5 minutes in your saved timetable. Travel time is not calculated.") + .font(.subheadline) + .foregroundStyle(.secondary) + + if gaps.isEmpty { + ContentUnavailableView( + "No Gaps Between Classes", + systemImage: "hourglass", + description: Text("No intervals of at least 5 minutes appear between classes on this day.") + ) + } else { + ForEach(gaps) { gap in + VStack(alignment: .leading, spacing: GapwiseSpacing.compact) { + Text("\(gap.durationMinutes) min") + .font(.title2.weight(.bold)) + Text(timeRange(gap, on: date)) + .font(.headline) + Text("After \(gap.previous.courseCode.rawValue) · Before \(gap.next.courseCode.rawValue)") + .font(.subheadline) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + .gapwiseCard() + .accessibilityElement(children: .combine) + } + } + } + } + .padding(GapwiseSpacing.standard) + } + .background(Color(uiColor: .systemGroupedBackground)) + .navigationTitle("Gaps") + } + + private func timeRange(_ gap: TimetableGap, on date: Date) -> String { + let start = GapwiseFormatters.time(gap.startTime, on: date, calendar: schedule.calendar) + let end = GapwiseFormatters.time(gap.endTime, on: date, calendar: schedule.calendar) + return "\(start)–\(end)" + } +} diff --git a/Gapwise/Features/Map/CampusMapView.swift b/Gapwise/Features/Map/CampusMapView.swift new file mode 100644 index 0000000..4d96078 --- /dev/null +++ b/Gapwise/Features/Map/CampusMapView.swift @@ -0,0 +1,23 @@ +import SwiftUI + +struct CampusMapView: View { + @Environment(AppModel.self) private var appModel + + var body: some View { + ScrollView { + switch appModel.campusIntegration { + case .notIntegrated: + ContentUnavailableView { + Label("UTM Map Unavailable", systemImage: "map") + } description: { + Text("Campus maps and walking directions are not available in this version. Imported classroom locations are shown in Timetable.") + } actions: { + Button("View Timetable") { appModel.selectedTab = .timetable } + } + .padding(.vertical, GapwiseSpacing.spacious) + } + } + .background(Color(uiColor: .systemGroupedBackground)) + .navigationTitle("Map") + } +} diff --git a/Gapwise/Features/Settings/SettingsView.swift b/Gapwise/Features/Settings/SettingsView.swift index eb4436d..5ca222d 100644 --- a/Gapwise/Features/Settings/SettingsView.swift +++ b/Gapwise/Features/Settings/SettingsView.swift @@ -1,10 +1,8 @@ import SwiftUI -import UniformTypeIdentifiers struct SettingsView: View { @Environment(AppModel.self) private var appModel @State private var isConfirmingTimetableRemoval = false - @State private var isSelectingCalendar = false var body: some View { Form { @@ -23,8 +21,13 @@ struct SettingsView: View { } Section("Timetable") { + if appModel.loadState == .failed { + Text("Saved data could not be opened. Retry loading, or remove the saved timetable to start again.") + Button("Retry Loading") { Task { await appModel.load() } } + } + Button { - isSelectingCalendar = true + appModel.requestTimetableImport() } label: { if appModel.isPreparingImport { HStack { @@ -35,7 +38,7 @@ struct SettingsView: View { Label("Import Calendar File", systemImage: "square.and.arrow.down") } } - .disabled(appModel.isLoading || appModel.isPreparingImport || appModel.isSavingImport) + .disabled(!appModel.canImport) LabeledContent("Saved meetings", value: "\(appModel.timetable.meetings.count)") @@ -49,21 +52,13 @@ struct SettingsView: View { Button("Remove Saved Timetable", role: .destructive) { isConfirmingTimetableRemoval = true } - .disabled(appModel.timetable.meetings.isEmpty) + .disabled(!appModel.canRemoveTimetable) } - Section("Campus Context") { - Picker( - "Campus", - selection: Binding( - get: { appModel.preferences.campusContext }, - set: { appModel.setCampusContext($0) } - ) - ) { - ForEach(Campus.selectableCases) { campus in - Text(campus.shortName).tag(campus) - } - } + Section("Campus") { + LabeledContent("Supported campus", value: "UTM") + Text("Class times are shown in Toronto time.") + .foregroundStyle(.secondary) } Section("Privacy") { @@ -105,36 +100,6 @@ struct SettingsView: View { } } .navigationTitle("Settings") - .fileImporter( - isPresented: $isSelectingCalendar, - allowedContentTypes: [.gapwiseICalendar] - ) { result in - switch result { - case let .success(url): - Task { @MainActor in - await appModel.prepareTimetableImport(from: url) - } - case let .failure(error): - let cocoaError = error as NSError - guard cocoaError.domain != NSCocoaErrorDomain || cocoaError.code != NSUserCancelledError else { return } - Task { @MainActor in - appModel.alertMessage = "The calendar picker could not open the selected file." - } - } - } - .sheet( - isPresented: Binding( - get: { appModel.pendingImportPlan != nil }, - set: { isPresented in - if !isPresented { appModel.cancelPendingImport() } - } - ) - ) { - if let plan = appModel.pendingImportPlan { - TimetableImportPreviewView(plan: plan) - .environment(appModel) - } - } .confirmationDialog( "Remove your saved timetable?", isPresented: $isConfirmingTimetableRemoval, @@ -147,7 +112,7 @@ struct SettingsView: View { } Button("Cancel", role: .cancel) {} } message: { - Text("This removes the timetable stored on this device.") + Text("This permanently removes the local timetable, import history, and saved removal choices. The original calendar file is unchanged.") } } } diff --git a/Gapwise/Features/Timetable/MeetingDetailView.swift b/Gapwise/Features/Timetable/MeetingDetailView.swift new file mode 100644 index 0000000..a82715b --- /dev/null +++ b/Gapwise/Features/Timetable/MeetingDetailView.swift @@ -0,0 +1,70 @@ +import SwiftUI + +struct MeetingDetailView: View { + @Environment(AppModel.self) private var appModel + @Environment(\.dismiss) private var dismiss + @State private var isConfirmingRemoval = false + + let meetingID: MeetingIdentity + + private var meeting: CourseMeeting? { + appModel.timetable.meetings.first { $0.id == meetingID } + } + + var body: some View { + List { + if let meeting { + Section("Class") { + Text(meeting.courseCode.rawValue).font(.headline) + if let title = meeting.courseTitle { Text(title) } + LabeledContent("Section", value: meeting.displaySection) + LabeledContent("Campus", value: meeting.campus.shortName) + Text(meeting.location?.displayName ?? "Location not provided") + Text(scheduleSummary(meeting)) + .foregroundStyle(.secondary) + Text("Times are shown in Toronto time.") + .font(.footnote) + .foregroundStyle(.secondary) + } + + let issues = TimetableReviewService().issues(for: meeting) + if !issues.isEmpty { + Section("Source Details to Review") { + ForEach(issues) { issue in + Label(issue.message, systemImage: "info.circle") + } + } + } + + Section { + Button("Remove Meeting", role: .destructive) { isConfirmingRemoval = true } + .disabled(!appModel.canImport) + } footer: { + Text("Removing an imported meeting also keeps it hidden when the same source event is imported again.") + } + } else { + Text("This meeting is no longer in your saved timetable.") + } + } + .navigationTitle("Meeting") + .navigationBarTitleDisplayMode(.inline) + .confirmationDialog( + "Remove this meeting?", isPresented: $isConfirmingRemoval, titleVisibility: .visible + ) { + Button("Remove Meeting", role: .destructive) { + Task { + await appModel.removeMeeting(id: meetingID) + if meeting == nil { dismiss() } + } + } + } + } + + private func scheduleSummary(_ meeting: CourseMeeting) -> String { + let days = Weekday.ordered.filter { meeting.days.contains($0) }.map(\.fullName).joined(separator: ", ") + let date = appModel.selectedTimetableDate + let start = GapwiseFormatters.time(meeting.startTime, on: date, calendar: .gapwiseToronto) + let end = GapwiseFormatters.time(meeting.endTime, on: date, calendar: .gapwiseToronto) + return "\(days) · \(start)–\(end)" + } +} diff --git a/Gapwise/Features/Timetable/TimetableAvailabilityView.swift b/Gapwise/Features/Timetable/TimetableAvailabilityView.swift new file mode 100644 index 0000000..45b4688 --- /dev/null +++ b/Gapwise/Features/Timetable/TimetableAvailabilityView.swift @@ -0,0 +1,30 @@ +import SwiftUI + +/// Shared presentation for unavailable local state; an unreadable schedule is never a free day. +struct TimetableAvailabilityView: View { + @Environment(AppModel.self) private var appModel + + var body: some View { + if appModel.isLoading { + ProgressView("Loading timetable") + .frame(maxWidth: .infinity, minHeight: 220) + } else if appModel.loadState == .failed { + ContentUnavailableView { + Label("Timetable Unavailable", systemImage: "exclamationmark.triangle") + } description: { + Text("Your saved timetable could not be opened. Its stored data has been preserved.") + } actions: { + Button("Retry Loading") { Task { await appModel.load() } } + } + } else { + ContentUnavailableView { + Label("No Saved Timetable", systemImage: "calendar") + } description: { + Text("Import your UTM timetable from an ACORN .ics export to see classes and time between them.") + } actions: { + Button("Import Calendar File") { appModel.requestTimetableImport() } + .disabled(!appModel.canImport) + } + } + } +} diff --git a/Gapwise/Features/Timetable/TimetableView.swift b/Gapwise/Features/Timetable/TimetableView.swift index c674b9e..5ad894a 100644 --- a/Gapwise/Features/Timetable/TimetableView.swift +++ b/Gapwise/Features/Timetable/TimetableView.swift @@ -28,14 +28,18 @@ struct TimetableView: View { .font(.title3.weight(.semibold)) .accessibilityAddTraits(.isHeader) - if appModel.isLoading && appModel.timetable.meetings.isEmpty { - ProgressView("Loading timetable") - .frame(maxWidth: .infinity, minHeight: 180) + if appModel.loadState != .ready || appModel.timetable.meetings.isEmpty { + TimetableAvailabilityView() } else if meetings.isEmpty { emptyState(hasSavedMeetings: !appModel.timetable.meetings.isEmpty) } else { ForEach(meetings) { meeting in - MeetingCard(meeting: meeting, date: selectedDate) + NavigationLink { + MeetingDetailView(meetingID: meeting.id) + } label: { + MeetingCard(meeting: meeting, date: selectedDate) + } + .buttonStyle(.plain) } } } @@ -67,6 +71,12 @@ struct TimetableView: View { Label("Next week", systemImage: "chevron.right") } } + ToolbarItem(placement: .bottomBar) { + Button { appModel.requestTimetableImport() } label: { + Label("Import Calendar", systemImage: "square.and.arrow.down") + } + .disabled(!appModel.canImport) + } } } diff --git a/Gapwise/Features/TimetableImport/ImportedDocumentReader.swift b/Gapwise/Features/TimetableImport/ImportedDocumentReader.swift index 773a901..e69c003 100644 --- a/Gapwise/Features/TimetableImport/ImportedDocumentReader.swift +++ b/Gapwise/Features/TimetableImport/ImportedDocumentReader.swift @@ -28,19 +28,35 @@ enum ImportedDocumentReader { } do { - let values = try url.resourceValues(forKeys: [.fileSizeKey, .isRegularFileKey]) - if values.isRegularFile == false { - throw ImportedDocumentReadError.notAFile - } - if let fileSize = values.fileSize, fileSize > maximumByteCount { - throw ImportedDocumentReadError.tooLarge(maximumBytes: maximumByteCount) - } + // Coordinate with Files/iCloud providers while the security scope is active. + var coordinationError: NSError? + var result: Result? + NSFileCoordinator().coordinate(readingItemAt: url, options: [], error: &coordinationError) { fileURL in + result = Result { + let values = try fileURL.resourceValues(forKeys: [.fileSizeKey, .isRegularFileKey]) + guard values.isRegularFile == true else { throw ImportedDocumentReadError.notAFile } + if let fileSize = values.fileSize, fileSize > maximumByteCount { + throw ImportedDocumentReadError.tooLarge(maximumBytes: maximumByteCount) + } - let data = try Data(contentsOf: url) - guard data.count <= maximumByteCount else { - throw ImportedDocumentReadError.tooLarge(maximumBytes: maximumByteCount) + let handle = try FileHandle(forReadingFrom: fileURL) + defer { try? handle.close() } + // Read at most the limit plus one byte even if the file grows after metadata was read. + var data = Data() + while data.count <= maximumByteCount { + let chunk = try handle.read(upToCount: min(65_536, maximumByteCount + 1 - data.count)) + guard let chunk, !chunk.isEmpty else { break } + data.append(chunk) + } + guard data.count <= maximumByteCount else { + throw ImportedDocumentReadError.tooLarge(maximumBytes: maximumByteCount) + } + return data + } } - return data + if let coordinationError { throw coordinationError } + guard let result else { throw ImportedDocumentReadError.unreadable } + return try result.get() } catch let error as ImportedDocumentReadError { throw error } catch { diff --git a/Gapwise/Features/TimetableImport/TimetableImportPreviewView.swift b/Gapwise/Features/TimetableImport/TimetableImportPreviewView.swift index 5ee0cc5..ada7391 100644 --- a/Gapwise/Features/TimetableImport/TimetableImportPreviewView.swift +++ b/Gapwise/Features/TimetableImport/TimetableImportPreviewView.swift @@ -40,7 +40,7 @@ struct TimetableImportPreviewView: View { ProgressView() .accessibilityLabel("Saving timetable") } else { - Text("Import") + Text("Save Import") .bold() } } @@ -54,7 +54,7 @@ struct TimetableImportPreviewView: View { Section { LabeledContent("Courses", value: "\(plan.draft.courseCount)") LabeledContent("Meetings", value: "\(plan.draft.meetings.count)") - LabeledContent("Campuses", value: campusSummary) + LabeledContent("Campus", value: campusSummary) if plan.draft.unresolvedCampusCount > 0 { Label( @@ -80,24 +80,35 @@ struct TimetableImportPreviewView: View { ChangeRow( title: "Unchanged", count: plan.changes.unchanged, systemImage: "checkmark.circle", tint: .secondary) - if plan.changes.retainedFromPreviousImport > 0 { + if plan.changes.removedFromSource > 0 { ChangeRow( - title: "Previously imported", - count: plan.changes.retainedFromPreviousImport, + title: "Removed from this source", + count: plan.changes.removedFromSource, systemImage: "archivebox", tint: .secondary ) } + if plan.changes.suppressed > 0 { + ChangeRow(title: "Previously removed by you", count: plan.changes.suppressed, + systemImage: "eye.slash", tint: .secondary) + } + if plan.changes.retainedForReview > 0 { + ChangeRow(title: "Kept from previous import", count: plan.changes.retainedForReview, + systemImage: "archivebox", tint: .secondary) + Text("Some source events could not be read safely, or this calendar has no distinct source name. Existing meetings are kept where replacement is uncertain.") + .font(.footnote) + .foregroundStyle(.secondary) + } } footer: { - if plan.changes.retainedFromPreviousImport > 0 { - Text("Meetings missing from this file will remain saved until explicit removal is available.") + if plan.changes.removedFromSource > 0 { + Text("Saving replaces this calendar source. Meetings absent from the updated source will be removed. Other sources are kept.") } } } private var meetingsSection: some View { - Section("Ready to Import") { - ForEach(plan.draft.meetings) { meeting in + Section("After Import") { + ForEach(plan.previewMeetings) { meeting in VStack(alignment: .leading, spacing: GapwiseSpacing.compact) { HStack(alignment: .firstTextBaseline) { Text(meeting.courseCode.rawValue) diff --git a/Gapwise/Features/Today/TodayView.swift b/Gapwise/Features/Today/TodayView.swift index 820322a..500ef6e 100644 --- a/Gapwise/Features/Today/TodayView.swift +++ b/Gapwise/Features/Today/TodayView.swift @@ -23,14 +23,13 @@ struct TodayView: View { .font(.title2.weight(.bold)) .accessibilityAddTraits(.isHeader) - if appModel.isLoading && appModel.timetable.meetings.isEmpty { - ProgressView("Loading today") - .frame(maxWidth: .infinity, minHeight: 220) + if appModel.loadState != .ready || appModel.timetable.meetings.isEmpty { + TimetableAvailabilityView() } else if meetings.isEmpty { ContentUnavailableView( "No Classes Today", systemImage: "sun.max", - description: Text("Your day is clear.") + description: Text("There are no classes in your saved timetable for today.") ) .frame(maxWidth: .infinity, minHeight: 220) } else { @@ -76,16 +75,15 @@ struct TodayView: View { Text(meeting.courseCode.rawValue) .font(.title2.weight(.bold)) if let courseTitle = meeting.courseTitle { - Text(courseTitle) - .font(.subheadline.weight(.medium)) - .lineLimit(2) + Text(courseTitle) + .font(.subheadline.weight(.medium)) } Text(meeting.displaySection) .font(.subheadline) .foregroundStyle(.secondary) } - HStack(spacing: GapwiseSpacing.standard) { + VStack(alignment: .leading, spacing: GapwiseSpacing.compact) { Label( timeRange(for: meeting, on: now), systemImage: "clock" diff --git a/Gapwise/Integrations/UTMCampusIntegration.swift b/Gapwise/Integrations/UTMCampusIntegration.swift new file mode 100644 index 0000000..35b020b --- /dev/null +++ b/Gapwise/Integrations/UTMCampusIntegration.swift @@ -0,0 +1,7 @@ +/// Native integration availability, supplied by app composition. +/// A future adapter must consume pinned Gapwise Data identities/evidence and canonical +/// Gapwise routing contracts. Imported room text never establishes a coordinate, +/// entrance, accessibility claim, or route. See docs/architecture.md. +enum UTMCampusIntegration: Sendable { + case notIntegrated +} diff --git a/GapwiseTests/App/AppModelTests.swift b/GapwiseTests/App/AppModelTests.swift new file mode 100644 index 0000000..2b4f4c9 --- /dev/null +++ b/GapwiseTests/App/AppModelTests.swift @@ -0,0 +1,187 @@ +import Foundation +import XCTest +@testable import Gapwise + +/// These exercise native app state in the Xcode test target; SwiftPM tests cover the portable core. +final class AppModelTests: XCTestCase, @unchecked Sendable { + @MainActor + func testFailedLoadBlocksImportAndCanRetryWithoutReplacingStoredData() async throws { + let repository = ControlledTimetableRepository() + await repository.setLoadFailure(true) + let model = makeModel(repository: repository) + + await model.load() + XCTAssertEqual(model.loadState, .failed) + XCTAssertFalse(model.canImport) + await model.prepareTimetableImport(from: URL(fileURLWithPath: "/unused.ics")) + XCTAssertNil(model.pendingImportPlan) + + await repository.setLoadFailure(false) + await model.load() + XCTAssertEqual(model.loadState, .ready) + XCTAssertTrue(model.canImport) + let saves = await repository.saveCount + XCTAssertEqual(saves, 0) + } + + @MainActor + func testPreviewDoesNotPersistAndBlocksRemovalUntilCancelled() async throws { + let repository = ControlledTimetableRepository() + let model = makeModel(repository: repository) + await model.load() + await model.prepareTimetableImport(from: URL(fileURLWithPath: "/timetable.ics")) + + XCTAssertNotNil(model.pendingImportPlan) + XCTAssertTrue(model.timetable.meetings.isEmpty) + XCTAssertFalse(model.canRemoveTimetable) + await model.clearTimetable() + let clears = await repository.clearCount + XCTAssertEqual(clears, 0) + let saves = await repository.saveCount + XCTAssertEqual(saves, 0) + + model.cancelPendingImport() + XCTAssertNil(model.pendingImportPlan) + XCTAssertTrue(model.canImport) + } + + @MainActor + func testSaveFailureKeepsPreviewAndPreviousStateForRetry() async throws { + let repository = ControlledTimetableRepository() + let model = makeModel(repository: repository) + await model.load() + await model.prepareTimetableImport(from: URL(fileURLWithPath: "/timetable.ics")) + await repository.setSaveFailure(true) + await model.confirmPendingImport() + + XCTAssertNotNil(model.pendingImportPlan) + XCTAssertTrue(model.timetable.meetings.isEmpty) + XCTAssertNotNil(model.alertMessage) + XCTAssertFalse(model.isSavingImport) + + await repository.setSaveFailure(false) + await model.confirmPendingImport() + XCTAssertNil(model.pendingImportPlan) + XCTAssertEqual(model.timetable.meetings.count, 1) + XCTAssertEqual(model.selectedTab, .timetable) + XCTAssertEqual(model.timetable.lastModified, Date(timeIntervalSince1970: 1_800_000_000)) + let saved = try await repository.load() + XCTAssertEqual(saved, model.timetable) + } + + @MainActor + func testImportReadBlocksClearAndSecondImport() async throws { + let repository = ControlledTimetableRepository() + let reader = SuspendedDocumentRead() + let model = AppModel( + timetableRepository: repository, + preferencesRepository: InMemoryPreferencesRepository(), + readDocument: { _, _ in try await reader.read() } + ) + await model.load() + let task = Task { await model.prepareTimetableImport(from: URL(fileURLWithPath: "/first.ics")) } + await reader.waitUntilStarted() + XCTAssertTrue(model.isPreparingImport) + XCTAssertFalse(model.canImport) + await model.clearTimetable() + await model.prepareTimetableImport(from: URL(fileURLWithPath: "/second.ics")) + let clears = await repository.clearCount + XCTAssertEqual(clears, 0) + let reads = await reader.readCount + XCTAssertEqual(reads, 1) + await reader.finish(with: Data(Self.calendar.utf8)) + await task.value + XCTAssertNotNil(model.pendingImportPlan) + XCTAssertFalse(model.isPreparingImport) + } + + @MainActor + func testRemovingImportedMeetingPersistsSuppression() async throws { + let repository = ControlledTimetableRepository() + let model = makeModel(repository: repository) + await model.load() + await model.prepareTimetableImport(from: URL(fileURLWithPath: "/timetable.ics")) + await model.confirmPendingImport() + let id = try XCTUnwrap(model.timetable.meetings.first?.id) + + await model.removeMeeting(id: id) + XCTAssertTrue(model.timetable.meetings.isEmpty) + XCTAssertEqual(model.timetable.suppressedImportedMeetings.count, 1) + await model.prepareTimetableImport(from: URL(fileURLWithPath: "/timetable.ics")) + XCTAssertEqual(model.pendingImportPlan?.changes.suppressed, 1) + } + + @MainActor + private func makeModel(repository: ControlledTimetableRepository) -> AppModel { + AppModel( + timetableRepository: repository, + preferencesRepository: InMemoryPreferencesRepository(), + now: { Date(timeIntervalSince1970: 1_800_000_000) }, + readDocument: { _, _ in Data(Self.calendar.utf8) } + ) + } + + private static let calendar = """ + BEGIN:VCALENDAR + VERSION:2.0 + PRODID:-//Gapwise//Synthetic App Tests//EN + X-WR-CALNAME:UTM Test Timetable + BEGIN:VEVENT + UID:app-test + SUMMARY:CSC108H5 F LEC0101 + DTSTART;TZID=America/Toronto:20260914T090000 + DTEND;TZID=America/Toronto:20260914T100000 + LOCATION:DH 2060 + END:VEVENT + END:VCALENDAR + """ +} + +private enum TestFailure: Error { case storage } + +private actor ControlledTimetableRepository: TimetableRepository { + private var snapshot: TimetableSnapshot = .empty + private var failsLoad = false + private var failsSave = false + private(set) var saveCount = 0 + private(set) var clearCount = 0 + + func setLoadFailure(_ value: Bool) { failsLoad = value } + func setSaveFailure(_ value: Bool) { failsSave = value } + func load() async throws -> TimetableSnapshot { + if failsLoad { throw TestFailure.storage } + return snapshot + } + func save(_ snapshot: TimetableSnapshot) async throws { + if failsSave { throw TestFailure.storage } + saveCount += 1 + self.snapshot = snapshot + } + func clear() async throws { + clearCount += 1 + snapshot = .empty + } +} + +private actor SuspendedDocumentRead { + private var continuation: CheckedContinuation? + private var started: CheckedContinuation? + private(set) var readCount = 0 + + func read() async throws -> Data { + readCount += 1 + return try await withCheckedThrowingContinuation { continuation in + self.continuation = continuation + started?.resume() + started = nil + } + } + func waitUntilStarted() async { + if continuation != nil { return } + await withCheckedContinuation { started = $0 } + } + func finish(with data: Data) { + continuation?.resume(returning: data) + continuation = nil + } +} diff --git a/GapwiseTests/Core/CampusAndLocationTests.swift b/GapwiseTests/Core/CampusAndLocationTests.swift index fe03c5d..5f8fe82 100644 --- a/GapwiseTests/Core/CampusAndLocationTests.swift +++ b/GapwiseTests/Core/CampusAndLocationTests.swift @@ -20,6 +20,25 @@ final class CampusAndLocationTests: XCTestCase, @unchecked Sendable { XCTAssertNil(parser.parse(" ")) } + func testLocationCodesUseCanonicalRecognitionWithoutGuessingUnknownIdentity() { + let parser = UniversityLocationParser() + XCTAssertEqual(parser.parse("CC 1080")?.buildingCode, "CCT") + XCTAssertEqual(parser.parse("SB 101")?.buildingCode, "NSB") + XCTAssertNil(parser.parse("HSC 101")?.buildingCode) + XCTAssertNil(parser.parse("XY 101")?.buildingCode) + XCTAssertEqual(parser.parse("XY 101")?.rawLocation, "XY 101") + XCTAssertEqual(parser.parse("ZZ TBA")?.kind, .toBeAnnounced) + XCTAssertEqual(parser.parse("N/A")?.kind, .toBeAnnounced) + XCTAssertEqual(UniversityBuildingCatalog.utmBuildingNames["KN"], "Kaneff Centre") + XCTAssertEqual(UniversityBuildingCatalog.utmBuildingNames["IC"], "Innovation Complex") + } + + func testCourseCampusSuffixCannotBeOverriddenByUTMImportContext() { + let detection = CampusDetector().detect(calendarName: "UTM", productIdentifier: nil, + location: nil, description: nil, importContext: .utm, courseCode: CourseCode(rawValue: "MAT157Y1")) + XCTAssertEqual(detection, CampusDetection(campus: .utsg, evidence: .courseCodeSuffix)) + } + func testKnownUTMBuildingCodesProvideExplainableCampusEvidence() throws { let location = try XCTUnwrap(UniversityLocationParser().parse("DV 2072")) let detection = CampusDetector().detect( diff --git a/GapwiseTests/Core/DomainTestSupport.swift b/GapwiseTests/Core/DomainTestSupport.swift new file mode 100644 index 0000000..90c62cc --- /dev/null +++ b/GapwiseTests/Core/DomainTestSupport.swift @@ -0,0 +1,69 @@ +import Foundation +import XCTest + +#if canImport(Gapwise) + @testable import Gapwise +#else + @testable import GapwiseCore +#endif + +enum DomainTestSupport { + static let modifiedAt = Date(timeIntervalSince1970: 1_800_000_000) + + static func meeting( + uid: String = "event-one", + source: String? = "acorn-utm", + code: String = "CSC108H5", + title: String? = "Introduction to Computer Programming", + campus: Campus = .utm, + type: MeetingType = .lecture, + days: Set = [.monday, .wednesday], + start: Int = 9, + end: Int = 10, + assessment: Bool = false + ) throws -> CourseMeeting { + try CourseMeeting( + campus: campus, + courseCode: XCTUnwrap(CourseCode(rawValue: code)), + courseTitle: title, + term: AcademicTerm( + id: .init(rawValue: "2026-fall"), displayName: "Fall 2026", + startsOn: LocalDate(year: 2026, month: 9, day: 1), + endsOn: LocalDate(year: 2026, month: 12, day: 31) + ), + meetingType: type, + meetingSection: "0101", + days: days, + startTime: XCTUnwrap(LocalTime(hour: start, minute: 0)), + endTime: XCTUnwrap(LocalTime(hour: end, minute: 0)), + sourceIdentifier: uid, + origin: source.map(MeetingOrigin.calendarImport) ?? .manual, + isReservedAssessmentWindow: assessment + ) + } + + static func edit( + _ meeting: CourseMeeting, + title: String? = nil, + start: Int? = nil, + end: Int? = nil, + code: String? = nil + ) throws -> CourseMeetingEdit { + try CourseMeetingEdit( + campus: meeting.campus, courseCode: code ?? meeting.courseCode.rawValue, + courseTitle: title ?? meeting.courseTitle, + meetingType: meeting.meetingType, meetingSection: meeting.meetingSection, days: meeting.days, + startTime: start.flatMap { LocalTime(hour: $0, minute: 0) } ?? meeting.startTime, + endTime: end.flatMap { LocalTime(hour: $0, minute: 0) } ?? meeting.endTime, + location: meeting.location?.rawLocation + ) + } + + static func object(_ value: T) throws -> [String: Any] { + try XCTUnwrap(JSONSerialization.jsonObject(with: JSONEncoder().encode(value)) as? [String: Any]) + } + + static func decode(_ type: T.Type, object: [String: Any]) throws -> T { + try JSONDecoder().decode(type, from: JSONSerialization.data(withJSONObject: object)) + } +} diff --git a/GapwiseTests/Core/Fixtures/mixed-campus.ics b/GapwiseTests/Core/Fixtures/mixed-campus.ics index a4e9d15..c3a0128 100644 --- a/GapwiseTests/Core/Fixtures/mixed-campus.ics +++ b/GapwiseTests/Core/Fixtures/mixed-campus.ics @@ -15,7 +15,7 @@ UID:mixed-mat157-utsg@example.invalid DTSTART;TZID=America/Toronto:20260914T120000 DTEND;TZID=America/Toronto:20260914T130000 RRULE:FREQ=WEEKLY;BYDAY=MO;COUNT=12 -SUMMARY:MAT157Y5 LEC0101 - Analysis I +SUMMARY:MAT157Y1 LEC0101 - Analysis I LOCATION:St. George campus END:VEVENT BEGIN:VEVENT @@ -31,7 +31,7 @@ UID:mixed-eco101-unknown@example.invalid DTSTART;TZID=America/Toronto:20260916T160000 DTEND;TZID=America/Toronto:20260916T170000 RRULE:FREQ=WEEKLY;BYDAY=WE;COUNT=12 -SUMMARY:ECO101H1 TUT0301 - Principles of Microeconomics +SUMMARY:ECO101H9 TUT0301 - Principles of Microeconomics LOCATION:Room pending END:VEVENT BEGIN:VEVENT diff --git a/GapwiseTests/Core/Fixtures/normal-utm.ics b/GapwiseTests/Core/Fixtures/normal-utm.ics index a1c6754..9c0d9f2 100644 --- a/GapwiseTests/Core/Fixtures/normal-utm.ics +++ b/GapwiseTests/Core/Fixtures/normal-utm.ics @@ -21,8 +21,8 @@ LOCATION:TBA END:VEVENT BEGIN:VEVENT UID:utm-csc108-pra@example.invalid -DTSTART:20260917T140000Z -DTEND:20260917T150000Z +DTSTART;TZID=America/Toronto:20260917T100000 +DTEND;TZID=America/Toronto:20260917T110000 RRULE:FREQ=WEEKLY;BYDAY=TH;UNTIL=20261203T220000Z SUMMARY:CSC108H5 PRA0301 - Introduction to Computer Programming LOCATION:Online synchronous diff --git a/GapwiseTests/Core/GapCalculatorTests.swift b/GapwiseTests/Core/GapCalculatorTests.swift new file mode 100644 index 0000000..ed8d98a --- /dev/null +++ b/GapwiseTests/Core/GapCalculatorTests.swift @@ -0,0 +1,184 @@ +import Foundation +import XCTest + +#if canImport(Gapwise) + @testable import Gapwise +#else + @testable import GapwiseCore +#endif + +final class GapCalculatorTests: XCTestCase { + private let calendar = Calendar.gapwiseToronto + private var calculator: GapCalculator { GapCalculator(calendar: calendar) } + private var monday: Date { date(2026, 9, 14) } + + func testEmptyAndSingleMeetingHaveNoInventedDayEdgeGaps() throws { + XCTAssertTrue(calculator.gaps(on: monday, in: []).isEmpty) + XCTAssertTrue(calculator.gaps(on: monday, in: [try meeting("only", 540, 600)]).isEmpty) + } + + func testReportsRawBoundariesAndDurationWithoutSubtractingTravel() throws { + let first = try meeting("first", 540, 600) + let next = try meeting("next", 660, 720) + let gap = try XCTUnwrap(calculator.gaps(on: monday, in: [first, next]).first) + + XCTAssertEqual(gap.previous, first) + XCTAssertEqual(gap.next, next) + XCTAssertEqual(gap.startTime.minutesSinceMidnight, 600) + XCTAssertEqual(gap.endTime.minutesSinceMidnight, 660) + XCTAssertEqual(gap.durationMinutes, 60) + } + + func testNestedMeetingsDoNotCreateFalseFreeTime() throws { + let outer = try meeting("outer", 540, 720) + let nested = try meeting("nested", 600, 660) + let next = try meeting("next", 780, 840) + let gaps = calculator.gaps(on: monday, in: [outer, nested, next]) + + XCTAssertEqual(gaps.count, 1) + XCTAssertEqual(gaps.first?.previous, outer) + XCTAssertEqual(gaps.first?.durationMinutes, 60) + } + + func testOverlappingChainRetainsLatestOccupiedEndAndItsMeeting() throws { + let first = try meeting("first", 540, 660) + let second = try meeting("second", 600, 720) + let extending = try meeting("extending", 690, 780) + let next = try meeting("next", 810, 840) + let gaps = calculator.gaps(on: monday, in: [first, second, extending, next]) + + XCTAssertEqual(gaps.count, 1) + XCTAssertEqual(gaps.first?.previous, extending) + XCTAssertEqual(gaps.first?.durationMinutes, 30) + } + + func testTouchingAndSubFiveMinuteIntervalsAreNotGaps() throws { + let first = try meeting("first", 540, 600) + let touching = try meeting("touching", 600, 660) + let near = try meeting("near", 664, 720) + let next = try meeting("next", 725, 780) + let gaps = calculator.gaps(on: monday, in: [first, touching, near, next]) + + XCTAssertEqual(gaps.count, 1) + XCTAssertEqual(gaps.first?.previous, near) + XCTAssertEqual(gaps.first?.durationMinutes, 5) + } + + func testPreservesAllSeparateQualifyingGapsInTimeOrder() throws { + let schedule = [ + try meeting("late", 900, 960), + try meeting("first", 540, 600), + try meeting("middle", 720, 780), + ] + let gaps = calculator.gaps(on: monday, in: schedule) + + XCTAssertEqual(gaps.map(\.startTime.minutesSinceMidnight), [600, 780]) + XCTAssertEqual(gaps.map(\.durationMinutes), [120, 120]) + XCTAssertNotEqual(gaps[0].id, gaps[1].id) + } + + func testEveryInputPermutationProducesIdenticalContextsAndIdentities() throws { + let first = try meeting("same-event", 540, 660, source: "a") + let tied = try meeting("same-event", 540, 660, source: "b") + let nested = try meeting("nested", 600, 630) + let next = try meeting("next", 720, 780) + let schedule = [first, tied, nested, next] + let expected = calculator.gaps(on: monday, in: schedule) + + XCTAssertEqual(expected.first?.previous, first) + for input in permutations(schedule) { + XCTAssertEqual(calculator.gaps(on: monday, in: input), expected) + } + } + + func testOnlyRequestedWeekdayAndInclusiveImportedDateRangeParticipate() throws { + let singleDay = term(start: LocalDate(year: 2026, month: 9, day: 14), + end: LocalDate(year: 2026, month: 9, day: 14)) + let first = try meeting("first", 540, 600, term: singleDay) + let next = try meeting("next", 660, 720, term: singleDay) + let tuesday = try meeting("other-day", 600, 660, days: [.tuesday]) + let schedule = [first, next, tuesday] + + XCTAssertEqual(calculator.gaps(on: monday, in: schedule).count, 1) + XCTAssertTrue(calculator.gaps(on: date(2026, 9, 7), in: schedule).isEmpty) + XCTAssertTrue(calculator.gaps(on: date(2026, 9, 21), in: schedule).isEmpty) + XCTAssertTrue(calculator.gaps(on: date(2026, 9, 15), in: schedule).isEmpty) + } + + func testTorontoDayAndGapIdentityRemainStableAcrossDSTAndUTCDateBoundary() throws { + let before = try meeting("first", 540, 600, days: [.sunday]) + let after = try meeting("next", 660, 720, days: [.sunday]) + let schedule = [before, after] + let morning = calculator.gaps(on: date(2026, 11, 1, hour: 0), in: schedule) + // 23:00 Toronto is already November 2 in UTC after the fall clock change. + let evening = calculator.gaps(on: date(2026, 11, 1, hour: 23), in: schedule) + let followingWeek = calculator.gaps(on: date(2026, 11, 8), in: schedule) + + XCTAssertEqual(morning, evening) + XCTAssertEqual(morning.first?.durationMinutes, 60) + XCTAssertEqual(morning.first?.id.date, LocalDate(year: 2026, month: 11, day: 1)) + XCTAssertNotEqual(morning.first?.id, followingWeek.first?.id) + XCTAssertTrue(calculator.gaps(on: date(2026, 11, 2, hour: 0), in: schedule).isEmpty) + } + + func testReservedAssessmentSuppressesWholeCandidateInsteadOfBecomingGapAnchor() throws { + let first = try meeting("first", 540, 600) + let reserved = try meeting("reserved", 660, 720, reserved: true) + let next = try meeting("next", 780, 840) + + XCTAssertTrue(calculator.gaps(on: monday, in: [first, reserved, next]).isEmpty) + XCTAssertTrue(calculator.gaps(on: monday, in: [reserved, next]).isEmpty) + XCTAssertTrue(calculator.gaps(on: monday, in: [first, reserved]).isEmpty) + } + + func testReservedAssessmentBoundaryTouchDoesNotSuppressUnoccupiedGap() throws { + let first = try meeting("first", 540, 600) + let next = try meeting("next", 660, 720) + let before = try meeting("reserved-before", 570, 600, reserved: true) + let after = try meeting("reserved-after", 660, 690, reserved: true) + let gaps = calculator.gaps(on: monday, in: [first, next, before, after]) + + XCTAssertEqual(gaps.count, 1) + XCTAssertEqual(gaps.first?.durationMinutes, 60) + } + + private func meeting( + _ identifier: String, _ start: Int, _ end: Int, + days: Set = [.monday], source: String = "acorn", term: AcademicTerm? = nil, + reserved: Bool = false + ) throws -> CourseMeeting { + try CourseMeeting( + campus: .utm, + courseCode: XCTUnwrap(CourseCode(rawValue: "CSC108H5")), + term: term ?? self.term(), + meetingType: .lecture, + meetingSection: "LEC0101", + days: days, + startTime: XCTUnwrap(LocalTime(hour: start / 60, minute: start % 60)), + endTime: XCTUnwrap(LocalTime(hour: end / 60, minute: end % 60)), + sourceIdentifier: identifier, + origin: .calendarImport(sourceIdentifier: source), + isReservedAssessmentWindow: reserved + ) + } + + private func term( + start: LocalDate = LocalDate(year: 2026, month: 9, day: 1), + end: LocalDate = LocalDate(year: 2026, month: 12, day: 31) + ) -> AcademicTerm { + AcademicTerm(id: .init(rawValue: "2026-fall"), displayName: "Fall 2026", startsOn: start, endsOn: end) + } + + private func date(_ year: Int, _ month: Int, _ day: Int, hour: Int = 12) -> Date { + calendar.date(from: DateComponents(year: year, month: month, day: day, hour: hour))! + } + + private func permutations(_ values: [T]) -> [[T]] { + guard !values.isEmpty else { return [[]] } + return values.indices.flatMap { index in + var rest = values + let first = rest.remove(at: index) + return permutations(rest).map { [first] + $0 } + } + } +} diff --git a/GapwiseTests/Core/ICalendarParserTests.swift b/GapwiseTests/Core/ICalendarParserTests.swift index 13d41e9..cef3284 100644 --- a/GapwiseTests/Core/ICalendarParserTests.swift +++ b/GapwiseTests/Core/ICalendarParserTests.swift @@ -133,6 +133,29 @@ final class ICalendarParserTests: XCTestCase, @unchecked Sendable { } } + func testInvalidDatesAndDSTGapCannotNormalizeIntoDifferentMeetings() { + let invalid = ["20260230T100000", "20260914T240000", "20260914T106000", "20260914T100060", + "20260914T100000junk", "20260914T100000+0400", "20260014T100000", "20260308T023000"] + for value in invalid { + guard case .failure = CalendarDateParser.parse(value: value, parameters: ["TZID": "America/Toronto"]) else { + XCTFail("Expected rejection of \(value)") + continue + } + } + } + + func testTimezoneIdentifiersMustMatchAnActualIdentifier() { + guard case .failure(.unknownTimeZone) = CalendarDateParser.parse( + value: "20260914T100000", parameters: ["TZID": "Bogus/America/Toronto"]) else { + return XCTFail("Substring matches cannot establish timezone identity") + } + } + + func testAmbiguousCalendarMetadataIsRejected() { + let content = "BEGIN:VCALENDAR\nX-WR-CALNAME:first\nX-WR-CALNAME:second\nEND:VCALENDAR" + XCTAssertThrowsError(try ICalendarParser().parse(Data(content.utf8))) + } + private func parsedDate(_ value: String, parameters: [String: String]) throws -> Date { let parsed = CalendarDateParser.parse(value: value, parameters: parameters) switch parsed { diff --git a/GapwiseTests/Core/RecurrenceRuleInterpreterTests.swift b/GapwiseTests/Core/RecurrenceRuleInterpreterTests.swift index 879cb8b..2549dd9 100644 --- a/GapwiseTests/Core/RecurrenceRuleInterpreterTests.swift +++ b/GapwiseTests/Core/RecurrenceRuleInterpreterTests.swift @@ -36,6 +36,10 @@ final class RecurrenceRuleInterpreterTests: XCTestCase, @unchecked Sendable { "FREQ=WEEKLY;BYDAY=MO;BYDAY=TU;COUNT=10", "FREQ=DAILY;COUNT=10", "FREQ=WEEKLY;INTERVAL=2;COUNT=10", + "FREQ=WEEKLY;BYDAY=MO,;COUNT=10", + "FREQ=WEEKLY;BYDAY=,MO;COUNT=10", + "FREQ=WEEKLY;BYDAY=MO;COUNT=0", + "FREQ=WEEKLY;BYDAY=MO;UNTIL=20260230", ] for rule in rules { diff --git a/GapwiseTests/Core/TimetableImporterTests.swift b/GapwiseTests/Core/TimetableImporterTests.swift index 0e81726..e76821e 100644 --- a/GapwiseTests/Core/TimetableImporterTests.swift +++ b/GapwiseTests/Core/TimetableImporterTests.swift @@ -50,24 +50,21 @@ final class TimetableImporterTests: XCTestCase, @unchecked Sendable { XCTAssertEqual(nextClass?.timing, .upcoming(minutesUntil: 30)) } - func testCampusSpecificSchedulesRemainCampusQualified() throws { - let utsg = try importFixture("utsg") - let utsc = try importFixture("utsc") - - XCTAssertEqual(utsg.meetings.map(\.campus), [.utsg]) - XCTAssertEqual(utsc.meetings.map(\.campus), [.utsc]) - XCTAssertEqual(try XCTUnwrap(utsc.meetings.first).meetingType, .practical) + func testNonUTMOnlySchedulesProduceNoSupportedEvents() { + for name in ["utsg", "utsc"] { + XCTAssertThrowsError(try importFixture(name)) { error in + XCTAssertEqual(error as? TimetableImportError, .noSupportedEvents) + } + } } - func testMixedCampusImportPreservesIdenticalCourseCodes() throws { + func testMixedCampusImportKeepsUTMAndUnresolvedEventsAndExplainsOtherSkips() throws { let draft = try importFixture("mixed-campus") - let matchingMeetings = draft.meetings.filter { $0.courseCode.rawValue == "MAT157Y5" } - - XCTAssertEqual(draft.meetings.count, 4) + XCTAssertEqual(draft.meetings.count, 2) XCTAssertEqual(draft.ignoredEventCount, 1) XCTAssertEqual(draft.unresolvedCampusCount, 1) - XCTAssertEqual(Set(matchingMeetings.map(\.campus)), [.utm, .utsg]) - XCTAssertEqual(Set(matchingMeetings.map(\.id)).count, 2) + XCTAssertEqual(Set(draft.meetings.map(\.campus)), [.utm, .unknown]) + XCTAssertEqual(draft.skippedEvents.map(\.reason), [.unsupportedCampus, .unsupportedCampus]) } func testMalformedAndUnsupportedEventsAreSkippedWithoutDiscardingValidMeetings() throws { @@ -204,6 +201,74 @@ final class TimetableImporterTests: XCTestCase, @unchecked Sendable { XCTAssertEqual(reasons, [.allDayEvent, .cancelled, .missingUID, .missingEndTime]) } + func testConflictingUIDsNeverChooseAnArbitraryFirstEvent() throws { + let first = event(uid: "conflict", codeAndSection: "MAT157Y5 LEC0101", recurrence: "") + let changed = first.replacingOccurrences(of: "T100000", with: "T103000") + let valid = event(uid: "valid", codeAndSection: "CSC108H5 TUT0101", recurrence: "") + for events in [[first, changed, valid], [changed, valid, first]] { + let document = try ICalendarParser().parse(Data(calendarWithEvents(events).utf8)) + let draft = try TimetableImporter().interpret(document, suggestedFileName: "schedule.ics") + XCTAssertEqual(draft.meetings.map(\.id.sourceIdentifier), ["valid"]) + XCTAssertEqual(draft.skippedEvents.map(\.reason), [.conflictingUID, .conflictingUID]) + XCTAssertEqual(draft.retainedEventUIDs, ["conflict"]) + } + } + + func testDetachedCancelledOccurrenceDoesNotSilentlyImportItsUnmodifiedMaster() throws { + let master = event(uid: "series", codeAndSection: "MAT157Y5 LEC0101", recurrence: "RRULE:FREQ=WEEKLY;COUNT=10") + let exception = "BEGIN:VEVENT\nUID:series\nRECURRENCE-ID:20260921T100000\nSTATUS:CANCELLED\nEND:VEVENT" + let valid = event(uid: "valid", codeAndSection: "CSC108H5 TUT0101", recurrence: "") + let document = try ICalendarParser().parse(Data(calendarWithEvents([master, exception, valid]).utf8)) + let draft = try TimetableImporter().interpret(document, suggestedFileName: nil) + XCTAssertEqual(draft.meetings.map(\.id.sourceIdentifier), ["valid"]) + XCTAssertEqual(draft.retainedEventUIDs, ["series"]) + } + + func testRecurringUTCEventsAreSkippedBecauseTorontoWeeklyTimesCannotRepresentTheirDSTShift() throws { + let utc = event(uid: "utc", codeAndSection: "MAT157Y5 LEC0101", recurrence: "RRULE:FREQ=WEEKLY;COUNT=20") + .replacingOccurrences(of: ";TZID=America/Toronto", with: "") + .replacingOccurrences(of: "T100000", with: "T100000Z") + .replacingOccurrences(of: "T110000", with: "T110000Z") + let valid = event(uid: "valid", codeAndSection: "CSC108H5 TUT0101", recurrence: "") + let document = try ICalendarParser().parse(Data(calendarWithEvents([utc, valid]).utf8)) + let draft = try TimetableImporter().interpret(document, suggestedFileName: nil) + XCTAssertEqual(draft.skippedEvents.map(\.reason), [.unsupportedTimeZoneRecurrence]) + XCTAssertEqual(draft.meetings.count, 1) + } + + func testMalformedExceptionAndEmptyRuleCannotBecomeInventedWeeklyOrSingleMeetings() throws { + let badException = event(uid: "bad-exception", codeAndSection: "MAT157Y5 LEC0101", + recurrence: "RRULE:FREQ=WEEKLY;COUNT=10\nEXDATE;BROKEN:20260921T100000") + let emptyRule = event(uid: "empty-rule", codeAndSection: "MAT157Y5 TUT0101", recurrence: "RRULE:") + let valid = event(uid: "valid", codeAndSection: "CSC108H5 TUT0101", recurrence: "") + let document = try ICalendarParser().parse(Data(calendarWithEvents([badException, emptyRule, valid]).utf8)) + let draft = try TimetableImporter().interpret(document, suggestedFileName: nil) + XCTAssertEqual(draft.meetings.map(\.id.sourceIdentifier), ["valid"]) + XCTAssertEqual(draft.skippedEvents.map(\.reason), [.malformedRequiredProperty, .malformedRequiredProperty]) + } + + func testFileRenameDoesNotChangeMetadataLessSourceIdentity() throws { + let content = calendarWithEvents([event(uid: "same", codeAndSection: "MAT157Y5 LEC0101", recurrence: "")]) + .replacingOccurrences(of: "X-WR-CALNAME:UTM Timetable\n", with: "") + let document = try ICalendarParser().parse(Data(content.utf8)) + let first = try TimetableImporter().interpret(document, suggestedFileName: "schedule.ics") + let second = try TimetableImporter().interpret(document, suggestedFileName: "schedule (2).ics") + XCTAssertEqual(first.sourceIdentifier, second.sourceIdentifier) + XCTAssertFalse(first.allowsMissingEventRemoval) + } + + func testReservedAssessmentWindowRequiresBothCanonicalACORNSignals() throws { + let reserved = event(uid: "reserved", codeAndSection: "MAT157Y5 LEC0101", + recurrence: "DESCRIPTION:Course title\\n********") + .replacingOccurrences(of: "LOCATION:MN 1210", with: "LOCATION:ZZ TBA") + let ordinary = event(uid: "ordinary", codeAndSection: "CSC108H5 TUT0101", recurrence: "") + .replacingOccurrences(of: "LOCATION:MN 1210", with: "LOCATION:ZZ TBA") + let document = try ICalendarParser().parse(Data(calendarWithEvents([reserved, ordinary]).utf8)) + let draft = try TimetableImporter().interpret(document, suggestedFileName: nil) + XCTAssertTrue(try XCTUnwrap(draft.meetings.first { $0.id.sourceIdentifier == "reserved" }).isReservedAssessmentWindow) + XCTAssertFalse(try XCTUnwrap(draft.meetings.first { $0.id.sourceIdentifier == "ordinary" }).isReservedAssessmentWindow) + } + private func importFixture(_ name: String) throws -> TimetableImportDraft { let document = try ICalendarParser().parse(FixtureLoader.data(named: name)) return try TimetableImporter().interpret(document, suggestedFileName: "\(name).ics") diff --git a/GapwiseTests/Core/TimetableModelValidationTests.swift b/GapwiseTests/Core/TimetableModelValidationTests.swift new file mode 100644 index 0000000..8326842 --- /dev/null +++ b/GapwiseTests/Core/TimetableModelValidationTests.swift @@ -0,0 +1,115 @@ +import Foundation +import XCTest + +#if canImport(Gapwise) + @testable import Gapwise +#else + @testable import GapwiseCore +#endif + +final class TimetableModelValidationTests: XCTestCase { + func testInvalidCalendarDatesAreRejectedWithoutNormalization() throws { + for (year, month, day) in [(2026, 2, 29), (2026, 4, 31), (2026, 0, 10), (0, 1, 1)] { + XCTAssertThrowsError(try DomainTestSupport.decode( + LocalDate.self, object: ["year": year, "month": month, "day": day] + )) + } + let leapDay = try DomainTestSupport.decode(LocalDate.self, object: ["year": 2028, "month": 2, "day": 29]) + XCTAssertEqual(leapDay, LocalDate(year: 2028, month: 2, day: 29)) + } + + func testReversedTermAndMismatchedIdentityAreRejected() throws { + let original = try DomainTestSupport.meeting() + var object = try DomainTestSupport.object(original) + var term = try XCTUnwrap(object["term"] as? [String: Any]) + term["endsOn"] = ["year": 2026, "month": 1, "day": 1] + object["term"] = term + XCTAssertThrowsError(try DomainTestSupport.decode(CourseMeeting.self, object: object)) + + object = try DomainTestSupport.object(original) + var identity = try XCTUnwrap(object["id"] as? [String: Any]) + identity["courseCode"] = "MAT102H5" + object["id"] = identity + XCTAssertThrowsError(try DomainTestSupport.decode(CourseMeeting.self, object: object)) { error in + XCTAssertEqual(error as? CourseMeetingValidationError, .inconsistentIdentity) + } + } + + func testSourceIdentityMismatchIsRejected() throws { + var object = try DomainTestSupport.object(DomainTestSupport.meeting()) + var origin = try XCTUnwrap(object["origin"] as? [String: Any]) + origin["sourceIdentifier"] = "different-calendar" + object["origin"] = origin + XCTAssertThrowsError(try DomainTestSupport.decode(CourseMeeting.self, object: object)) { error in + XCTAssertEqual(error as? CourseMeetingValidationError, .invalidImportSource) + } + } + + func testLegacySnapshotMigratesWithoutLosingImportedIdentity() throws { + let meeting = try DomainTestSupport.meeting() + var object = try DomainTestSupport.object(meeting) + object.removeValue(forKey: "sourceValues") + object.removeValue(forKey: "isReservedAssessmentWindow") + var identity = try XCTUnwrap(object["id"] as? [String: Any]) + identity.removeValue(forKey: "importSourceIdentifier") + object["id"] = identity + let migrated = try DomainTestSupport.decode(TimetableSnapshot.self, object: ["meetings": [object]]) + + XCTAssertEqual(migrated.schemaVersion, TimetableSnapshot.currentSchemaVersion) + XCTAssertEqual(migrated.meetings, [meeting]) + XCTAssertEqual(migrated.sources.map(\.id), ["acorn-utm"]) + XCTAssertTrue(migrated.suppressedImportedMeetings.isEmpty) + } + + func testUnsupportedSnapshotSchemasAndMissingRecordsAreRejected() throws { + for version in [0, -1, TimetableSnapshot.currentSchemaVersion + 1] { + XCTAssertThrowsError(try DomainTestSupport.decode( + TimetableSnapshot.self, object: ["schemaVersion": version, "meetings": []] + )) { error in + XCTAssertEqual(error as? TimetableSnapshotError, .unsupportedSchemaVersion(version)) + } + } + XCTAssertThrowsError(try DomainTestSupport.decode(TimetableSnapshot.self, object: [:])) + } + + func testDuplicateUIDsAreRejectedEvenWhenCourseLabelsDiffer() throws { + let first = try DomainTestSupport.meeting() + let second = try DomainTestSupport.meeting(code: "MAT102H5") + let snapshot = TimetableSnapshot(meetings: [first, second]) + XCTAssertThrowsError(try JSONEncoder().encode(snapshot)) { error in + XCTAssertEqual(error as? TimetableSnapshotError, .duplicateMeeting) + } + } + + func testActiveSuppressedMeetingIsRejected() throws { + let meeting = try DomainTestSupport.meeting() + let snapshot = TimetableSnapshot( + meetings: [meeting], suppressedImportedMeetings: [try XCTUnwrap(ImportedMeetingIdentity(meeting))] + ) + XCTAssertThrowsError(try JSONEncoder().encode(snapshot)) { error in + XCTAssertEqual(error as? TimetableSnapshotError, .suppressedMeetingPresent) + } + } + + func testAssessmentSourceFactPersistsAndUpdates() throws { + let original = try DomainTestSupport.meeting() + let incoming = try DomainTestSupport.meeting(assessment: true) + let updated = try original.mergingImportedSource(incoming) + XCTAssertTrue(updated.isReservedAssessmentWindow) + XCTAssertFalse(original.hasSameImportedSource(as: incoming)) + XCTAssertEqual(try JSONDecoder().decode(CourseMeeting.self, from: JSONEncoder().encode(updated)), updated) + } + + func testComparableIdentityIncludesCalendarSource() throws { + let first = try DomainTestSupport.meeting(source: "calendar-a") + let second = try DomainTestSupport.meeting(source: "calendar-b") + XCTAssertEqual([second.id, first.id].sorted(), [first.id, second.id]) + XCTAssertFalse(first.id < first.id) + } + + func testOnlyUTMIsSelectableWhileLegacyCampusesRemainDecodable() throws { + XCTAssertEqual(Campus.selectableCases, [.utm]) + XCTAssertEqual(Campus.editableCases, [.utm, .unknown]) + XCTAssertEqual(try JSONDecoder().decode(Campus.self, from: Data("\"utsg\"".utf8)), .utsg) + } +} diff --git a/GapwiseTests/Core/TimetableReconcilerTests.swift b/GapwiseTests/Core/TimetableReconcilerTests.swift index 15b2d4a..913f756 100644 --- a/GapwiseTests/Core/TimetableReconcilerTests.swift +++ b/GapwiseTests/Core/TimetableReconcilerTests.swift @@ -31,7 +31,7 @@ final class TimetableReconcilerTests: XCTestCase, @unchecked Sendable { XCTAssertEqual(second.resultingSnapshot.meetings.count, 3) } - func testNewerImportUpdatesMatchingUIDAndConservativelyRetainsMissingMeeting() throws { + func testNewerCompleteSourceImportUpdatesMatchingUIDAndRemovesMissingMeeting() throws { let service = TimetableImportService() let first = try service.prepareImport( data: FixtureLoader.data(named: "update-v1"), @@ -47,8 +47,8 @@ final class TimetableReconcilerTests: XCTestCase, @unchecked Sendable { ) XCTAssertEqual(second.changes.updated, 1) - XCTAssertEqual(second.changes.retainedFromPreviousImport, 1) - XCTAssertEqual(second.resultingSnapshot.meetings.count, 2) + XCTAssertEqual(second.changes.removedFromSource, 1) + XCTAssertEqual(second.resultingSnapshot.meetings.count, 1) let updated = try XCTUnwrap( second.resultingSnapshot.meetings.first { $0.id.sourceIdentifier == "update-primary@example.invalid" } @@ -90,6 +90,7 @@ final class TimetableReconcilerTests: XCTestCase, @unchecked Sendable { let encoded = try JSONEncoder().encode(original) var object = try XCTUnwrap(JSONSerialization.jsonObject(with: encoded) as? [String: Any]) object.removeValue(forKey: "origin") + object.removeValue(forKey: "sourceValues") var identifier = try XCTUnwrap(object["id"] as? [String: Any]) identifier.removeValue(forKey: "importSourceIdentifier") object["id"] = identifier @@ -128,6 +129,53 @@ final class TimetableReconcilerTests: XCTestCase, @unchecked Sendable { XCTAssertEqual(loaded, plan.resultingSnapshot) } + func testSkippedUpdateRetainsExistingUIDInsteadOfTreatingItAsRemoved() throws { + let service = TimetableImportService() + let originalData = try FixtureLoader.data(named: "normal-utm") + let first = try service.prepareImport(data: originalData, suggestedFileName: nil, existingSnapshot: .empty) + let brokenText = try XCTUnwrap(String(data: originalData, encoding: .utf8)) + .replacingOccurrences(of: "20260914T100000", with: "20260230T100000") + let second = try service.prepareImport(data: Data(brokenText.utf8), suggestedFileName: nil, + existingSnapshot: first.resultingSnapshot) + XCTAssertEqual(second.resultingSnapshot.meetings.count, 3) + XCTAssertEqual(second.changes.retainedForReview, 1) + XCTAssertEqual(second.changes.removedFromSource, 0) + } + + func testSuppressedUIDDoesNotReappearOnImport() throws { + let service = TimetableImportService() + let data = try FixtureLoader.data(named: "normal-utm") + let first = try service.prepareImport(data: data, suggestedFileName: nil, existingSnapshot: .empty) + let suppressed = try XCTUnwrap(first.resultingSnapshot.meetings.first) + var snapshot = first.resultingSnapshot + snapshot.meetings.removeAll { $0.id == suppressed.id } + snapshot.suppressedImportedMeetings.insert(try XCTUnwrap(ImportedMeetingIdentity(suppressed))) + let repeated = try service.prepareImport(data: data, suggestedFileName: nil, existingSnapshot: snapshot) + XCTAssertEqual(repeated.resultingSnapshot.meetings.count, 2) + XCTAssertEqual(repeated.changes.suppressed, 1) + XCTAssertFalse(repeated.resultingSnapshot.meetings.contains { $0.id == suppressed.id }) + } + + func testSourceRefreshPreservesExplicitLocalEdits() throws { + let service = TimetableImportService() + let first = try service.prepareImport(data: FixtureLoader.data(named: "update-v1"), + suggestedFileName: nil, existingSnapshot: .empty) + var snapshot = first.resultingSnapshot + let index = try XCTUnwrap(snapshot.meetings.firstIndex { $0.id.sourceIdentifier == "update-primary@example.invalid" }) + let original = snapshot.meetings[index] + let edit = try CourseMeetingEdit(campus: original.campus, courseCode: original.courseCode.rawValue, + courseTitle: original.courseTitle, meetingType: original.meetingType, meetingSection: original.meetingSection, + days: original.days, startTime: original.startTime, endTime: original.endTime, location: "IB 120") + snapshot.meetings[index] = try original.applying(edit) + let updated = try service.prepareImport(data: FixtureLoader.data(named: "update-v2"), + suggestedFileName: nil, existingSnapshot: snapshot) + let meeting = try XCTUnwrap(updated.resultingSnapshot.meetings.first) + XCTAssertEqual(meeting.location?.rawLocation, "IB 120") + XCTAssertEqual(meeting.sourceValues.location?.room, "2110") + XCTAssertEqual(meeting.startTime, LocalTime(hour: 10, minute: 30)) + XCTAssertEqual(meeting.overriddenFields, [.location]) + } + private func makeMeeting(sourceIdentifier: String, origin: MeetingOrigin) throws -> CourseMeeting { try CourseMeeting( campus: .utm, From 59d5c8da2c2b75464a479816c392b90fe257715b Mon Sep 17 00:00:00 2001 From: Andrew Muratov Date: Tue, 15 Sep 2026 07:28:08 -0400 Subject: [PATCH 04/12] docs: align native foundation with UTM-only scope --- README.md | 97 +++++++++++++++++++++++-------------------------------- 1 file changed, 41 insertions(+), 56 deletions(-) diff --git a/README.md b/README.md index 92ade98..c570283 100644 --- a/README.md +++ b/README.md @@ -4,9 +4,9 @@ # Gapwise for iOS -### The native iOS client for Gapwise. +### The native iOS client for Gapwise for UTM. -**A privacy-first Swift + SwiftUI app for University of Toronto timetables, designed around fast local interaction and a UTM-focused campus layer.** +**A privacy-first Swift + SwiftUI app for UTM timetables and campus context, designed around fast local interaction and canonical Gapwise data boundaries.** [![iOS](https://img.shields.io/badge/iOS-Native-000000?style=for-the-badge&logo=apple&logoColor=white)](https://developer.apple.com/ios/) [![Swift](https://img.shields.io/badge/Swift-Native-F05138?style=for-the-badge&logo=swift&logoColor=white)](https://www.swift.org/) @@ -25,32 +25,32 @@ ## What Gapwise for iOS is -Gapwise for iOS is the native iPhone client for **Gapwise**, a privacy-first timetable and campus-intelligence platform for University of Toronto students. +Gapwise for iOS is the native iPhone client for **Gapwise for UTM**, a privacy-first timetable, gap-planning, and campus-intelligence project for University of Toronto Mississauga students. -This repository is the home of the Swift/SwiftUI application as it is built. The timetable experience is intended to support **UTM, UTSG, UTSC, and mixed-campus schedules**, while the first native campus map and routing work stays **UTM-focused** until equally grounded campus data exists elsewhere. +The current product scope is **UTM only**. The native client should preserve uncertain or unsupported source information when necessary, but it does not promise a UTSG, UTSC, or mixed-campus product experience. Campus facts, entrances, accessibility evidence, and geometry remain owned by the canonical Gapwise Data boundary rather than by hand-maintained iOS constants. -The goal is a real native iOS application rather than a WebView wrapper: navigation, storage, rendering, accessibility, interactions, and platform integration should be designed for iPhone while staying aligned with canonical Gapwise contracts. +The goal is a real native iOS application rather than a WebView wrapper: navigation, storage, rendering, accessibility, interactions, and platform integration are designed for iPhone while staying aligned with the wider Gapwise repository ecosystem. --- ## Current status -The repository contains a working early native application: an iPhone SwiftUI target, on-device `.ics` timetable import with a review-before-save flow, local timetable persistence, campus-qualified schedule models, date-aware Today and Timetable views, an honest UTM-first Campus boundary, Settings, portable domain tests, and macOS CI configuration. +The repository contains a working early native foundation: an iPhone SwiftUI target, local `.ics` timetable import with review-before-save, local timetable persistence, deterministic timetable/reconciliation logic, date-aware Today and Timetable views, a Gaps surface backed by portable schedule arithmetic, a UTM-only Map boundary, Settings, portable domain tests, and GitHub Actions for Linux-portable and macOS/iOS validation. -This remains an **early implementation**, not a shipped client. Import compatibility has been validated against synthetic standards-based fixtures, not every calendar producer or a live ACORN export. Timetable editing, gaps, maps, routing, account continuity, and remote integrations are still planned work. The preview schedule is development-only and the production app starts with an empty local timetable. +This remains an **early implementation**, not a shipped client. Import compatibility is validated against synthetic standards-based fixtures rather than every producer or a live ACORN export. Native UTM map rendering, production routing, account continuity, remote integrations, and broader timetable editing remain future work. The production app starts from local user data rather than seeded demo data. --- ## Product direction -The native iOS experience is being designed around the same principles as the Android and web clients: +The native iOS experience follows the same principles as the rest of Gapwise: - **Fast to open and easy to understand.** The important parts of the day should be immediately visible. -- **All-campus timetable identity.** UTM, UTSG, and UTSC meetings should preserve their actual campus and source location. -- **UTM map honesty.** Map/routing features should only claim the campus coverage backed by current first-party data. -- **Local-first timetable handling.** Import and core schedule use should work without unnecessary network transmission. -- **Optional account continuity.** A Gapwise account should enhance continuity rather than gate the core timetable experience. -- **Deterministic planning.** Timetable arithmetic, gap boundaries, route timing, and feasibility should remain explicit and testable. +- **UTM-first product scope.** Current native product behavior is designed for UTM rather than prematurely generalizing across campuses. +- **Campus-data honesty.** Map and routing features only claim facts supported by canonical Gapwise Data evidence. +- **Local-first timetable handling.** Import and core schedule use work without unnecessary network transmission. +- **Optional account continuity.** A Gapwise account should enhance continuity rather than gate core timetable use. +- **Deterministic planning.** Timetable arithmetic, gaps, route timing, and feasibility should remain explicit and testable. - **Native interaction.** Navigation, gestures, sheets, system pickers, accessibility, appearance, and lifecycle behavior should feel natural on iOS. --- @@ -59,23 +59,16 @@ The native iOS experience is being designed around the same principles as the An The current foundation includes: -- **Today**, derived from local timetable data, with current/next-class context and empty states; -- **Timetable**, with week/day navigation and campus-aware meeting cards; -- **Campus**, with a selectable campus context and an explicit UTM-first availability state; -- **Settings**, with appearance, local timetable removal, campus context, privacy, version, and ecosystem links; -- a native document-picker and import preview for compatible `.ics` timetable/calendar exports; +- **Today**, derived from the local timetable with current/next-class context and truthful empty states; +- **Timetable**, with local schedule browsing, import, review, and meeting details; +- **Gaps**, using deterministic portable schedule arithmetic rather than heuristic suggestions; +- **Map**, currently an honest UTM integration boundary rather than fabricated map/routing coverage; +- **Settings**, for implemented local preferences, timetable management, privacy information, and ecosystem links; +- native document picking and import preview for compatible `.ics` files; - atomic local JSON persistence behind an async repository boundary; -- light/dark appearance, Dynamic Type-friendly layouts, VoiceOver labels, and native navigation. +- light/dark appearance, Dynamic Type-friendly layouts, VoiceOver-minded labels, and native navigation. -The native iOS client is intended to grow toward the remaining Gapwise experience: - -- broader timetable-import compatibility and user-controlled editing; -- **Gaps** for deterministic time-between-class planning; -- **Map** for UTM-focused campus navigation and route context; -- timetable editing, exports, and integrations; -- optional, explicitly scoped account continuity. - -These are roadmap targets. This README will be tightened as implementation lands so the “current” surface never outruns the code. +Planned work includes broader import compatibility, richer timetable editing, canonical UTM map consumption and routing, exports/integrations, and optional account continuity. --- @@ -86,69 +79,61 @@ Gapwise for iOS uses modern native Apple tooling: - **Swift** - **SwiftUI** - Foundation persistence and Apple lifecycle APIs -- **Keychain / platform-secure storage** where secret material is eventually required -- a native map stack compatible with canonical Gapwise UTM data and routing semantics +- platform-secure storage such as **Keychain** when secret material is eventually required +- a native UTM map layer consuming canonical Gapwise data/contracts rather than inventing a parallel source of truth - optional Gapwise account integration only after its security boundary is implemented and reviewed -The architecture keeps portable domain models, iCalendar parsing, schedule interpretation, reconciliation, and schedule arithmetic separate from persistence and SwiftUI features. Account/sync and map integration will remain separate boundaries as they are introduced. +Portable domain models, iCalendar parsing, timetable interpretation, reconciliation, and schedule arithmetic are kept separate from persistence and SwiftUI presentation. ### Timetable import -Gapwise accepts user-selected `.ics` files through the system document picker. Parsing, interpretation, preview, and persistence happen locally. The original calendar file is not copied into the app container and timetable contents are not uploaded. +Gapwise accepts user-selected `.ics` files through the system document picker. Parsing, interpretation, preview, and persistence happen locally. The original calendar file is not retained as an application data source and timetable contents are not uploaded as part of ordinary import. -The current parser intentionally supports the timetable-oriented iCalendar subset used by the app: `VCALENDAR`, `VEVENT`, `DTSTART`, `DTEND`, `SUMMARY`, `LOCATION`, `DESCRIPTION`, `UID`, bounded weekly `RRULE` values, folded lines, escaped text, UTF-8 input, and UTC, numeric-offset, `America/Toronto`, or floating timestamps. Floating times are interpreted in Toronto time and surfaced as review warnings. +The parser supports the timetable-oriented iCalendar subset exercised by the repository fixtures, including `VCALENDAR`, `VEVENT`, `DTSTART`, `DTEND`, `SUMMARY`, `LOCATION`, `DESCRIPTION`, `UID`, bounded weekly `RRULE` values, folded lines, escaped text, UTF-8 input, UTC/system timezone identifiers, and floating timestamps. -Course meetings are imported only when a full U of T-style course code and meeting section can be identified. Campus remains explicit in the model: calendar/event wording can identify UTM, UTSG, or UTSC, and a small centralized mapping of published UTM building codes can provide UTM evidence. Conflicting or insufficient evidence produces `unknown`, never a course-code guess. +Course/campus interpretation is deliberately conservative. UTM building evidence can establish UTM identity; conflicting or insufficient evidence remains unknown. Legacy/off-campus campus values may remain decodable or detectable so imported data is not silently corrupted, but the native product itself is UTM-only for now. -Repeated imports reconcile by calendar-source identity and event `UID`. Existing matches are updated, unchanged events are not duplicated, and entries from other imports or future manual sources remain untouched. A meeting missing from a newer export is conservatively retained until Gapwise has an explicit removal review. +Repeated imports reconcile by source identity and event `UID` so unchanged meetings are not duplicated. Existing local data is not silently discarded simply because a later import omits an event. -Current limitations: - -- recurring events must be weekly, use an interval of one, and be bounded by `COUNT` or `UNTIL`; -- recurrence exceptions (`EXDATE`, `RDATE`, and `RECURRENCE-ID`) and non-weekly recurrence are reported and skipped; -- timezone identifiers other than recognized system zones and `America/Toronto` aliases are not guessed; -- campus/building recognition is intentionally incomplete, with UTM as the initial building-code focus; -- importing is not ACORN login, credential access, scraping, or an account/API integration; -- imported meetings can be reviewed and the whole saved timetable can be removed, but individual editing is not implemented yet. +Current limitations include unsupported recurrence-exception families and incomplete building recognition. Import is not ACORN login, credential access, scraping, or account/API integration. --- ## Privacy and security -The iOS client follows the same security posture as the wider Gapwise ecosystem: +The iOS client follows the wider Gapwise security posture: - minimize collection and transmission of student timetable data; -- keep original ACORN calendar handling local where practical; +- keep calendar handling local where practical; - never embed privileged service credentials in the application; - distinguish local-only features from optional account/cloud behavior; -- use platform-backed secret storage when secret material is actually introduced; -- preserve campus/source identity rather than inventing location certainty; -- make permissions narrow, understandable, and revocable. +- use platform-backed secret storage when secret material is introduced; +- preserve uncertainty instead of inventing location/accessibility certainty; +- keep permissions narrow, understandable, and revocable. -The current timetable store writes an atomically replaced JSON snapshot inside Application Support and applies iOS file protection. Calendar import is performed on-device without networking, telemetry, or retention of the source file. No account sync, end-to-end encryption, analytics, or map capability is implemented. +The current timetable store uses local application storage behind an explicit repository boundary. Calendar import is designed to run on-device without requiring networking or telemetry. No account sync or production map/routing capability is claimed by this foundation. --- ## Development -The app targets iPhone on iOS 17 or later and uses a filesystem-synchronized Xcode 16 project. There are no third-party runtime dependencies. +The app targets iPhone on iOS 17 or later and uses an Xcode project with no third-party runtime dependency requirement in the current foundation. ```bash git clone https://github.com/Gapwise-for-UTM/ios.git cd ios ``` -Open `Gapwise.xcodeproj` in Xcode 16 or later, select the `Gapwise` scheme, and run it on an iPhone simulator or device. The shared scheme builds the app and runs `GapwiseTests`. +On macOS, open `Gapwise.xcodeproj`, select the `Gapwise` scheme, and build/test on an iPhone simulator or device. -The Foundation-only core, import pipeline, and tests also build on Linux: +The portable Swift core can also be validated on Linux. The repository CI uses Swift 6.3.3 on Ubuntu Noble for the portable package: ```bash -swift test +docker run --rm -v "$PWD:/workspace" -w /workspace swift:6.3.3-noble swift package describe +docker run --rm -v "$PWD:/workspace" -w /workspace swift:6.3.3-noble swift test ``` -GitHub Actions runs those portable tests on Ubuntu and runs the complete unsigned iOS build and unit-test suite on a dynamically selected iPhone simulator on `macos-15`. - -Linux cannot compile or exercise SwiftUI, the document picker, Apple security-scoped URL behavior, or the iOS app target. Those remain Xcode/macOS validation responsibilities; Linux coverage is limited to the portable Foundation core and fixture-driven tests. +Linux cannot validate SwiftUI, the system document picker, Apple security-scoped URL behavior, signing, simulator lifecycle, or other Apple-only integration. Those remain macOS/Xcode validation responsibilities. --- From 2f2ad0482519186547d835256d42982c9f8b5091 Mon Sep 17 00:00:00 2001 From: Andrew Muratov Date: Tue, 15 Sep 2026 07:29:49 -0400 Subject: [PATCH 05/12] ci: run whitespace check outside Swift container --- .github/workflows/ios.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ios.yml b/.github/workflows/ios.yml index 4506205..cef2154 100644 --- a/.github/workflows/ios.yml +++ b/.github/workflows/ios.yml @@ -20,8 +20,6 @@ jobs: run: swift --version - name: Inspect portable package run: swift package describe - - name: Check whitespace - run: git diff --check - name: Test core run: swift test @@ -30,6 +28,8 @@ jobs: runs-on: macos-15 steps: - uses: actions/checkout@v4 + - name: Check whitespace + run: git diff --check - name: Show toolchain run: xcodebuild -version - name: Inspect project From b17e41103e5237a1ea5ae955f8990d5adc81096a Mon Sep 17 00:00:00 2001 From: Andrew Muratov Date: Tue, 15 Sep 2026 07:32:36 -0400 Subject: [PATCH 06/12] fix: make SwiftUI theme styles explicit --- Gapwise/DesignSystem/GapwiseTheme.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Gapwise/DesignSystem/GapwiseTheme.swift b/Gapwise/DesignSystem/GapwiseTheme.swift index 66cd0c1..8087716 100644 --- a/Gapwise/DesignSystem/GapwiseTheme.swift +++ b/Gapwise/DesignSystem/GapwiseTheme.swift @@ -21,7 +21,10 @@ struct GapwiseCardModifier: ViewModifier { func body(content: Content) -> some View { content .padding(GapwiseSpacing.standard) - .background(.gapwiseSecondarySurface, in: RoundedRectangle(cornerRadius: GapwiseRadius.card)) + .background( + Color.gapwiseSecondarySurface, + in: RoundedRectangle(cornerRadius: GapwiseRadius.card) + ) } } From 7476eb3d57abe690b204ae3b600f1f5bee39ccc4 Mon Sep 17 00:00:00 2001 From: Andrew Muratov Date: Tue, 15 Sep 2026 07:32:48 -0400 Subject: [PATCH 07/12] fix: return settings version description --- Gapwise/Features/Settings/SettingsView.swift | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/Gapwise/Features/Settings/SettingsView.swift b/Gapwise/Features/Settings/SettingsView.swift index 5ca222d..8d8acfe 100644 --- a/Gapwise/Features/Settings/SettingsView.swift +++ b/Gapwise/Features/Settings/SettingsView.swift @@ -129,10 +129,14 @@ private enum AppInformation { let build = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String switch (version, build) { - case let (.some(version), .some(build)): "\(version) (\(build))" - case let (.some(version), .none): version - case let (.none, .some(build)): build - case (.none, .none): "Development" + case let (.some(version), .some(build)): + return "\(version) (\(build))" + case let (.some(version), .none): + return version + case let (.none, .some(build)): + return build + case (.none, .none): + return "Development" } } } From 912b1af54e9ca52bdbc4f8bcd94d97d6c7693e2a Mon Sep 17 00:00:00 2001 From: Andrew Muratov Date: Tue, 15 Sep 2026 07:33:00 -0400 Subject: [PATCH 08/12] fix: use explicit Gapwise accent color --- Gapwise/Features/Timetable/TimetableView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gapwise/Features/Timetable/TimetableView.swift b/Gapwise/Features/Timetable/TimetableView.swift index 5ad894a..c4727d1 100644 --- a/Gapwise/Features/Timetable/TimetableView.swift +++ b/Gapwise/Features/Timetable/TimetableView.swift @@ -92,7 +92,7 @@ struct TimetableView: View { Spacer() Image(systemName: "calendar.badge.clock") .font(.title2) - .foregroundStyle(.gapwiseAccent) + .foregroundStyle(Color.gapwiseAccent) .accessibilityHidden(true) } } From 4ef934ede24095992020cbdb070a1d1b786812f2 Mon Sep 17 00:00:00 2001 From: Andrew Muratov Date: Tue, 15 Sep 2026 07:33:09 -0400 Subject: [PATCH 09/12] fix: use explicit meeting card accent color --- Gapwise/Features/Timetable/MeetingCard.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Gapwise/Features/Timetable/MeetingCard.swift b/Gapwise/Features/Timetable/MeetingCard.swift index c47c8bd..a533764 100644 --- a/Gapwise/Features/Timetable/MeetingCard.swift +++ b/Gapwise/Features/Timetable/MeetingCard.swift @@ -19,7 +19,7 @@ struct MeetingCard: View { .frame(minWidth: 68, alignment: .leading) RoundedRectangle(cornerRadius: 2) - .fill(isHighlighted ? Color.gapwiseSuccess : .gapwiseAccent) + .fill(isHighlighted ? Color.gapwiseSuccess : Color.gapwiseAccent) .frame(width: 4) .accessibilityHidden(true) @@ -30,7 +30,7 @@ struct MeetingCard: View { Spacer(minLength: GapwiseSpacing.compact) Text(meeting.campus.shortName) .font(.caption.weight(.semibold)) - .foregroundStyle(.gapwiseAccent) + .foregroundStyle(Color.gapwiseAccent) .padding(.horizontal, 7) .padding(.vertical, 3) .background(Color.gapwiseAccent.opacity(0.12), in: Capsule()) From fee580fa42ce6f1cdba5edea1abffa58317635a4 Mon Sep 17 00:00:00 2001 From: Andrew Muratov Date: Tue, 15 Sep 2026 07:33:23 -0400 Subject: [PATCH 10/12] fix: use explicit Today status colors --- Gapwise/Features/Today/TodayView.swift | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Gapwise/Features/Today/TodayView.swift b/Gapwise/Features/Today/TodayView.swift index 500ef6e..e810759 100644 --- a/Gapwise/Features/Today/TodayView.swift +++ b/Gapwise/Features/Today/TodayView.swift @@ -75,8 +75,8 @@ struct TodayView: View { Text(meeting.courseCode.rawValue) .font(.title2.weight(.bold)) if let courseTitle = meeting.courseTitle { - Text(courseTitle) - .font(.subheadline.weight(.medium)) + Text(courseTitle) + .font(.subheadline.weight(.medium)) } Text(meeting.displaySection) .font(.subheadline) @@ -110,7 +110,7 @@ struct TodayView: View { private var classesCompleteCard: some View { Label("Classes complete for today", systemImage: "checkmark.circle.fill") .font(.headline) - .foregroundStyle(.gapwiseSuccess) + .foregroundStyle(Color.gapwiseSuccess) .frame(maxWidth: .infinity, alignment: .leading) .gapwiseCard() } @@ -133,8 +133,8 @@ struct TodayView: View { private func statusColor(for timing: ClassTiming) -> Color { switch timing { - case .inProgress: .gapwiseSuccess - case .upcoming: .gapwiseAccent + case .inProgress: Color.gapwiseSuccess + case .upcoming: Color.gapwiseAccent } } } From d10a2030d7419cbfe268f3cdc6242316ee4077c0 Mon Sep 17 00:00:00 2001 From: Andrew Muratov Date: Tue, 15 Sep 2026 07:33:40 -0400 Subject: [PATCH 11/12] fix: make import review section builders explicit --- .../TimetableImportPreviewView.swift | 40 ++++++++++++++----- 1 file changed, 31 insertions(+), 9 deletions(-) diff --git a/Gapwise/Features/TimetableImport/TimetableImportPreviewView.swift b/Gapwise/Features/TimetableImport/TimetableImportPreviewView.swift index ada7391..5e671eb 100644 --- a/Gapwise/Features/TimetableImport/TimetableImportPreviewView.swift +++ b/Gapwise/Features/TimetableImport/TimetableImportPreviewView.swift @@ -72,13 +72,25 @@ struct TimetableImportPreviewView: View { } private var changesSection: some View { - Section("Changes") { - ChangeRow(title: "New", count: plan.changes.added, systemImage: "plus.circle.fill", tint: .gapwiseSuccess) + Section { + ChangeRow( + title: "New", + count: plan.changes.added, + systemImage: "plus.circle.fill", + tint: Color.gapwiseSuccess + ) ChangeRow( - title: "Updated", count: plan.changes.updated, systemImage: "arrow.triangle.2.circlepath", - tint: .gapwiseAccent) + title: "Updated", + count: plan.changes.updated, + systemImage: "arrow.triangle.2.circlepath", + tint: Color.gapwiseAccent + ) ChangeRow( - title: "Unchanged", count: plan.changes.unchanged, systemImage: "checkmark.circle", tint: .secondary) + title: "Unchanged", + count: plan.changes.unchanged, + systemImage: "checkmark.circle", + tint: .secondary + ) if plan.changes.removedFromSource > 0 { ChangeRow( @@ -89,16 +101,26 @@ struct TimetableImportPreviewView: View { ) } if plan.changes.suppressed > 0 { - ChangeRow(title: "Previously removed by you", count: plan.changes.suppressed, - systemImage: "eye.slash", tint: .secondary) + ChangeRow( + title: "Previously removed by you", + count: plan.changes.suppressed, + systemImage: "eye.slash", + tint: .secondary + ) } if plan.changes.retainedForReview > 0 { - ChangeRow(title: "Kept from previous import", count: plan.changes.retainedForReview, - systemImage: "archivebox", tint: .secondary) + ChangeRow( + title: "Kept from previous import", + count: plan.changes.retainedForReview, + systemImage: "archivebox", + tint: .secondary + ) Text("Some source events could not be read safely, or this calendar has no distinct source name. Existing meetings are kept where replacement is uncertain.") .font(.footnote) .foregroundStyle(.secondary) } + } header: { + Text("Changes") } footer: { if plan.changes.removedFromSource > 0 { Text("Saving replaces this calendar source. Meetings absent from the updated source will be removed. Other sources are kept.") From b141b2cfbaaf5466ab200b9e5440e99bdb10be97 Mon Sep 17 00:00:00 2001 From: Andrew Muratov Date: Tue, 15 Sep 2026 07:36:06 -0400 Subject: [PATCH 12/12] fix: keep FileManager actor-local under Swift 6 --- .../Core/Persistence/JSONTimetableRepository.swift | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/Gapwise/Core/Persistence/JSONTimetableRepository.swift b/Gapwise/Core/Persistence/JSONTimetableRepository.swift index 60cbf21..f57276b 100644 --- a/Gapwise/Core/Persistence/JSONTimetableRepository.swift +++ b/Gapwise/Core/Persistence/JSONTimetableRepository.swift @@ -2,13 +2,11 @@ import Foundation actor JSONTimetableRepository: TimetableRepository { private let fileURL: URL - private let fileManager: FileManager private let encoder: JSONEncoder private let decoder: JSONDecoder - init(fileURL: URL, fileManager: FileManager = .default) { + init(fileURL: URL) { self.fileURL = fileURL - self.fileManager = fileManager let encoder = JSONEncoder() encoder.dateEncodingStrategy = .iso8601 @@ -20,7 +18,8 @@ actor JSONTimetableRepository: TimetableRepository { self.decoder = decoder } - static func applicationSupport(fileManager: FileManager = .default) throws -> Self { + static func applicationSupport() throws -> Self { + let fileManager = FileManager.default guard let baseURL = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else { throw TimetableRepositoryError.applicationSupportUnavailable } @@ -29,8 +28,7 @@ actor JSONTimetableRepository: TimetableRepository { fileURL: baseURL .appendingPathComponent("Gapwise", isDirectory: true) - .appendingPathComponent("timetable.json"), - fileManager: fileManager + .appendingPathComponent("timetable.json") ) } @@ -45,6 +43,7 @@ actor JSONTimetableRepository: TimetableRepository { func save(_ snapshot: TimetableSnapshot) async throws { let data = try encoder.encode(snapshot) + let fileManager = FileManager.default try fileManager.createDirectory( at: fileURL.deletingLastPathComponent(), withIntermediateDirectories: true @@ -60,7 +59,7 @@ actor JSONTimetableRepository: TimetableRepository { func clear() async throws { do { - try fileManager.removeItem(at: fileURL) + try FileManager.default.removeItem(at: fileURL) } catch CocoaError.fileNoSuchFile { return }