From 9cad175ccc8c43d95150e72bd3a3a36b55c18c53 Mon Sep 17 00:00:00 2001 From: Huong Do Date: Fri, 3 Oct 2025 16:56:17 +0700 Subject: [PATCH 1/5] Fetch filtered bookings on time tabs --- .../BookingListContainerView.swift | 3 ++- .../BookingListContainerViewModel.swift | 22 +++++++++++++++++ .../BookingList/BookingListView.swift | 4 +++- .../BookingList/BookingListViewModel.swift | 24 ++++++++++++------- 4 files changed, 42 insertions(+), 11 deletions(-) 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..2cbb12c9ee5 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 { } } + var startDateBefore: Date? { + switch self { + case .today: Date().endOfDay(timezone: Self.utcTimeZone) + case .upcoming, .all: nil + } + } + + var startDateAfter: Date? { + switch self { + case .today: Date().startOfDay(timezone: Self.utcTimeZone) + case .upcoming: Date().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..a0415229b35 100644 --- a/WooCommerce/Classes/Bookings/BookingList/BookingListView.swift +++ b/WooCommerce/Classes/Bookings/BookingList/BookingListView.swift @@ -58,7 +58,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: TimeZone(identifier: "UTC")!)) .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..31539f8b7d5 100644 --- a/WooCommerce/Classes/Bookings/BookingList/BookingListViewModel.swift +++ b/WooCommerce/Classes/Bookings/BookingList/BookingListViewModel.swift @@ -24,10 +24,17 @@ final class BookingListViewModel: ObservableObject { /// Booking ResultsController. private lazy var resultsController: ResultsController = { - let predicate = NSPredicate(format: "siteID == %lld", siteID) + var predicates = [NSPredicate(format: "siteID == %lld", siteID)] + if let before = type.startDateBefore { + predicates.append(NSPredicate(format: "startDate < %@", before as NSDate)) + } + if let after = type.startDateAfter { + predicates.append(NSPredicate(format: "startDate > %@", after as NSDate)) + } + let combinedPredicate = NSCompoundPredicate(type: .and, subpredicates: predicates) let sortDescriptorByDate = NSSortDescriptor(key: "dateCreated", ascending: false) let resultsController = ResultsController(storageManager: storage, - matching: predicate, + matching: combinedPredicate, sortedBy: [sortDescriptorByDate]) return resultsController }() @@ -92,12 +99,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 +107,11 @@ 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 action = BookingAction.synchronizeBookings(siteID: siteID, + pageNumber: pageNumber, + pageSize: pageSize, + startDateBefore: type.startDateBefore?.ISO8601Format(), + startDateAfter: type.startDateAfter?.ISO8601Format()) { [weak self] result in switch result { case .success(let hasNextPage): onCompletion?(.success(hasNextPage)) From 8b7c352b40312c9afa57c708ba8b956637fcefaf Mon Sep 17 00:00:00 2001 From: Huong Do Date: Fri, 3 Oct 2025 17:14:58 +0700 Subject: [PATCH 2/5] Enable clearing cache upon pull to refresh --- .../BookingList/BookingListViewModel.swift | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/WooCommerce/Classes/Bookings/BookingList/BookingListViewModel.swift b/WooCommerce/Classes/Bookings/BookingList/BookingListViewModel.swift index 31539f8b7d5..234b503d71d 100644 --- a/WooCommerce/Classes/Bookings/BookingList/BookingListViewModel.swift +++ b/WooCommerce/Classes/Bookings/BookingList/BookingListViewModel.swift @@ -12,6 +12,8 @@ final class BookingListViewModel: ObservableObject { private let stores: StoresManager private let storage: StorageManagerType + private static let refreshCacheReason = "refresh-cache" + /// Keeps track of the current state of the syncing @Published private(set) var syncState: SyncState = .empty @@ -67,7 +69,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: ()) } } @@ -107,11 +109,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, - startDateBefore: type.startDateBefore?.ISO8601Format(), - startDateAfter: type.startDateAfter?.ISO8601Format()) { [weak self] result in + let shouldClearCache = reason == Self.refreshCacheReason + let action = BookingAction.synchronizeBookings( + siteID: siteID, + pageNumber: pageNumber, + pageSize: pageSize, + startDateBefore: type.startDateBefore?.ISO8601Format(), + startDateAfter: type.startDateAfter?.ISO8601Format(), + shouldClearCache: shouldClearCache + ) { [weak self] result in switch result { case .success(let hasNextPage): onCompletion?(.success(hasNextPage)) From 3b3b78d193a3e0515304cbeac97529b13ef841c6 Mon Sep 17 00:00:00 2001 From: Huong Do Date: Fri, 3 Oct 2025 17:39:34 +0700 Subject: [PATCH 3/5] Add tests for BookingListViewModel --- .../BookingListContainerViewModel.swift | 10 +- .../BookingList/BookingListViewModel.swift | 13 +- .../Bookings/BookingListViewModelTests.swift | 282 +++++++++++++++++- 3 files changed, 287 insertions(+), 18 deletions(-) diff --git a/WooCommerce/Classes/Bookings/BookingList/BookingListContainerViewModel.swift b/WooCommerce/Classes/Bookings/BookingList/BookingListContainerViewModel.swift index 2cbb12c9ee5..0908e3adfac 100644 --- a/WooCommerce/Classes/Bookings/BookingList/BookingListContainerViewModel.swift +++ b/WooCommerce/Classes/Bookings/BookingList/BookingListContainerViewModel.swift @@ -46,17 +46,17 @@ enum BookingListTab: Int, CaseIterable { } } - var startDateBefore: Date? { + func startDateBefore(currentDate: Date = Date()) -> Date? { switch self { - case .today: Date().endOfDay(timezone: Self.utcTimeZone) + case .today: currentDate.endOfDay(timezone: Self.utcTimeZone) case .upcoming, .all: nil } } - var startDateAfter: Date? { + func startDateAfter(currentDate: Date = Date()) -> Date? { switch self { - case .today: Date().startOfDay(timezone: Self.utcTimeZone) - case .upcoming: Date().endOfDay(timezone: Self.utcTimeZone) + case .today: currentDate.startOfDay(timezone: Self.utcTimeZone) + case .upcoming: currentDate.endOfDay(timezone: Self.utcTimeZone) case .all: nil } } diff --git a/WooCommerce/Classes/Bookings/BookingList/BookingListViewModel.swift b/WooCommerce/Classes/Bookings/BookingList/BookingListViewModel.swift index 234b503d71d..30da75e90d8 100644 --- a/WooCommerce/Classes/Bookings/BookingList/BookingListViewModel.swift +++ b/WooCommerce/Classes/Bookings/BookingList/BookingListViewModel.swift @@ -11,6 +11,7 @@ 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" @@ -27,10 +28,10 @@ final class BookingListViewModel: ObservableObject { /// Booking ResultsController. private lazy var resultsController: ResultsController = { var predicates = [NSPredicate(format: "siteID == %lld", siteID)] - if let before = type.startDateBefore { + if let before = type.startDateBefore(currentDate: currentDate) { predicates.append(NSPredicate(format: "startDate < %@", before as NSDate)) } - if let after = type.startDateAfter { + if let after = type.startDateAfter(currentDate: currentDate) { predicates.append(NSPredicate(format: "startDate > %@", after as NSDate)) } let combinedPredicate = NSCompoundPredicate(type: .and, subpredicates: predicates) @@ -44,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() @@ -114,8 +117,8 @@ extension BookingListViewModel: PaginationTrackerDelegate { siteID: siteID, pageNumber: pageNumber, pageSize: pageSize, - startDateBefore: type.startDateBefore?.ISO8601Format(), - startDateAfter: type.startDateAfter?.ISO8601Format(), + startDateBefore: type.startDateBefore(currentDate: currentDate)?.ISO8601Format(), + startDateAfter: type.startDateAfter(currentDate: currentDate)?.ISO8601Format(), shouldClearCache: shouldClearCache ) { [weak self] result in switch result { diff --git a/WooCommerce/WooCommerceTests/ViewRelated/Bookings/BookingListViewModelTests.swift b/WooCommerce/WooCommerceTests/ViewRelated/Bookings/BookingListViewModelTests.swift index a744cb7b62a..626005c6561 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 == "2021-01-01T00:00:00Z", "Today tab should filter after start of day") + #expect(capturedStartDateBefore == "2021-01-01T23:59:59Z", "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 todayEnd = testDate.endOfDay(timezone: BookingListTab.utcTimeZone) + + // Create bookings with different start dates + let withinTodayBooking = createBooking(id: 1, startDate: todayStart.addingTimeInterval(3600)) // 1 hour after start + let beforeTodayBooking = createBooking(id: 2, startDate: todayStart.addingTimeInterval(-3600)) // 1 hour before start + let afterTodayBooking = createBooking(id: 3, startDate: todayEnd.addingTimeInterval(3600)) // 1 hour after end + let atStartOfDayBooking = createBooking(id: 4, startDate: todayStart) // Exactly at start + let atEndOfDayBooking = createBooking(id: 5, startDate: todayEnd) // Exactly at end + + insertBookings([withinTodayBooking, beforeTodayBooking, afterTodayBooking, atStartOfDayBooking, atEndOfDayBooking]) + + 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 == 1, "Today tab should only show bookings within today") + #expect(viewModel.bookings.first?.bookingID == withinTodayBooking.bookingID, "Should contain only the booking within today") + } + + @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) + } } From 35b4586bc5ed04ba602c23398b103d4125a83b2a Mon Sep 17 00:00:00 2001 From: Huong Do Date: Fri, 3 Oct 2025 18:16:24 +0700 Subject: [PATCH 4/5] Restore loading bookings upon switching tab and sort items by start date --- .../Bookings/BookingList/BookingListContainerViewModel.swift | 4 ++-- .../Classes/Bookings/BookingList/BookingListView.swift | 5 +---- .../Classes/Bookings/BookingList/BookingListViewModel.swift | 2 +- 3 files changed, 4 insertions(+), 7 deletions(-) diff --git a/WooCommerce/Classes/Bookings/BookingList/BookingListContainerViewModel.swift b/WooCommerce/Classes/Bookings/BookingList/BookingListContainerViewModel.swift index 0908e3adfac..c5abae94681 100644 --- a/WooCommerce/Classes/Bookings/BookingList/BookingListContainerViewModel.swift +++ b/WooCommerce/Classes/Bookings/BookingList/BookingListContainerViewModel.swift @@ -46,14 +46,14 @@ enum BookingListTab: Int, CaseIterable { } } - func startDateBefore(currentDate: Date = Date()) -> Date? { + func startDateBefore(currentDate: Date) -> Date? { switch self { case .today: currentDate.endOfDay(timezone: Self.utcTimeZone) case .upcoming, .all: nil } } - func startDateAfter(currentDate: Date = Date()) -> Date? { + func startDateAfter(currentDate: Date) -> Date? { switch self { case .today: currentDate.startOfDay(timezone: Self.utcTimeZone) case .upcoming: currentDate.endOfDay(timezone: Self.utcTimeZone) diff --git a/WooCommerce/Classes/Bookings/BookingList/BookingListView.swift b/WooCommerce/Classes/Bookings/BookingList/BookingListView.swift index a0415229b35..1851a5614e0 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() } } } diff --git a/WooCommerce/Classes/Bookings/BookingList/BookingListViewModel.swift b/WooCommerce/Classes/Bookings/BookingList/BookingListViewModel.swift index 30da75e90d8..07df245cafd 100644 --- a/WooCommerce/Classes/Bookings/BookingList/BookingListViewModel.swift +++ b/WooCommerce/Classes/Bookings/BookingList/BookingListViewModel.swift @@ -35,7 +35,7 @@ final class BookingListViewModel: ObservableObject { predicates.append(NSPredicate(format: "startDate > %@", after as NSDate)) } let combinedPredicate = NSCompoundPredicate(type: .and, subpredicates: predicates) - let sortDescriptorByDate = NSSortDescriptor(key: "dateCreated", ascending: false) + let sortDescriptorByDate = NSSortDescriptor(key: "startDate", ascending: false) let resultsController = ResultsController(storageManager: storage, matching: combinedPredicate, sortedBy: [sortDescriptorByDate]) From a93b6411db116dbb6bf21bd5ae6c80bdcdac442a Mon Sep 17 00:00:00 2001 From: Huong Do Date: Fri, 3 Oct 2025 18:51:17 +0700 Subject: [PATCH 5/5] Update time limit for today tab --- .../BookingListContainerViewModel.swift | 4 ++-- .../BookingList/BookingListView.swift | 2 +- .../Bookings/BookingListViewModelTests.swift | 22 +++++++++---------- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/WooCommerce/Classes/Bookings/BookingList/BookingListContainerViewModel.swift b/WooCommerce/Classes/Bookings/BookingList/BookingListContainerViewModel.swift index c5abae94681..ce5e8b31ec0 100644 --- a/WooCommerce/Classes/Bookings/BookingList/BookingListContainerViewModel.swift +++ b/WooCommerce/Classes/Bookings/BookingList/BookingListContainerViewModel.swift @@ -48,14 +48,14 @@ enum BookingListTab: Int, CaseIterable { func startDateBefore(currentDate: Date) -> Date? { switch self { - case .today: currentDate.endOfDay(timezone: Self.utcTimeZone) + 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) + case .today: currentDate.startOfDay(timezone: Self.utcTimeZone).addingTimeInterval(-1) case .upcoming: currentDate.endOfDay(timezone: Self.utcTimeZone) case .all: nil } diff --git a/WooCommerce/Classes/Bookings/BookingList/BookingListView.swift b/WooCommerce/Classes/Bookings/BookingList/BookingListView.swift index 1851a5614e0..b073d2c8253 100644 --- a/WooCommerce/Classes/Bookings/BookingList/BookingListView.swift +++ b/WooCommerce/Classes/Bookings/BookingList/BookingListView.swift @@ -57,7 +57,7 @@ private extension BookingListView { VStack(alignment: .leading) { Text(booking.startDate.toString(dateStyle: .short, timeStyle: .short, - timeZone: TimeZone(identifier: "UTC")!)) + timeZone: BookingListTab.utcTimeZone)) .font(.body) .fontWeight(.medium) .frame(maxWidth: .infinity, alignment: .leading) diff --git a/WooCommerce/WooCommerceTests/ViewRelated/Bookings/BookingListViewModelTests.swift b/WooCommerce/WooCommerceTests/ViewRelated/Bookings/BookingListViewModelTests.swift index 626005c6561..e76b6765c00 100644 --- a/WooCommerce/WooCommerceTests/ViewRelated/Bookings/BookingListViewModelTests.swift +++ b/WooCommerce/WooCommerceTests/ViewRelated/Bookings/BookingListViewModelTests.swift @@ -337,8 +337,8 @@ struct BookingListViewModelTests { viewModel.loadBookings() // Then - #expect(capturedStartDateAfter == "2021-01-01T00:00:00Z", "Today tab should filter after start of day") - #expect(capturedStartDateBefore == "2021-01-01T23:59:59Z", "Today tab should filter before end of day") + #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() { @@ -471,16 +471,16 @@ struct BookingListViewModelTests { // Given let testDate = Date(timeIntervalSince1970: 1609459200) // 2021-01-01 00:00:00 UTC let todayStart = testDate.startOfDay(timezone: BookingListTab.utcTimeZone) - let todayEnd = testDate.endOfDay(timezone: BookingListTab.utcTimeZone) + let nextDayStart = testDate.endOfDay(timezone: BookingListTab.utcTimeZone).addingTimeInterval(1) // Create bookings with different start dates - let withinTodayBooking = createBooking(id: 1, startDate: todayStart.addingTimeInterval(3600)) // 1 hour after start - let beforeTodayBooking = createBooking(id: 2, startDate: todayStart.addingTimeInterval(-3600)) // 1 hour before start - let afterTodayBooking = createBooking(id: 3, startDate: todayEnd.addingTimeInterval(3600)) // 1 hour after end - let atStartOfDayBooking = createBooking(id: 4, startDate: todayStart) // Exactly at start - let atEndOfDayBooking = createBooking(id: 5, startDate: todayEnd) // Exactly at end + 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, atEndOfDayBooking]) + insertBookings([withinTodayBooking, beforeTodayBooking, afterTodayBooking, atStartOfDayBooking, startOfNextDayBooking]) let viewModel = BookingListViewModel(siteID: sampleSiteID, type: .today, @@ -489,8 +489,8 @@ struct BookingListViewModelTests { currentDate: testDate) // When/Then - should only show bookings within today (startDate > start AND startDate < end) - #expect(viewModel.bookings.count == 1, "Today tab should only show bookings within today") - #expect(viewModel.bookings.first?.bookingID == withinTodayBooking.bookingID, "Should contain only the booking within today") + #expect(viewModel.bookings.count == 2) + #expect(viewModel.bookings.first?.bookingID == withinTodayBooking.bookingID) } @Test func upcoming_tab_results_controller_filters_local_storage_correctly() {