diff --git a/.github/workflows/ios.yml b/.github/workflows/ios.yml new file mode 100644 index 0000000..cef2154 --- /dev/null +++ b/.github/workflows/ios.yml @@ -0,0 +1,50 @@ +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 + 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: Test core + run: swift test + + ios: + name: iOS build and tests + 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 + 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..978833c --- /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 = UnavailableTimetableRepository() + startupMessage = "Local storage is unavailable. Import will be available after storage can be opened." + } + + 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..10c69a1 --- /dev/null +++ b/Gapwise/App/AppModel.swift @@ -0,0 +1,185 @@ +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 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 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, + 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 + alertMessage = startupMessage + } + + var preferredColorScheme: ColorScheme? { + switch preferences.appearance { + case .system: nil + case .light: .light + case .dark: .dark + } + } + + 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 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." + } + } + + func setAppearance(_ appearance: AppearancePreference) { + preferences.appearance = appearance + 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 canImport else { return } + isPreparingImport = true + defer { isPreparingImport = false } + + do { + 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, + importedAt: importedAt + ) + }.value + } catch { + alertMessage = + (error as? LocalizedError)?.errorDescription + ?? "The selected calendar could not be imported." + } + } + + func confirmPendingImport() async { + guard let plan = pendingImportPlan, loadState == .ready, !isBusy else { return } + isSavingImport = true + defer { isSavingImport = false } + + do { + 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 + } + + func dismissAlert() { + alertMessage = nil + } + + private func persistPreferences() { + let preferences = preferences + let repository = preferencesRepository + let previousSave = preferencesSaveTask + preferencesSaveTask = Task { + await previousSave?.value + 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..320198a --- /dev/null +++ b/Gapwise/App/RootView.swift @@ -0,0 +1,93 @@ +import SwiftUI + +struct RootView: View { + @Environment(AppModel.self) private var appModel + + var body: some View { + @Bindable var appModel = appModel + + TabView(selection: $appModel.selectedTab) { + NavigationStack { + TodayView() + } + .tabItem { + Label("Today", systemImage: "sun.max") + } + .tag(AppTab.today) + + NavigationStack { + TimetableView() + } + .tabItem { + Label("Timetable", systemImage: "calendar") + } + .tag(AppTab.timetable) + + NavigationStack { + GapsView() + } + .tabItem { + Label("Gaps", systemImage: "hourglass") + } + .tag(AppTab.gaps) + + NavigationStack { + CampusMapView() + } + .tabItem { + Label("Map", systemImage: "map") + } + .tag(AppTab.map) + + NavigationStack { + SettingsView() + } + .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( + 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/Import/CalendarDateParser.swift b/Gapwise/Core/Import/CalendarDateParser.swift new file mode 100644 index 0000000..b61b848 --- /dev/null +++ b/Gapwise/Core/Import/CalendarDateParser.swift @@ -0,0 +1,90 @@ +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.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"] + let isUTC = value.hasSuffix("Z") + guard !isUTC || timeZoneIdentifier == nil else { return .failure(.invalidDate(value)) } + let timeZone: TimeZone + if isUTC { + timeZone = .gmt + } else if let timeZoneIdentifier { + guard let resolved = TimeZone(identifier: timeZoneIdentifier) else { + return .failure(.unknownTimeZone(timeZoneIdentifier)) + } + timeZone = resolved + } else { + timeZone = Calendar.gapwiseToronto.timeZone + } + 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: instant, + timeZoneIdentifier: timeZoneIdentifier, + isFloating: !isUTC && timeZoneIdentifier == 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 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 number(_ digits: ArraySlice) -> Int { + digits.reduce(0) { $0 * 10 + Int($1 - 48) } + } + + 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 new file mode 100644 index 0000000..2e11ab8 --- /dev/null +++ b/Gapwise/Core/Import/CampusDetector.swift @@ -0,0 +1,87 @@ +import Foundation + +enum CampusDetectionEvidence: Equatable, Sendable { + case courseCodeSuffix + 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, + 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) + } + + 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..b9e0d0d --- /dev/null +++ b/Gapwise/Core/Import/ICalendarParser.swift @@ -0,0 +1,365 @@ +import Foundation + +struct ICalendarParser: Sendable { + static let maximumByteCount = 2 * 1_048_576 + static let maximumEventCount = 2_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 stack.isEmpty { + throw TimetableImportError.malformedCalendar + } 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": + 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"] { + 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": + 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": + 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..fce1ec0 --- /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: ",", omittingEmptySubsequences: false) { + 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.. = [] + var allowsMissingEventRemoval = true + + var courseCount: Int { + Set(meetings.map { ImportedCourseIdentity(campus: $0.campus, courseCode: $0.courseCode) }).count + } + + var campuses: Set { + 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 removedFromSource: Int + let suppressed: Int + let unresolved: Int + var retainedForReview: Int = 0 +} + +struct TimetableImportPlan: Identifiable, Sendable { + var id: String { draft.sourceIdentifier } + + 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 new file mode 100644 index 0000000..e1e9e24 --- /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 try 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..823702c --- /dev/null +++ b/Gapwise/Core/Import/TimetableImporter.swift @@ -0,0 +1,386 @@ +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 = [] + 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( + skipped(event, fallbackID: fallbackIdentifier, reason: .missingMeetingSection) + ) + } else { + ignoredEventCount += 1 + } + 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 { + allowsMissingEventRemoval = false + 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, .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), + 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 { + 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, + 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, + 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)", + 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), + isReservedAssessmentWindow: isReservedAssessmentWindow(event) + ) + meetings.append(meeting) + acceptedOrRemovedUIDs.insert(uid) + } 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 ?? suggestedFileName, + meetings: meetings, + warnings: warnings.removingDuplicateIDs(), + skippedEvents: skippedEvents, + ignoredEventCount: ignoredEventCount, + 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, + 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: "|") + // 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 { + 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", "EXDATE", "RDATE", "RECURRENCE-ID"] + 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..b29018c --- /dev/null +++ b/Gapwise/Core/Import/TimetableReconciler.swift @@ -0,0 +1,103 @@ +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 + ) 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 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 } + + if existingSnapshot.suppressedImportedMeetings.contains(key) { + suppressed += 1 + continue + } + + if let existing = existingByKey[key] { + let merged = try existing.mergingImportedSource(incoming) + previewMeetings.append(merged) + if existing.hasSameImportedSource(as: incoming) { + unchanged += 1 + } else { + updated += 1 + } + } else { + previewMeetings.append(incoming) + added += 1 + } + } + + 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, + changes: TimetableImportChanges( + added: added, + updated: updated, + unchanged: unchanged, + removedFromSource: removedFromSource, + suppressed: suppressed, + unresolved: unresolved, + retainedForReview: retainedMeetings.count + ), + previewMeetings: previewMeetings, + resultingSnapshot: TimetableSnapshot( + meetings: resultingMeetings, + sources: sources, + suppressedImportedMeetings: existingSnapshot.suppressedImportedMeetings, + lastModified: importedAt + ) + ) + } + + 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 + } + } +} diff --git a/Gapwise/Core/Import/UniversityLocationParser.swift b/Gapwise/Core/Import/UniversityLocationParser.swift new file mode 100644 index 0000000..e1bea75 --- /dev/null +++ b/Gapwise/Core/Import/UniversityLocationParser.swift @@ -0,0 +1,112 @@ +import Foundation + +enum UniversityBuildingCatalog { + // 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] = [ + "MN": "Maanjiwe nendamowinan", + "DH": "Deerfield Hall", + "IB": "Instructional Centre", + "DV": "William G. Davis Building", + "CCT": "Communication, Culture and Technology Building", + "HM": "Hazel McCallion Academic Learning Centre", + "KN": "Kaneff Centre", + "IC": "Innovation Complex", + "RAWC": "Recreation, Athletics and Wellness Centre", + "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 { + canonicalCode(code) != 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 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) + } + + 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 { + 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)? { + 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 let canonicalCode = UniversityBuildingCatalog.canonicalCode(token), suffix.first?.isNumber == true { + return (canonicalCode, String(suffix)) + } + + guard + let canonicalCode = UniversityBuildingCatalog.canonicalCode(token), + suffix.isEmpty, + index + 1 < rawTokens.count + else { + continue + } + + let nextToken = rawTokens[index + 1].trimmingCharacters(in: .punctuationCharacters) + if nextToken.first?.isNumber == true { + return (canonicalCode, nextToken) + } + } + + return nil + } +} 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 new file mode 100644 index 0000000..a2ad2ec --- /dev/null +++ b/Gapwise/Core/Models/AcademicTerm.swift @@ -0,0 +1,101 @@ +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 + } + + 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) + } +} + +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 + } + + 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 new file mode 100644 index 0000000..2df383d --- /dev/null +++ b/Gapwise/Core/Models/Campus.swift @@ -0,0 +1,39 @@ +import Foundation + +enum Campus: String, CaseIterable, Codable, Hashable, Identifiable, Sendable { + case utm + case utsg + case utsc + case unknown + + var id: Self { self } + + // 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 { + case .utm: "UTM" + case .utsg: "UTSG" + case .utsc: "UTSC" + case .unknown: "Unknown" + } + } + + var fullName: String { + switch self { + case .utm: "University of Toronto Mississauga" + case .utsg: "University of Toronto St. George" + case .utsc: "University of Toronto Scarborough" + 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 new file mode 100644 index 0000000..379dc62 --- /dev/null +++ b/Gapwise/Core/Models/CourseMeeting.swift @@ -0,0 +1,645 @@ +import Foundation + +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" + case .tutorial: "TUT" + case .practical: "PRA" + 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 { + 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, Comparable, Sendable { + let campus: Campus + let courseCode: CourseCode + let termID: AcademicTerm.ID + 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 + } + + 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 + 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, + 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, 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." + } + } +} + +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 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 { 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() + 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? = nil, + meetingType: MeetingType, + meetingSection: String, + days: Set, + startTime: LocalTime, + endTime: LocalTime, + location: MeetingLocation? = nil, + instructor: String? = nil, + sourceIdentifier: String, + origin: MeetingOrigin = .legacy, + isReservedAssessmentWindow: Bool = false + ) throws { + let sourceIdentifier = sourceIdentifier.trimmingCharacters(in: .whitespacesAndNewlines) + guard !sourceIdentifier.isEmpty else { throw CourseMeetingValidationError.emptySourceIdentifier } + + let sourceValues = try MeetingValues( + campus: campus, + courseCode: courseCode, + 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 + ) + 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.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 { + case id + case courseTitle + case term + case days + case startTime + case endTime + 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 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( + 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/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..dfd8aa6 --- /dev/null +++ b/Gapwise/Core/Models/TimetableSnapshot.swift @@ -0,0 +1,150 @@ +import Foundation + +struct TimetableSnapshot: Codable, Equatable, Sendable { + 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] = [], + 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." + } + } +} + +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/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 new file mode 100644 index 0000000..acca48d --- /dev/null +++ b/Gapwise/Core/Models/Weekday.swift @@ -0,0 +1,41 @@ +import Foundation + +enum Weekday: Int, CaseIterable, Codable, Hashable, Sendable { + case sunday = 1 + case monday + case tuesday + case wednesday + case thursday + 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" + } + } + + 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/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..f57276b --- /dev/null +++ b/Gapwise/Core/Persistence/JSONTimetableRepository.swift @@ -0,0 +1,67 @@ +import Foundation + +actor JSONTimetableRepository: TimetableRepository { + private let fileURL: URL + private let encoder: JSONEncoder + private let decoder: JSONDecoder + + init(fileURL: URL) { + self.fileURL = fileURL + + 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() throws -> Self { + let fileManager = FileManager.default + 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") + ) + } + + func load() async throws -> TimetableSnapshot { + 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) + let fileManager = FileManager.default + try fileManager.createDirectory( + at: fileURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + #if os(iOS) + try data.write( + to: fileURL, options: [.atomic, .completeFileProtectionUntilFirstUserAuthentication] + ) + #else + try data.write(to: fileURL, options: .atomic) + #endif + } + + func clear() async throws { + do { + try FileManager.default.removeItem(at: fileURL) + } catch CocoaError.fileNoSuchFile { + return + } + } +} 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..a068fa4 --- /dev/null +++ b/Gapwise/Core/Persistence/TimetableRepository.swift @@ -0,0 +1,28 @@ +import Foundation + +protocol TimetableRepository: Sendable { + func load() async throws -> TimetableSnapshot + func save(_ snapshot: TimetableSnapshot) async throws + func clear() async throws +} + +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 new file mode 100644 index 0000000..cd8f229 --- /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 < rhs.id + } +} + +extension Calendar { + static var gapwiseToronto: Calendar { + var calendar = Calendar(identifier: .gregorian) + calendar.locale = Locale(identifier: "en_CA") + calendar.timeZone = TimeZone(identifier: "America/Toronto") ?? .gmt + return calendar + } +} diff --git a/Gapwise/DesignSystem/GapwiseFormatters.swift b/Gapwise/DesignSystem/GapwiseFormatters.swift new file mode 100644 index 0000000..824adc9 --- /dev/null +++ b/Gapwise/DesignSystem/GapwiseFormatters.swift @@ -0,0 +1,75 @@ +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) + } + + 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 { + 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 { + let format = dateFormat(calendar: calendar) + return "\(format.month(.wide).format(first)) \(format.day().format(first))-\(format.day().format(last))" + } + + let format = dateFormat(calendar: calendar).month(.abbreviated).day() + return "\(format.format(first)) - \(format.format(last))" + } + + 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" + } + + 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/GapwiseTheme.swift b/Gapwise/DesignSystem/GapwiseTheme.swift new file mode 100644 index 0000000..8087716 --- /dev/null +++ b/Gapwise/DesignSystem/GapwiseTheme.swift @@ -0,0 +1,35 @@ +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( + Color.gapwiseSecondarySurface, + in: RoundedRectangle(cornerRadius: GapwiseRadius.card) + ) + } +} + +extension View { + func gapwiseCard() -> some View { + modifier(GapwiseCardModifier()) + } +} 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/Development/PreviewData.swift b/Gapwise/Development/PreviewData.swift new file mode 100644 index 0000000..655bbf1 --- /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: .utm, + 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-utm-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/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 new file mode 100644 index 0000000..8d8acfe --- /dev/null +++ b/Gapwise/Features/Settings/SettingsView.swift @@ -0,0 +1,151 @@ +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") { + 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 { + appModel.requestTimetableImport() + } label: { + if appModel.isPreparingImport { + HStack { + ProgressView() + Text("Reading Calendar") + } + } else { + Label("Import Calendar File", systemImage: "square.and.arrow.down") + } + } + .disabled(!appModel.canImport) + + 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.canRemoveTimetable) + } + + Section("Campus") { + LabeledContent("Supported campus", value: "UTM") + Text("Class times are shown in Toronto time.") + .foregroundStyle(.secondary) + } + + 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 permanently removes the local timetable, import history, and saved removal choices. The original calendar file is unchanged.") + } + } +} + +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)): + return "\(version) (\(build))" + case let (.some(version), .none): + return version + case let (.none, .some(build)): + return build + case (.none, .none): + return "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..a533764 --- /dev/null +++ b/Gapwise/Features/Timetable/MeetingCard.swift @@ -0,0 +1,78 @@ +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 : Color.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(Color.gapwiseAccent) + .padding(.horizontal, 7) + .padding(.vertical, 3) + .background(Color.gapwiseAccent.opacity(0.12), in: Capsule()) + } + + 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: location.systemImageName) + .font(.subheadline) + .foregroundStyle(.secondary) + .lineLimit(2) + } + } + } + .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.displaySection), \(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/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 new file mode 100644 index 0000000..c4727d1 --- /dev/null +++ b/Gapwise/Features/Timetable/TimetableView.swift @@ -0,0 +1,127 @@ +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(GapwiseFormatters.fullDate(selectedDate, calendar: calculator.calendar)) + .font(.title3.weight(.semibold)) + .accessibilityAddTraits(.isHeader) + + if appModel.loadState != .ready || appModel.timetable.meetings.isEmpty { + TimetableAvailabilityView() + } else if meetings.isEmpty { + emptyState(hasSavedMeetings: !appModel.timetable.meetings.isEmpty) + } else { + ForEach(meetings) { meeting in + NavigationLink { + MeetingDetailView(meetingID: meeting.id) + } label: { + MeetingCard(meeting: meeting, date: selectedDate) + } + .buttonStyle(.plain) + } + } + } + .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") + } + } + ToolbarItem(placement: .bottomBar) { + Button { appModel.requestTimetableImport() } label: { + Label("Import Calendar", systemImage: "square.and.arrow.down") + } + .disabled(!appModel.canImport) + } + } + } + + 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(Color.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..e4e331b --- /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(GapwiseFormatters.narrowWeekday(date, calendar: calendar)) + .font(.caption.weight(.medium)) + Text(GapwiseFormatters.dayNumber(date, calendar: calendar)) + .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(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..e69c003 --- /dev/null +++ b/Gapwise/Features/TimetableImport/ImportedDocumentReader.swift @@ -0,0 +1,67 @@ +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 { + // 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 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 + } + } + if let coordinationError { throw coordinationError } + guard let result else { throw ImportedDocumentReadError.unreadable } + return try result.get() + } 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..5e671eb --- /dev/null +++ b/Gapwise/Features/TimetableImport/TimetableImportPreviewView.swift @@ -0,0 +1,230 @@ +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("Save 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("Campus", 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 { + 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: Color.gapwiseAccent + ) + ChangeRow( + title: "Unchanged", + count: plan.changes.unchanged, + systemImage: "checkmark.circle", + tint: .secondary + ) + + if plan.changes.removedFromSource > 0 { + ChangeRow( + 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) + } + } 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.") + } + } + } + + private var meetingsSection: some View { + Section("After Import") { + ForEach(plan.previewMeetings) { 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 new file mode 100644 index 0000000..e810759 --- /dev/null +++ b/Gapwise/Features/Today/TodayView.swift @@ -0,0 +1,149 @@ +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(GapwiseFormatters.fullDate(now, calendar: calculator.calendar)) + .font(.title2.weight(.bold)) + .accessibilityAddTraits(.isHeader) + + if appModel.loadState != .ready || appModel.timetable.meetings.isEmpty { + TimetableAvailabilityView() + } else if meetings.isEmpty { + ContentUnavailableView( + "No Classes Today", + systemImage: "sun.max", + description: Text("There are no classes in your saved timetable for today.") + ) + .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)) + if let courseTitle = meeting.courseTitle { + Text(courseTitle) + .font(.subheadline.weight(.medium)) + } + Text(meeting.displaySection) + .font(.subheadline) + .foregroundStyle(.secondary) + } + + VStack(alignment: .leading, spacing: GapwiseSpacing.compact) { + Label( + timeRange(for: meeting, on: now), + systemImage: "clock" + ) + if let location = meeting.location { + Label(location.displayName, systemImage: location.systemImageName) + .lineLimit(2) + } else { + Label("Location unavailable", systemImage: "mappin.slash") + } + } + .font(.subheadline) + } + .gapwiseCard() + .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) + .foregroundStyle(Color.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: Color.gapwiseSuccess + case .upcoming: Color.gapwiseAccent + } + } +} + +#if DEBUG + #Preview { + NavigationStack { + TodayView() + .environment(PreviewData.appModel()) + } + } +#endif 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/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 0000000..f54c87b Binary files /dev/null and b/Gapwise/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon.png differ 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 0000000..f42d0d9 Binary files /dev/null and b/Gapwise/Resources/Assets.xcassets/GapwiseBrand.imageset/gapwise-ios.png differ 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 new file mode 100644 index 0000000..5f8fe82 --- /dev/null +++ b/GapwiseTests/Core/CampusAndLocationTests.swift @@ -0,0 +1,116 @@ +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 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( + 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/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/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..c3a0128 --- /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:MAT157Y1 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:ECO101H9 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..9c0d9f2 --- /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;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 +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/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 new file mode 100644 index 0000000..cef3284 --- /dev/null +++ b/GapwiseTests/Core/ICalendarParserTests.swift @@ -0,0 +1,182 @@ +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") + } + } + } + + 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 { + 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..2549dd9 --- /dev/null +++ b/GapwiseTests/Core/RecurrenceRuleInterpreterTests.swift @@ -0,0 +1,53 @@ +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", + "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 { + 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/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/GapwiseTests/Core/TimetableImporterTests.swift b/GapwiseTests/Core/TimetableImporterTests.swift new file mode 100644 index 0000000..e76821e --- /dev/null +++ b/GapwiseTests/Core/TimetableImporterTests.swift @@ -0,0 +1,302 @@ +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 testNonUTMOnlySchedulesProduceNoSupportedEvents() { + for name in ["utsg", "utsc"] { + XCTAssertThrowsError(try importFixture(name)) { error in + XCTAssertEqual(error as? TimetableImportError, .noSupportedEvents) + } + } + } + + func testMixedCampusImportKeepsUTMAndUnresolvedEventsAndExplainsOtherSkips() throws { + let draft = try importFixture("mixed-campus") + XCTAssertEqual(draft.meetings.count, 2) + XCTAssertEqual(draft.ignoredEventCount, 1) + XCTAssertEqual(draft.unresolvedCampusCount, 1) + XCTAssertEqual(Set(draft.meetings.map(\.campus)), [.utm, .unknown]) + XCTAssertEqual(draft.skippedEvents.map(\.reason), [.unsupportedCampus, .unsupportedCampus]) + } + + 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]) + } + + 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") + } + + 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/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 new file mode 100644 index 0000000..913f756 --- /dev/null +++ b/GapwiseTests/Core/TimetableReconcilerTests.swift @@ -0,0 +1,205 @@ +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 testNewerCompleteSourceImportUpdatesMatchingUIDAndRemovesMissingMeeting() 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.removedFromSource, 1) + XCTAssertEqual(second.resultingSnapshot.meetings.count, 1) + + 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") + object.removeValue(forKey: "sourceValues") + 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) + } + + 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, + 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 new file mode 100644 index 0000000..bd6d918 --- /dev/null +++ b/Package.swift @@ -0,0 +1,26 @@ +// 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", + resources: [.process("Fixtures")] + ), + ] +) diff --git a/README.md b/README.md index 94ba5d1..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,94 +25,115 @@ ## 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 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 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. -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. 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. --- -## 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; -- **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; -- optional, explicitly scoped account continuity. +- **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-minded labels, and native navigation. -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. --- -## 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 -- **Keychain / platform-secure storage** where secret material is eventually required -- a native map stack compatible with canonical Gapwise UTM data and routing semantics +- Foundation persistence and Apple lifecycle APIs +- 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 should keep domain models, timetable parsing, persistence, account/sync, navigation, map integration, and feature UI separated as the application grows. +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 retained as an application data source and timetable contents are not uploaded as part of ordinary import. + +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/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 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 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. -No persistence, encryption, account-sync, or map capability should be described as implemented before the corresponding code and verification exist. +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 -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 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 ``` -As the Xcode project and build pipeline land, this section will carry the exact supported Xcode/iOS requirements and verification commands. +On macOS, open `Gapwise.xcodeproj`, select the `Gapwise` scheme, and build/test on an iPhone simulator or device. + +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 +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 +``` + +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. ---