diff --git a/WooCommerce/Classes/Bookings/BookingList/BookingListContainerView.swift b/WooCommerce/Classes/Bookings/BookingList/BookingListContainerView.swift index c955d393735..29bfb5cc40a 100644 --- a/WooCommerce/Classes/Bookings/BookingList/BookingListContainerView.swift +++ b/WooCommerce/Classes/Bookings/BookingList/BookingListContainerView.swift @@ -89,7 +89,8 @@ private extension BookingListContainerView { height: Layout.selectedTabIndicatorHeight) .offset(x: tabIndicatorOffset(containerWidth: geometry.size.width, tabCount: BookingListTab.allCases.count, - selectedIndex: viewModel.selectedTab.rawValue)) + selectedIndex: viewModel.selectedTab.rawValue), + y: -Layout.selectedTabIndicatorHeight / 2) .animation(.easeInOut(duration: 0.3), value: viewModel.selectedTab.rawValue) } } diff --git a/WooCommerce/Classes/Bookings/BookingList/BookingListContainerViewModel.swift b/WooCommerce/Classes/Bookings/BookingList/BookingListContainerViewModel.swift index 27941dac58c..ce5e8b31ec0 100644 --- a/WooCommerce/Classes/Bookings/BookingList/BookingListContainerViewModel.swift +++ b/WooCommerce/Classes/Bookings/BookingList/BookingListContainerViewModel.swift @@ -31,6 +31,13 @@ enum BookingListTab: Int, CaseIterable { case upcoming case all + static let utcTimeZone: TimeZone = { + guard let timeZone = TimeZone(identifier: "UTC") else { + fatalError("Unable to set up UTC time zone") + } + return timeZone + }() + var title: String { switch self { case .today: Localization.today @@ -39,6 +46,21 @@ enum BookingListTab: Int, CaseIterable { } } + func startDateBefore(currentDate: Date) -> Date? { + switch self { + case .today: currentDate.endOfDay(timezone: Self.utcTimeZone).addingTimeInterval(1) + case .upcoming, .all: nil + } + } + + func startDateAfter(currentDate: Date) -> Date? { + switch self { + case .today: currentDate.startOfDay(timezone: Self.utcTimeZone).addingTimeInterval(-1) + case .upcoming: currentDate.endOfDay(timezone: Self.utcTimeZone) + case .all: nil + } + } + private enum Localization { static let today = NSLocalizedString( "bookingListView.today", diff --git a/WooCommerce/Classes/Bookings/BookingList/BookingListView.swift b/WooCommerce/Classes/Bookings/BookingList/BookingListView.swift index becea04fb26..b073d2c8253 100644 --- a/WooCommerce/Classes/Bookings/BookingList/BookingListView.swift +++ b/WooCommerce/Classes/Bookings/BookingList/BookingListView.swift @@ -24,10 +24,7 @@ struct BookingListView: View { } } .task { - // Only load first page if no content is available. - if viewModel.bookings.isEmpty { - viewModel.loadBookings() - } + viewModel.loadBookings() } } } @@ -58,7 +55,9 @@ private extension BookingListView { func bookingItem(_ booking: Booking) -> some View { VStack(spacing: 0) { VStack(alignment: .leading) { - Text(booking.startDate.formatted(date: .numeric, time: .shortened)) + Text(booking.startDate.toString(dateStyle: .short, + timeStyle: .short, + timeZone: BookingListTab.utcTimeZone)) .font(.body) .fontWeight(.medium) .frame(maxWidth: .infinity, alignment: .leading) diff --git a/WooCommerce/Classes/Bookings/BookingList/BookingListViewModel.swift b/WooCommerce/Classes/Bookings/BookingList/BookingListViewModel.swift index fc599311178..07df245cafd 100644 --- a/WooCommerce/Classes/Bookings/BookingList/BookingListViewModel.swift +++ b/WooCommerce/Classes/Bookings/BookingList/BookingListViewModel.swift @@ -11,6 +11,9 @@ final class BookingListViewModel: ObservableObject { private let type: BookingListTab private let stores: StoresManager private let storage: StorageManagerType + private let currentDate: Date + + private static let refreshCacheReason = "refresh-cache" /// Keeps track of the current state of the syncing @Published private(set) var syncState: SyncState = .empty @@ -24,10 +27,17 @@ final class BookingListViewModel: ObservableObject { /// Booking ResultsController. private lazy var resultsController: ResultsController = { - let predicate = NSPredicate(format: "siteID == %lld", siteID) - let sortDescriptorByDate = NSSortDescriptor(key: "dateCreated", ascending: false) + var predicates = [NSPredicate(format: "siteID == %lld", siteID)] + if let before = type.startDateBefore(currentDate: currentDate) { + predicates.append(NSPredicate(format: "startDate < %@", before as NSDate)) + } + if let after = type.startDateAfter(currentDate: currentDate) { + predicates.append(NSPredicate(format: "startDate > %@", after as NSDate)) + } + let combinedPredicate = NSCompoundPredicate(type: .and, subpredicates: predicates) + let sortDescriptorByDate = NSSortDescriptor(key: "startDate", ascending: false) let resultsController = ResultsController(storageManager: storage, - matching: predicate, + matching: combinedPredicate, sortedBy: [sortDescriptorByDate]) return resultsController }() @@ -35,11 +45,13 @@ final class BookingListViewModel: ObservableObject { init(siteID: Int64, type: BookingListTab, stores: StoresManager = ServiceLocator.stores, - storage: StorageManagerType = ServiceLocator.storageManager) { + storage: StorageManagerType = ServiceLocator.storageManager, + currentDate: Date = Date()) { self.siteID = siteID self.type = type self.stores = stores self.storage = storage + self.currentDate = currentDate self.paginationTracker = PaginationTracker(pageFirstIndex: pageFirstIndex) configureResultsController() @@ -60,7 +72,7 @@ final class BookingListViewModel: ObservableObject { @MainActor func onRefreshAction() async { await withCheckedContinuation { continuation in - paginationTracker.resync(reason: nil) { + paginationTracker.resync(reason: Self.refreshCacheReason) { continuation.resume(returning: ()) } } @@ -92,12 +104,7 @@ private extension BookingListViewModel { /// Updates row view models and sync state. func updateResults() { - /// TODO: update logic for fetching bookings - if type == .all { - bookings = resultsController.fetchedObjects - } else { - bookings = [] - } + bookings = resultsController.fetchedObjects transitionToResultsUpdatedState() } } @@ -105,7 +112,15 @@ private extension BookingListViewModel { extension BookingListViewModel: PaginationTrackerDelegate { func sync(pageNumber: Int, pageSize: Int, reason: String?, onCompletion: SyncCompletion?) { transitionToSyncingState() - let action = BookingAction.synchronizeBookings(siteID: siteID, pageNumber: pageNumber, pageSize: pageSize) { [weak self] result in + let shouldClearCache = reason == Self.refreshCacheReason + let action = BookingAction.synchronizeBookings( + siteID: siteID, + pageNumber: pageNumber, + pageSize: pageSize, + startDateBefore: type.startDateBefore(currentDate: currentDate)?.ISO8601Format(), + startDateAfter: type.startDateAfter(currentDate: currentDate)?.ISO8601Format(), + shouldClearCache: shouldClearCache + ) { [weak self] result in switch result { case .success(let hasNextPage): onCompletion?(.success(hasNextPage)) diff --git a/WooCommerce/WooCommerceTests/ViewRelated/Bookings/BookingListViewModelTests.swift b/WooCommerce/WooCommerceTests/ViewRelated/Bookings/BookingListViewModelTests.swift index a744cb7b62a..e76b6765c00 100644 --- a/WooCommerce/WooCommerceTests/ViewRelated/Bookings/BookingListViewModelTests.swift +++ b/WooCommerce/WooCommerceTests/ViewRelated/Bookings/BookingListViewModelTests.swift @@ -73,7 +73,7 @@ struct BookingListViewModelTests { } @Test func state_is_results_upon_load_bookings_if_existing_bookings_in_storage() { - let existingBooking = Booking.fake().copy(siteID: sampleSiteID, bookingID: 123) + let existingBooking = createBooking(id: 123, startDate: Date()) insertBookings([existingBooking]) let viewModel = BookingListViewModel(siteID: sampleSiteID, type: .all, @@ -90,7 +90,7 @@ struct BookingListViewModelTests { @Test func state_is_results_after_load_bookings_with_nonempty_results() async { // Given let stores = MockStoresManager(sessionManager: .testingInstance) - let booking = Booking.fake().copy(siteID: sampleSiteID) + let booking = createBooking(id: 1, startDate: Date()) stores.whenReceivingAction(ofType: BookingAction.self) { action in guard case let .synchronizeBookings(_, _, _, _, _, _, onCompletion) = action else { return @@ -167,8 +167,8 @@ struct BookingListViewModelTests { // Given let stores = MockStoresManager(sessionManager: .testingInstance) var invocationCountOfLoadBookings = 0 - let firstPageItems = [Booking](repeating: .fake().copy(siteID: sampleSiteID), count: 2) - let secondPageItems = [Booking](repeating: .fake().copy(siteID: sampleSiteID), count: 1) + let firstPageItems = (1...2).map { createBooking(id: Int64($0), startDate: Date()) } + let secondPageItems = [createBooking(id: 3, startDate: Date())] stores.whenReceivingAction(ofType: BookingAction.self) { action in guard case let .synchronizeBookings(_, pageNumber, _, _, _, _, onCompletion) = action else { return @@ -215,8 +215,8 @@ struct BookingListViewModelTests { @Test func booking_models_match_loaded_bookings() { // Given let stores = MockStoresManager(sessionManager: .testingInstance) - let booking1 = Booking.fake().copy(siteID: sampleSiteID, bookingID: 9) - let booking2 = Booking.fake().copy(siteID: sampleSiteID, bookingID: 10) + let booking1 = createBooking(id: 9, startDate: Date()) + let booking2 = createBooking(id: 10, startDate: Date()) stores.whenReceivingAction(ofType: BookingAction.self) { action in guard case let .synchronizeBookings(_, _, _, _, _, _, onCompletion) = action else { return @@ -263,8 +263,8 @@ struct BookingListViewModelTests { @Test func booking_models_are_sorted_by_date_created() { // Given let stores = MockStoresManager(sessionManager: .testingInstance) - let olderBooking = Booking.fake().copy(siteID: sampleSiteID, bookingID: 1, dateCreated: Date(timeIntervalSince1970: 1000)) - let newerBooking = Booking.fake().copy(siteID: sampleSiteID, bookingID: 3, dateCreated: Date(timeIntervalSince1970: 2000)) + let olderBooking = Booking.fake().copy(siteID: sampleSiteID, bookingID: 1, dateCreated: Date(timeIntervalSince1970: 1000), startDate: Date()) + let newerBooking = Booking.fake().copy(siteID: sampleSiteID, bookingID: 3, dateCreated: Date(timeIntervalSince1970: 2000), startDate: Date()) stores.whenReceivingAction(ofType: BookingAction.self) { action in guard case let .synchronizeBookings(_, _, _, _, _, _, onCompletion) = action else { return @@ -312,6 +312,268 @@ struct BookingListViewModelTests { #expect(skip == 0) #expect(invocationCountOfLoadBookings == 1) } + + // MARK: - Type-based filtering + + @Test func today_tab_passes_correct_date_filters_to_booking_action() { + // Given + let testDate = Date(timeIntervalSince1970: 1609459200) // 2021-01-01 00:00:00 UTC + let stores = MockStoresManager(sessionManager: .testingInstance) + var capturedStartDateBefore: String? + var capturedStartDateAfter: String? + + stores.whenReceivingAction(ofType: BookingAction.self) { action in + guard case let .synchronizeBookings(_, _, _, startDateBefore, startDateAfter, _, onCompletion) = action else { + return + } + capturedStartDateBefore = startDateBefore + capturedStartDateAfter = startDateAfter + onCompletion(.success(false)) + } + + let viewModel = BookingListViewModel(siteID: sampleSiteID, type: .today, stores: stores, currentDate: testDate) + + // When + viewModel.loadBookings() + + // Then + #expect(capturedStartDateAfter == "2020-12-31T23:59:59Z", "Today tab should filter after start of day") + #expect(capturedStartDateBefore == "2021-01-02T00:00:00Z", "Today tab should filter before end of day") + } + + @Test func upcoming_tab_passes_correct_date_filters_to_booking_action() { + // Given + let testDate = Date(timeIntervalSince1970: 1609459200) // 2021-01-01 00:00:00 UTC + let stores = MockStoresManager(sessionManager: .testingInstance) + var capturedStartDateBefore: String? + var capturedStartDateAfter: String? + + stores.whenReceivingAction(ofType: BookingAction.self) { action in + guard case let .synchronizeBookings(_, _, _, startDateBefore, startDateAfter, _, onCompletion) = action else { + return + } + capturedStartDateBefore = startDateBefore + capturedStartDateAfter = startDateAfter + onCompletion(.success(false)) + } + + let viewModel = BookingListViewModel(siteID: sampleSiteID, type: .upcoming, stores: stores, currentDate: testDate) + + // When + viewModel.loadBookings() + + // Then + #expect(capturedStartDateBefore == nil, "Upcoming tab should not have startDateBefore filter") + #expect(capturedStartDateAfter == "2021-01-01T23:59:59Z", "Upcoming tab should filter after end of day") + } + + @Test func all_tab_passes_no_date_filters_to_booking_action() { + // Given + let testDate = Date(timeIntervalSince1970: 1609459200) // 2021-01-01 00:00:00 UTC + let stores = MockStoresManager(sessionManager: .testingInstance) + var capturedStartDateBefore: String? + var capturedStartDateAfter: String? + + stores.whenReceivingAction(ofType: BookingAction.self) { action in + guard case let .synchronizeBookings(_, _, _, startDateBefore, startDateAfter, _, onCompletion) = action else { + return + } + capturedStartDateBefore = startDateBefore + capturedStartDateAfter = startDateAfter + onCompletion(.success(false)) + } + + let viewModel = BookingListViewModel(siteID: sampleSiteID, type: .all, stores: stores, currentDate: testDate) + + // When + viewModel.loadBookings() + + // Then + #expect(capturedStartDateBefore == nil, "All tab should not have startDateBefore filter") + #expect(capturedStartDateAfter == nil, "All tab should not have startDateAfter filter") + } + + // MARK: - Cache clearing logic + + @Test func load_bookings_does_not_clear_cache() { + // Given + let stores = MockStoresManager(sessionManager: .testingInstance) + var capturedShouldClearCache: Bool? + + stores.whenReceivingAction(ofType: BookingAction.self) { action in + guard case let .synchronizeBookings(_, _, _, _, _, shouldClearCache, onCompletion) = action else { + return + } + capturedShouldClearCache = shouldClearCache + onCompletion(.success(false)) + } + + let viewModel = BookingListViewModel(siteID: sampleSiteID, type: .all, stores: stores) + + // When + viewModel.loadBookings() + + // Then + #expect(capturedShouldClearCache == false, "Initial load should not clear cache") + } + + @Test func on_load_next_page_action_does_not_clear_cache() { + // Given + let stores = MockStoresManager(sessionManager: .testingInstance) + var capturedShouldClearCache: Bool? + var actionCallCount = 0 + + stores.whenReceivingAction(ofType: BookingAction.self) { action in + guard case let .synchronizeBookings(_, _, _, _, _, shouldClearCache, onCompletion) = action else { + return + } + actionCallCount += 1 + capturedShouldClearCache = shouldClearCache + onCompletion(.success(actionCallCount == 1)) // First call has next page, second doesn't + } + + let viewModel = BookingListViewModel(siteID: sampleSiteID, type: .all, stores: stores) + + // When + viewModel.loadBookings() // First page + viewModel.onLoadNextPageAction() // Next page + + // Then + #expect(capturedShouldClearCache == false, "Load next page should not clear cache") + #expect(actionCallCount == 2, "Should have made two API calls") + } + + @Test func on_refresh_action_clears_cache() async { + // Given + let stores = MockStoresManager(sessionManager: .testingInstance) + var capturedShouldClearCache: Bool? + + stores.whenReceivingAction(ofType: BookingAction.self) { action in + guard case let .synchronizeBookings(_, _, _, _, _, shouldClearCache, onCompletion) = action else { + return + } + capturedShouldClearCache = shouldClearCache + onCompletion(.success(false)) + } + + let viewModel = BookingListViewModel(siteID: sampleSiteID, type: .all, stores: stores) + + // When + await viewModel.onRefreshAction() + + // Then + #expect(capturedShouldClearCache == true, "Refresh action should clear cache") + } + + // MARK: - Local storage filtering + + @Test func today_tab_results_controller_filters_local_storage_correctly() { + // Given + let testDate = Date(timeIntervalSince1970: 1609459200) // 2021-01-01 00:00:00 UTC + let todayStart = testDate.startOfDay(timezone: BookingListTab.utcTimeZone) + let nextDayStart = testDate.endOfDay(timezone: BookingListTab.utcTimeZone).addingTimeInterval(1) + + // Create bookings with different start dates + let atStartOfDayBooking = createBooking(id: 1, startDate: todayStart) // Exactly at start + let withinTodayBooking = createBooking(id: 2, startDate: todayStart.addingTimeInterval(3600)) // 1 hour after start + let beforeTodayBooking = createBooking(id: 3, startDate: todayStart.addingTimeInterval(-3600)) // 1 hour before start + let afterTodayBooking = createBooking(id: 4, startDate: nextDayStart.addingTimeInterval(3600)) // 1 hour after end + let startOfNextDayBooking = createBooking(id: 5, startDate: nextDayStart) // First second of next day + + insertBookings([withinTodayBooking, beforeTodayBooking, afterTodayBooking, atStartOfDayBooking, startOfNextDayBooking]) + + let viewModel = BookingListViewModel(siteID: sampleSiteID, + type: .today, + stores: MockStoresManager(sessionManager: .testingInstance), + storage: storageManager, + currentDate: testDate) + + // When/Then - should only show bookings within today (startDate > start AND startDate < end) + #expect(viewModel.bookings.count == 2) + #expect(viewModel.bookings.first?.bookingID == withinTodayBooking.bookingID) + } + + @Test func upcoming_tab_results_controller_filters_local_storage_correctly() { + // Given + let testDate = Date(timeIntervalSince1970: 1609459200) // 2021-01-01 00:00:00 UTC + let todayEnd = testDate.endOfDay(timezone: BookingListTab.utcTimeZone) + + // Create bookings with different start dates + let afterTodayBooking1 = createBooking(id: 1, startDate: todayEnd.addingTimeInterval(3600)) // 1 hour after end + let afterTodayBooking2 = createBooking(id: 2, startDate: todayEnd.addingTimeInterval(86400)) // 1 day after end + let withinTodayBooking = createBooking(id: 3, startDate: todayEnd.addingTimeInterval(-3600)) // 1 hour before end + let beforeTodayBooking = createBooking(id: 4, startDate: testDate.addingTimeInterval(-86400)) // 1 day before + let atEndOfDayBooking = createBooking(id: 5, startDate: todayEnd) // Exactly at end + + insertBookings([afterTodayBooking1, afterTodayBooking2, withinTodayBooking, beforeTodayBooking, atEndOfDayBooking]) + + let viewModel = BookingListViewModel(siteID: sampleSiteID, + type: .upcoming, + stores: MockStoresManager(sessionManager: .testingInstance), + storage: storageManager, + currentDate: testDate) + + // When/Then - should only show bookings after today (startDate > end of today) + #expect(viewModel.bookings.count == 2, "Upcoming tab should show bookings after today") + let bookingIDs = Set(viewModel.bookings.map { $0.bookingID }) + #expect(bookingIDs.contains(afterTodayBooking1.bookingID), "Should contain booking after today") + #expect(bookingIDs.contains(afterTodayBooking2.bookingID), "Should contain second booking after today") + #expect(!bookingIDs.contains(withinTodayBooking.bookingID), "Should not contain booking within today") + #expect(!bookingIDs.contains(beforeTodayBooking.bookingID), "Should not contain booking before today") + #expect(!bookingIDs.contains(atEndOfDayBooking.bookingID), "Should not contain booking exactly at end of day") + } + + @Test func all_tab_results_controller_shows_all_bookings_from_local_storage() { + // Given + let testDate = Date(timeIntervalSince1970: 1609459200) // 2021-01-01 00:00:00 UTC + + // Create bookings for different times + let todayBooking = createBooking(id: 1, startDate: testDate) + let futureBooking = createBooking(id: 2, startDate: testDate.addingTimeInterval(86400)) // 1 day later + let pastBooking = createBooking(id: 3, startDate: testDate.addingTimeInterval(-86400)) // 1 day earlier + let farFutureBooking = createBooking(id: 4, startDate: testDate.addingTimeInterval(86400 * 30)) // 30 days later + let farPastBooking = createBooking(id: 5, startDate: testDate.addingTimeInterval(-86400 * 30)) // 30 days earlier + + insertBookings([todayBooking, futureBooking, pastBooking, farFutureBooking, farPastBooking]) + + let viewModel = BookingListViewModel(siteID: sampleSiteID, + type: .all, + stores: MockStoresManager(sessionManager: .testingInstance), + storage: storageManager, + currentDate: testDate) + + // When/Then - should show all bookings regardless of date + #expect(viewModel.bookings.count == 5, "All tab should show all bookings") + let bookingIDs = Set(viewModel.bookings.map { $0.bookingID }) + #expect(bookingIDs.contains(todayBooking.bookingID), "Should contain today's booking") + #expect(bookingIDs.contains(futureBooking.bookingID), "Should contain future booking") + #expect(bookingIDs.contains(pastBooking.bookingID), "Should contain past booking") + #expect(bookingIDs.contains(farFutureBooking.bookingID), "Should contain far future booking") + #expect(bookingIDs.contains(farPastBooking.bookingID), "Should contain far past booking") + } + + @Test func results_controller_filters_by_site_id() { + // Given + let testDate = Date(timeIntervalSince1970: 1609459200) // 2021-01-01 00:00:00 UTC + let otherSiteID: Int64 = 999 + + // Create bookings for different sites + let correctSiteBooking = createBooking(id: 1, startDate: testDate) + let wrongSiteBooking = createBooking(id: 2, startDate: testDate, siteID: otherSiteID) + + insertBookings([correctSiteBooking, wrongSiteBooking]) + + let viewModel = BookingListViewModel(siteID: sampleSiteID, + type: .all, + stores: MockStoresManager(sessionManager: .testingInstance), + storage: storageManager, + currentDate: testDate) + + // When/Then - should only show bookings for the correct site + #expect(viewModel.bookings.count == 1, "Should only show bookings for the correct site") + #expect(viewModel.bookings.first?.bookingID == correctSiteBooking.bookingID, "Should contain only the booking for the correct site") + #expect(viewModel.bookings.first?.siteID == sampleSiteID, "Booking should have the correct site ID") + } } private extension BookingListViewModelTests { @@ -323,4 +585,8 @@ private extension BookingListViewModelTests { } }, completion: {}, on: .main) } + + func createBooking(id: Int64, startDate: Date, siteID: Int64? = nil) -> Booking { + return Booking.fake().copy(siteID: siteID ?? self.sampleSiteID, bookingID: id, startDate: startDate) + } }