From b6cd96c8052d9f4695625be7c1d8c976faf54483 Mon Sep 17 00:00:00 2001 From: AdamGrzybkowski Date: Tue, 25 Nov 2025 21:20:45 +0100 Subject: [PATCH 1/6] Prefer BookingFilters.EMPTY over nullable property --- .../com/woocommerce/android/ui/bookings/BookingsRepository.kt | 2 +- .../android/ui/bookings/list/BookingListHandler.kt | 4 ++-- .../fluxc/network/rest/wpcom/wc/bookings/BookingsStore.kt | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/BookingsRepository.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/BookingsRepository.kt index 430e472e36a..c9ce7f699a3 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/BookingsRepository.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/BookingsRepository.kt @@ -24,7 +24,7 @@ class BookingsRepository @Inject constructor( page: Int, perPage: Int, query: String? = null, - filters: BookingFilters? = null, + filters: BookingFilters = BookingFilters.EMPTY, order: BookingsOrderOption ): Result { val result = bookingsStore.fetchBookings( diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/list/BookingListHandler.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/list/BookingListHandler.kt index fd8c92176f3..00c80631828 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/list/BookingListHandler.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/list/BookingListHandler.kt @@ -32,7 +32,7 @@ class BookingListHandler @Inject constructor( private val canLoadMore = AtomicBoolean(false) private val searchQuery = MutableStateFlow(null) - private val filters = MutableStateFlow(null) + private val filters = MutableStateFlow(BookingFilters.EMPTY) private val sortBy = MutableStateFlow(BookingListSortOption.NewestToOldest) private val searchResults = MutableStateFlow(emptyList()) @@ -57,7 +57,7 @@ class BookingListHandler @Inject constructor( suspend fun loadBookings( searchQuery: String? = null, - filters: BookingFilters? = null, + filters: BookingFilters = BookingFilters.EMPTY, sortBy: BookingListSortOption ): Result = mutex.withLock { // Reset pagination attributes diff --git a/libs/fluxc-plugin/src/main/kotlin/org/wordpress/android/fluxc/network/rest/wpcom/wc/bookings/BookingsStore.kt b/libs/fluxc-plugin/src/main/kotlin/org/wordpress/android/fluxc/network/rest/wpcom/wc/bookings/BookingsStore.kt index a77fa849358..a10b27e1002 100644 --- a/libs/fluxc-plugin/src/main/kotlin/org/wordpress/android/fluxc/network/rest/wpcom/wc/bookings/BookingsStore.kt +++ b/libs/fluxc-plugin/src/main/kotlin/org/wordpress/android/fluxc/network/rest/wpcom/wc/bookings/BookingsStore.kt @@ -32,7 +32,7 @@ class BookingsStore @Inject internal constructor( perPage: Int = BookingsRestClient.DEFAULT_PER_PAGE, page: Int = 1, query: String? = null, - filters: BookingFilters?, + filters: BookingFilters, order: BookingsOrderOption ): WooResult { return coroutineEngine.withDefaultContext(AppLog.T.API, this, "fetchBookings") { From 98c6ec884938d30a3627244366ffe6f8283f32f4 Mon Sep 17 00:00:00 2001 From: AdamGrzybkowski Date: Tue, 25 Nov 2025 21:21:16 +0100 Subject: [PATCH 2/6] Extract Today/Upcoming DateRange creation for reuse in other places --- .../list/BookingListFiltersBuilder.kt | 29 ++---------- .../wc/bookings/BookingsDateRangePresets.kt | 47 +++++++++++++++++++ 2 files changed, 52 insertions(+), 24 deletions(-) create mode 100644 libs/fluxc-plugin/src/main/kotlin/org/wordpress/android/fluxc/network/rest/wpcom/wc/bookings/BookingsDateRangePresets.kt diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/list/BookingListFiltersBuilder.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/list/BookingListFiltersBuilder.kt index 5cc42cd2c77..0dc71d6ed50 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/list/BookingListFiltersBuilder.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/list/BookingListFiltersBuilder.kt @@ -1,10 +1,8 @@ package com.woocommerce.android.ui.bookings.list +import org.wordpress.android.fluxc.network.rest.wpcom.wc.bookings.BookingsDateRangePresets import org.wordpress.android.fluxc.network.rest.wpcom.wc.bookings.BookingsFilterOption import java.time.Clock -import java.time.LocalDate -import java.time.LocalTime -import java.time.ZoneOffset import javax.inject.Inject class BookingListFiltersBuilder @Inject constructor( @@ -12,27 +10,10 @@ class BookingListFiltersBuilder @Inject constructor( ) { /** * Returns a [BookingsFilterOption.DateRange] based on the selected [BookingListTab]. - * - * We use UTC for the API calls, as the API stores the dates without timezone information, which means that - * when comparing dates, they are treated as UTC times. - * See p1759398245019489-slack-C09FHQNQERG */ - fun BookingListTab.asDateRangeFilter(): BookingsFilterOption.DateRange? { - fun todayAtMidnight() = LocalDate.now(clock).atTime(LocalTime.MIDNIGHT).atOffset(ZoneOffset.UTC).toInstant() - fun todayAtEndOfDay() = LocalDate.now(clock).atTime(LocalTime.MAX).atOffset(ZoneOffset.UTC).toInstant() - - return when (this) { - BookingListTab.Today -> BookingsFilterOption.DateRange( - before = todayAtEndOfDay(), - after = todayAtMidnight() - ) - - BookingListTab.Upcoming -> BookingsFilterOption.DateRange( - before = null, - after = todayAtEndOfDay() - ) - - BookingListTab.All -> null - } + fun BookingListTab.asDateRangeFilter(): BookingsFilterOption.DateRange? = when (this) { + BookingListTab.Today -> BookingsDateRangePresets.today(clock) + BookingListTab.Upcoming -> BookingsDateRangePresets.upcoming(clock) + BookingListTab.All -> null } } diff --git a/libs/fluxc-plugin/src/main/kotlin/org/wordpress/android/fluxc/network/rest/wpcom/wc/bookings/BookingsDateRangePresets.kt b/libs/fluxc-plugin/src/main/kotlin/org/wordpress/android/fluxc/network/rest/wpcom/wc/bookings/BookingsDateRangePresets.kt new file mode 100644 index 00000000000..a7e04c2fcb7 --- /dev/null +++ b/libs/fluxc-plugin/src/main/kotlin/org/wordpress/android/fluxc/network/rest/wpcom/wc/bookings/BookingsDateRangePresets.kt @@ -0,0 +1,47 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.wc.bookings + +import java.time.Clock +import java.time.LocalDate +import java.time.LocalTime +import java.time.ZoneOffset + +/** + * Factory for canonical booking date range presets used across app and data layers. + * + * Notes about time zone: + * The WC Bookings API stores dates without timezone information. To ensure + * consistent comparisons and filtering on both client and server, we construct + * ranges in UTC. + * See p1759398245019489-slack-C09FHQNQERG + */ +object BookingsDateRangePresets { + /** + * Returns a DateRange that spans the current day in UTC, inclusive of the + * full day when interpreted by the API (midnight to end-of-day). + */ + fun today(clock: Clock): BookingsFilterOption.DateRange { + val start = LocalDate.now(clock) + .atTime(LocalTime.MIDNIGHT) + .atOffset(ZoneOffset.UTC) + .toInstant() + val end = LocalDate.now(clock) + .atTime(LocalTime.MAX) + .atOffset(ZoneOffset.UTC) + .toInstant() + return BookingsFilterOption.DateRange(before = end, after = start) + } + + /** + * Returns a DateRange that includes bookings strictly after the end of the + * current day in UTC (i.e., upcoming bookings from tomorrow onwards). + * + * before = null and after = end-of-today. + */ + fun upcoming(clock: Clock): BookingsFilterOption.DateRange { + val endOfToday = LocalDate.now(clock) + .atTime(LocalTime.MAX) + .atOffset(ZoneOffset.UTC) + .toInstant() + return BookingsFilterOption.DateRange(before = null, after = endOfToday) + } +} From 99c37357ea175dafe1622efa1fe88ecc8b134c9c Mon Sep 17 00:00:00 2001 From: AdamGrzybkowski Date: Tue, 25 Nov 2025 21:21:45 +0100 Subject: [PATCH 3/6] Properly clear the Booking table for Today/Upcoming tabs With this change when we fetch the initial page with the DateRange filter matching Today or Upcoming range the Bookings table will be cleared only from the bookings that are not in the initial page load. --- .../rest/wpcom/wc/bookings/BookingsStore.kt | 33 ++- .../fluxc/persistence/dao/BookingsDao.kt | 33 +++ .../wpcom/wc/bookings/BookingsStoreTest.kt | 221 ++++++++++++++---- 3 files changed, 238 insertions(+), 49 deletions(-) diff --git a/libs/fluxc-plugin/src/main/kotlin/org/wordpress/android/fluxc/network/rest/wpcom/wc/bookings/BookingsStore.kt b/libs/fluxc-plugin/src/main/kotlin/org/wordpress/android/fluxc/network/rest/wpcom/wc/bookings/BookingsStore.kt index a10b27e1002..7b7c6b9bb4a 100644 --- a/libs/fluxc-plugin/src/main/kotlin/org/wordpress/android/fluxc/network/rest/wpcom/wc/bookings/BookingsStore.kt +++ b/libs/fluxc-plugin/src/main/kotlin/org/wordpress/android/fluxc/network/rest/wpcom/wc/bookings/BookingsStore.kt @@ -15,6 +15,7 @@ import org.wordpress.android.fluxc.store.WCOrderStore import org.wordpress.android.fluxc.tools.CoroutineEngine import org.wordpress.android.fluxc.utils.HeadersParser import org.wordpress.android.util.AppLog +import java.time.Clock import javax.inject.Inject import javax.inject.Singleton @@ -26,6 +27,7 @@ class BookingsStore @Inject internal constructor( private val bookingDtoMapper: BookingDtoMapper, private val headersParser: HeadersParser, private val coroutineEngine: CoroutineEngine, + private val clock: Clock, ) { suspend fun fetchBookings( site: SiteModel, @@ -54,9 +56,28 @@ class BookingsStore @Inject internal constructor( ) } } - if (page == 1 && filters == BookingFilters.EMPTY && query.isNullOrEmpty()) { - // Clear existing bookings and insert new ones when fetching the first page - bookingsDao.replaceAllForSite(site.localId(), entities) + // Clear existing bookings when fetching the first page. + // If filters are applied, only clear entries that match the applied filters, + // otherwise (no filters) clear all entries for the site. + if (page == 1 && query.isNullOrEmpty()) { + when { + filters == BookingFilters.EMPTY -> { + bookingsDao.replaceAllForSite(site.localId(), entities) + } + filters.dateRange != null && isTodayOrUpcoming(filters.dateRange) -> { + // Delete only the rows that match the applied date range filter for Today/Upcoming + bookingsDao.deleteForSiteWithDateRangeFilter( + site.localId(), + filters.dateRange, + entities.map { it.id.value } + ) + bookingsDao.insertOrReplace(entities) + } + else -> { + // For any other filters, avoid deletions to prevent removing unrelated cached items + bookingsDao.insertOrReplace(entities) + } + } } else { bookingsDao.insertOrReplace(entities) } @@ -77,6 +98,12 @@ class BookingsStore @Inject internal constructor( } } + private fun isTodayOrUpcoming(dateRange: BookingsFilterOption.DateRange): Boolean { + val today = BookingsDateRangePresets.today(clock) + val upcoming = BookingsDateRangePresets.upcoming(clock) + return dateRange == today || dateRange == upcoming + } + fun observeBookings( site: SiteModel, limit: Int? = null, diff --git a/libs/fluxc-plugin/src/main/kotlin/org/wordpress/android/fluxc/persistence/dao/BookingsDao.kt b/libs/fluxc-plugin/src/main/kotlin/org/wordpress/android/fluxc/persistence/dao/BookingsDao.kt index 25510451d87..10395921662 100644 --- a/libs/fluxc-plugin/src/main/kotlin/org/wordpress/android/fluxc/persistence/dao/BookingsDao.kt +++ b/libs/fluxc-plugin/src/main/kotlin/org/wordpress/android/fluxc/persistence/dao/BookingsDao.kt @@ -8,6 +8,7 @@ import androidx.room.Transaction import kotlinx.coroutines.flow.Flow import org.wordpress.android.fluxc.model.LocalOrRemoteId.LocalId import org.wordpress.android.fluxc.network.rest.wpcom.wc.bookings.BookingFilters +import org.wordpress.android.fluxc.network.rest.wpcom.wc.bookings.BookingsFilterOption import org.wordpress.android.fluxc.network.rest.wpcom.wc.bookings.BookingsOrderOption import org.wordpress.android.fluxc.persistence.entity.BookingEntity import org.wordpress.android.fluxc.persistence.entity.BookingResourceEntity @@ -70,6 +71,38 @@ interface BookingsDao { insertOrReplace(entities) } + @Suppress("LongParameterList") + @Query( + """ + DELETE FROM Bookings + WHERE localSiteId = :localSiteId + AND (:startDateBefore IS NULL OR start <= :startDateBefore) + AND (:startDateAfter IS NULL OR start >= :startDateAfter) + AND ((:idsSize = 0) OR id NOT IN (:ids)) + """ + ) + suspend fun deleteForSiteWithDateRangeFilter( + localSiteId: LocalId, + startDateBefore: Long?, + startDateAfter: Long?, + ids: List, + idsSize: Int, + ) + + suspend fun deleteForSiteWithDateRangeFilter( + localSiteId: LocalId, + dateRange: BookingsFilterOption.DateRange, + ids: List + ) { + deleteForSiteWithDateRangeFilter( + localSiteId = localSiteId, + startDateBefore = dateRange.before?.epochSecond, + startDateAfter = dateRange.after?.epochSecond, + ids = ids, + idsSize = ids.size, + ) + } + fun observeBookings( localSiteId: LocalId, limit: Int? = null, diff --git a/libs/fluxc-plugin/src/test/java/org/wordpress/android/fluxc/network/rest/wpcom/wc/bookings/BookingsStoreTest.kt b/libs/fluxc-plugin/src/test/java/org/wordpress/android/fluxc/network/rest/wpcom/wc/bookings/BookingsStoreTest.kt index bf35d29d0dd..b556389b136 100644 --- a/libs/fluxc-plugin/src/test/java/org/wordpress/android/fluxc/network/rest/wpcom/wc/bookings/BookingsStoreTest.kt +++ b/libs/fluxc-plugin/src/test/java/org/wordpress/android/fluxc/network/rest/wpcom/wc/bookings/BookingsStoreTest.kt @@ -26,6 +26,7 @@ import org.wordpress.android.fluxc.persistence.entity.OrderEntity import org.wordpress.android.fluxc.store.WCOrderStore import org.wordpress.android.fluxc.tools.CoroutineEngine import org.wordpress.android.fluxc.utils.HeadersParser +import java.time.Clock import java.time.Instant import kotlin.coroutines.EmptyCoroutineContext @@ -38,6 +39,7 @@ class BookingsStoreTest { private val bookingsDao: BookingsDao = mock() private val bookingDtoMapper: BookingDtoMapper = BookingDtoMapper(mock()) private val headersParser: HeadersParser = mock() + private val clock: Clock = Clock.systemUTC() @Before fun setUp() { @@ -47,63 +49,66 @@ class BookingsStoreTest { bookingsDao = bookingsDao, bookingDtoMapper = bookingDtoMapper, headersParser = headersParser, - coroutineEngine = CoroutineEngine(EmptyCoroutineContext, mock()) + coroutineEngine = CoroutineEngine(EmptyCoroutineContext, mock()), + clock = clock, ) } @Test - fun `given refreshOrder is false, when updateBooking succeeds, then inserts mapped entity and returns it`(): Unit = runBlocking { - // given - val site = SiteModel().apply { id = TEST_LOCAL_SITE_ID.value } - val dto = sampleBookingDto() - val storedBooking = sampleBookingEntity(order = BookingOrderInfo(productInfo = null)) - whenever(bookingsDao.getBooking(TEST_LOCAL_SITE_ID, dto.id)).thenReturn(storedBooking) - whenever(bookingsRestClient.updateBooking(site, dto.id, BookingUpdatePayload(note = "n"))) - .thenReturn(WooPayload(dto)) - whenever(bookingsDao.insertOrReplace(any())).thenReturn(1L) + fun `given refreshOrder is false, when updateBooking succeeds, then inserts mapped entity and returns it`(): Unit = + runBlocking { + // given + val site = SiteModel().apply { id = TEST_LOCAL_SITE_ID.value } + val dto = sampleBookingDto() + val storedBooking = sampleBookingEntity(order = BookingOrderInfo(productInfo = null)) + whenever(bookingsDao.getBooking(TEST_LOCAL_SITE_ID, dto.id)).thenReturn(storedBooking) + whenever(bookingsRestClient.updateBooking(site, dto.id, BookingUpdatePayload(note = "n"))) + .thenReturn(WooPayload(dto)) + whenever(bookingsDao.insertOrReplace(any())).thenReturn(1L) - // when - val result = sut.updateBooking( - site = site, - bookingId = dto.id, - bookingUpdatePayload = BookingUpdatePayload(note = "n"), - refreshOrder = false - ) + // when + val result = sut.updateBooking( + site = site, + bookingId = dto.id, + bookingUpdatePayload = BookingUpdatePayload(note = "n"), + refreshOrder = false + ) - // then - assertThat(result.isError).isFalse() - assertThat(result.model).isNotNull - // The store preserves the stored order on the mapped entity - verify(bookingsDao).insertOrReplace(argThat { this.order == storedBooking.order }) - } + // then + assertThat(result.isError).isFalse() + assertThat(result.model).isNotNull + // The store preserves the stored order on the mapped entity + verify(bookingsDao).insertOrReplace(argThat { this.order == storedBooking.order }) + } @Test - fun `given refreshOrder is true, when updateBooking succeeds, then refreshes order and inserts mapped entity`(): Unit = runBlocking { - // given - val site = SiteModel().apply { id = TEST_LOCAL_SITE_ID.value } - val dto = sampleBookingDto() + fun `given refreshOrder is true, when updateBooking succeeds, then refreshes order and inserts mapped entity`(): Unit = + runBlocking { + // given + val site = SiteModel().apply { id = TEST_LOCAL_SITE_ID.value } + val dto = sampleBookingDto() - val fetchedOrder = OrderEntity(orderId = dto.orderId, localSiteId = TEST_LOCAL_SITE_ID) - whenever(orderStore.fetchSingleOrderSync(site, dto.orderId)).thenReturn(WooResult(fetchedOrder)) - whenever(bookingsRestClient.updateBooking(site, dto.id, BookingUpdatePayload(status = Status.Confirmed))) - .thenReturn(WooPayload(dto)) - whenever(bookingsDao.insertOrReplace(any())).thenReturn(1L) + val fetchedOrder = OrderEntity(orderId = dto.orderId, localSiteId = TEST_LOCAL_SITE_ID) + whenever(orderStore.fetchSingleOrderSync(site, dto.orderId)).thenReturn(WooResult(fetchedOrder)) + whenever(bookingsRestClient.updateBooking(site, dto.id, BookingUpdatePayload(status = Status.Confirmed))) + .thenReturn(WooPayload(dto)) + whenever(bookingsDao.insertOrReplace(any())).thenReturn(1L) - // when - val result = sut.updateBooking( - site = site, - bookingId = dto.id, - bookingUpdatePayload = BookingUpdatePayload(status = Status.Confirmed), - refreshOrder = true - ) + // when + val result = sut.updateBooking( + site = site, + bookingId = dto.id, + bookingUpdatePayload = BookingUpdatePayload(status = Status.Confirmed), + refreshOrder = true + ) - // then - assertThat(result.isError).isFalse() - val expected = with(bookingDtoMapper) { dto.toEntity(TEST_LOCAL_SITE_ID, fetchedOrder) } - assertThat(result.model).isEqualTo(expected) - verify(bookingsDao).insertOrReplace(expected) - verify(bookingsDao, never()).getBooking(TEST_LOCAL_SITE_ID, dto.id) - } + // then + assertThat(result.isError).isFalse() + val expected = with(bookingDtoMapper) { dto.toEntity(TEST_LOCAL_SITE_ID, fetchedOrder) } + assertThat(result.model).isEqualTo(expected) + verify(bookingsDao).insertOrReplace(expected) + verify(bookingsDao, never()).getBooking(TEST_LOCAL_SITE_ID, dto.id) + } @Test fun `given rest client fails, when updateBooking, then returns error and does not insert`(): Unit = runBlocking { @@ -169,6 +174,130 @@ class BookingsStoreTest { verify(bookingsDao).insertOrReplace(argThat { this.order == storedBooking.order }) } + @Test + fun `given page 1 and today date filter and no query, when fetchBookings, then delete matching and insert is called`(): Unit = + runBlocking { + // given + val site = SiteModel().apply { id = TEST_LOCAL_SITE_ID.value } + val dto = sampleBookingDto().copy(orderId = 0L) // avoid order fetch + val filters = BookingFilters( + dateRange = BookingsDateRangePresets.today(clock) + ) + whenever( + bookingsRestClient.fetchBookings( + site, + perPage = 25, + page = 1, + query = null, + filters = filters, + order = BookingsOrderOption.DESC + ) + ) + .thenReturn(WooPayload(arrayOf(dto))) + whenever(headersParser.getTotalPages(any())).thenReturn(null) + + // when + val result = sut.fetchBookings( + site = site, + perPage = 25, + page = 1, + query = null, + filters = filters, + order = BookingsOrderOption.DESC + ) + + // then + assertThat(result.isError).isFalse() + // We delete only matching filtered rows then insert the fetched page + verify(bookingsDao).deleteForSiteWithDateRangeFilter( + any(), + any(), + any>() + ) + verify(bookingsDao).insertOrReplace(any>()) + verify(bookingsDao, never()).replaceAllForSite(any(), any()) + } + + @Test + fun `given page greater than 1 with multiple filters, when fetchBookings, then insertOrReplace is called`(): Unit = + runBlocking { + // given + val site = SiteModel().apply { id = TEST_LOCAL_SITE_ID.value } + val dto = sampleBookingDto().copy(orderId = 0L) + val filters = BookingFilters( + dateRange = BookingsFilterOption.DateRange(before = Instant.now(), after = Instant.now()), + customer = BookingsFilterOption.Customer(customerId = 1L, customerName = "name") + ) + whenever( + bookingsRestClient.fetchBookings( + site, + perPage = 25, + page = 2, + query = null, + filters = filters, + order = BookingsOrderOption.DESC + ) + ) + .thenReturn(WooPayload(arrayOf(dto))) + whenever(headersParser.getTotalPages(any())).thenReturn(null) + + // when + val result = sut.fetchBookings( + site = site, + perPage = 25, + page = 2, + query = null, + filters = filters, + order = BookingsOrderOption.DESC + ) + + // then + assertThat(result.isError).isFalse() + verify(bookingsDao).insertOrReplace(any>()) + verify(bookingsDao, never()).replaceAllForSite(any(), any()) + verify(bookingsDao, never()) + .deleteForSiteWithDateRangeFilter(any(), any(), any>()) + } + + @Test + fun `given page 1 with no filters and no query, when fetchBookings, then replaceAllForSite is called`(): Unit = + runBlocking { + // given + val site = SiteModel().apply { id = TEST_LOCAL_SITE_ID.value } + val dto = sampleBookingDto().copy(orderId = 0L) + val filters: BookingFilters = BookingFilters.EMPTY + whenever( + bookingsRestClient.fetchBookings( + site, + perPage = 25, + page = 1, + query = null, + filters = filters, + order = BookingsOrderOption.DESC + ) + ).thenReturn(WooPayload(arrayOf(dto))) + whenever(headersParser.getTotalPages(any())).thenReturn(null) + + // when + val result = sut.fetchBookings( + site = site, + perPage = 25, + page = 1, + query = null, + filters = filters, + order = BookingsOrderOption.DESC + ) + + // then + assertThat(result.isError).isFalse() + verify(bookingsDao).replaceAllForSite(any(), any()) + verify(bookingsDao, never()).deleteForSiteWithDateRangeFilter( + any(), + any(), + any>() + ) + } + private fun sampleBookingDto(): BookingDto = BookingDto( id = TEST_BOOKING_ID, start = Instant.now().epochSecond, From 5ef2be3b6791405c95a919eb123e26b54986197c Mon Sep 17 00:00:00 2001 From: AdamGrzybkowski Date: Wed, 26 Nov 2025 10:55:27 +0100 Subject: [PATCH 4/6] Make sure that only date range matching today/upcoming is applied --- .../rest/wpcom/wc/bookings/BookingsStore.kt | 15 +- .../wpcom/wc/bookings/BookingsStoreTest.kt | 131 +++++++++++++++++- 2 files changed, 139 insertions(+), 7 deletions(-) diff --git a/libs/fluxc-plugin/src/main/kotlin/org/wordpress/android/fluxc/network/rest/wpcom/wc/bookings/BookingsStore.kt b/libs/fluxc-plugin/src/main/kotlin/org/wordpress/android/fluxc/network/rest/wpcom/wc/bookings/BookingsStore.kt index 7b7c6b9bb4a..eeb8a79cffb 100644 --- a/libs/fluxc-plugin/src/main/kotlin/org/wordpress/android/fluxc/network/rest/wpcom/wc/bookings/BookingsStore.kt +++ b/libs/fluxc-plugin/src/main/kotlin/org/wordpress/android/fluxc/network/rest/wpcom/wc/bookings/BookingsStore.kt @@ -64,7 +64,8 @@ class BookingsStore @Inject internal constructor( filters == BookingFilters.EMPTY -> { bookingsDao.replaceAllForSite(site.localId(), entities) } - filters.dateRange != null && isTodayOrUpcoming(filters.dateRange) -> { + + filters.dateRange != null && filters.isTodayOrUpcoming -> { // Delete only the rows that match the applied date range filter for Today/Upcoming bookingsDao.deleteForSiteWithDateRangeFilter( site.localId(), @@ -73,6 +74,7 @@ class BookingsStore @Inject internal constructor( ) bookingsDao.insertOrReplace(entities) } + else -> { // For any other filters, avoid deletions to prevent removing unrelated cached items bookingsDao.insertOrReplace(entities) @@ -98,11 +100,12 @@ class BookingsStore @Inject internal constructor( } } - private fun isTodayOrUpcoming(dateRange: BookingsFilterOption.DateRange): Boolean { - val today = BookingsDateRangePresets.today(clock) - val upcoming = BookingsDateRangePresets.upcoming(clock) - return dateRange == today || dateRange == upcoming - } + private val BookingFilters.isTodayOrUpcoming: Boolean + get() { + val today = BookingsDateRangePresets.today(clock) + val upcoming = BookingsDateRangePresets.upcoming(clock) + return (dateRange == today || dateRange == upcoming) && enabledFiltersCount == 1 + } fun observeBookings( site: SiteModel, diff --git a/libs/fluxc-plugin/src/test/java/org/wordpress/android/fluxc/network/rest/wpcom/wc/bookings/BookingsStoreTest.kt b/libs/fluxc-plugin/src/test/java/org/wordpress/android/fluxc/network/rest/wpcom/wc/bookings/BookingsStoreTest.kt index b556389b136..df878774a4d 100644 --- a/libs/fluxc-plugin/src/test/java/org/wordpress/android/fluxc/network/rest/wpcom/wc/bookings/BookingsStoreTest.kt +++ b/libs/fluxc-plugin/src/test/java/org/wordpress/android/fluxc/network/rest/wpcom/wc/bookings/BookingsStoreTest.kt @@ -192,8 +192,94 @@ class BookingsStoreTest { filters = filters, order = BookingsOrderOption.DESC ) + ).thenReturn(WooPayload(arrayOf(dto))) + whenever(headersParser.getTotalPages(any())).thenReturn(null) + + // when + val result = sut.fetchBookings( + site = site, + perPage = 25, + page = 1, + query = null, + filters = filters, + order = BookingsOrderOption.DESC ) - .thenReturn(WooPayload(arrayOf(dto))) + + // then + assertThat(result.isError).isFalse() + // We delete only matching filtered rows then insert the fetched page + verify(bookingsDao).deleteForSiteWithDateRangeFilter( + any(), + any(), + any>() + ) + verify(bookingsDao).insertOrReplace(any>()) + verify(bookingsDao, never()).replaceAllForSite(any(), any()) + } + + @Test + fun `given page 1, today date filter, customer filter and no query, when fetchBookings, then only insertOrReplace is called`(): Unit = + runBlocking { + // given + val site = SiteModel().apply { id = TEST_LOCAL_SITE_ID.value } + val dto = sampleBookingDto().copy(orderId = 0L) // avoid order fetch + val filters = BookingFilters( + dateRange = BookingsDateRangePresets.today(clock), + customer = BookingsFilterOption.Customer(customerId = 1L, customerName = "name"), + ) + whenever( + bookingsRestClient.fetchBookings( + site, + perPage = 25, + page = 1, + query = null, + filters = filters, + order = BookingsOrderOption.DESC + ) + ).thenReturn(WooPayload(arrayOf(dto))) + whenever(headersParser.getTotalPages(any())).thenReturn(null) + + // when + val result = sut.fetchBookings( + site = site, + perPage = 25, + page = 1, + query = null, + filters = filters, + order = BookingsOrderOption.DESC + ) + + // then + assertThat(result.isError).isFalse() + // We delete only matching filtered rows then insert the fetched page + verify(bookingsDao).insertOrReplace(any>()) + verify(bookingsDao, never()).deleteForSiteWithDateRangeFilter( + any(), + any(), + any>() + ) + verify(bookingsDao, never()).replaceAllForSite(any(), any()) + } + + @Test + fun `given page 1 and upcoming date filter and no query, when fetchBookings, then delete matching and insert is called`(): Unit = + runBlocking { + // given + val site = SiteModel().apply { id = TEST_LOCAL_SITE_ID.value } + val dto = sampleBookingDto().copy(orderId = 0L) // avoid order fetch + val filters = BookingFilters( + dateRange = BookingsDateRangePresets.upcoming(clock) + ) + whenever( + bookingsRestClient.fetchBookings( + site, + perPage = 25, + page = 1, + query = null, + filters = filters, + order = BookingsOrderOption.DESC + ) + ).thenReturn(WooPayload(arrayOf(dto))) whenever(headersParser.getTotalPages(any())).thenReturn(null) // when @@ -298,6 +384,49 @@ class BookingsStoreTest { ) } + @Test + fun `given page 1 with custom date range filter and no query, when fetchBookings, then only insertOrReplace is called`(): Unit = + runBlocking { + // given + val site = SiteModel().apply { id = TEST_LOCAL_SITE_ID.value } + val dto = sampleBookingDto().copy(orderId = 0L) + val filters = BookingFilters( + dateRange = BookingsFilterOption.DateRange(before = Instant.now(), after = Instant.now()), + customer = BookingsFilterOption.Customer(customerId = 1L, customerName = "name"), + ) + whenever( + bookingsRestClient.fetchBookings( + site, + perPage = 25, + page = 1, + query = null, + filters = filters, + order = BookingsOrderOption.DESC + ) + ).thenReturn(WooPayload(arrayOf(dto))) + whenever(headersParser.getTotalPages(any())).thenReturn(null) + + // when + val result = sut.fetchBookings( + site = site, + perPage = 25, + page = 1, + query = null, + filters = filters, + order = BookingsOrderOption.DESC + ) + + // then + assertThat(result.isError).isFalse() + verify(bookingsDao).insertOrReplace(any>()) + verify(bookingsDao, never()).replaceAllForSite(any(), any()) + verify(bookingsDao, never()).deleteForSiteWithDateRangeFilter( + any(), + any(), + any>() + ) + } + private fun sampleBookingDto(): BookingDto = BookingDto( id = TEST_BOOKING_ID, start = Instant.now().epochSecond, From e19f6d12213b40a82d167bb05236a695fa78ba00 Mon Sep 17 00:00:00 2001 From: AdamGrzybkowski Date: Thu, 27 Nov 2025 09:49:50 +0100 Subject: [PATCH 5/6] Improve BookingsDateRangePresets --- .../wc/bookings/BookingsDateRangePresets.kt | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/libs/fluxc-plugin/src/main/kotlin/org/wordpress/android/fluxc/network/rest/wpcom/wc/bookings/BookingsDateRangePresets.kt b/libs/fluxc-plugin/src/main/kotlin/org/wordpress/android/fluxc/network/rest/wpcom/wc/bookings/BookingsDateRangePresets.kt index a7e04c2fcb7..cff43d4ca28 100644 --- a/libs/fluxc-plugin/src/main/kotlin/org/wordpress/android/fluxc/network/rest/wpcom/wc/bookings/BookingsDateRangePresets.kt +++ b/libs/fluxc-plugin/src/main/kotlin/org/wordpress/android/fluxc/network/rest/wpcom/wc/bookings/BookingsDateRangePresets.kt @@ -1,6 +1,7 @@ package org.wordpress.android.fluxc.network.rest.wpcom.wc.bookings import java.time.Clock +import java.time.Instant import java.time.LocalDate import java.time.LocalTime import java.time.ZoneOffset @@ -24,24 +25,23 @@ object BookingsDateRangePresets { .atTime(LocalTime.MIDNIGHT) .atOffset(ZoneOffset.UTC) .toInstant() - val end = LocalDate.now(clock) - .atTime(LocalTime.MAX) - .atOffset(ZoneOffset.UTC) - .toInstant() + val end = getEndOfToday(clock) return BookingsFilterOption.DateRange(before = end, after = start) } /** * Returns a DateRange that includes bookings strictly after the end of the * current day in UTC (i.e., upcoming bookings from tomorrow onwards). - * - * before = null and after = end-of-today. */ fun upcoming(clock: Clock): BookingsFilterOption.DateRange { - val endOfToday = LocalDate.now(clock) + val endOfToday = getEndOfToday(clock) + return BookingsFilterOption.DateRange(before = null, after = endOfToday) + } + + private fun getEndOfToday(clock: Clock): Instant { + return LocalDate.now(clock) .atTime(LocalTime.MAX) .atOffset(ZoneOffset.UTC) .toInstant() - return BookingsFilterOption.DateRange(before = null, after = endOfToday) } } From c2cf448d3d6c2aa9a399a0496b2bf0695f6dc32b Mon Sep 17 00:00:00 2001 From: AdamGrzybkowski Date: Thu, 27 Nov 2025 13:23:31 +0100 Subject: [PATCH 6/6] Wrap delete and insert in one DB transaction --- .../rest/wpcom/wc/bookings/BookingsStore.kt | 6 ++--- .../fluxc/persistence/dao/BookingsDao.kt | 19 ++++++++++++++- .../wpcom/wc/bookings/BookingsStoreTest.kt | 24 +++++++++---------- 3 files changed, 31 insertions(+), 18 deletions(-) diff --git a/libs/fluxc-plugin/src/main/kotlin/org/wordpress/android/fluxc/network/rest/wpcom/wc/bookings/BookingsStore.kt b/libs/fluxc-plugin/src/main/kotlin/org/wordpress/android/fluxc/network/rest/wpcom/wc/bookings/BookingsStore.kt index eeb8a79cffb..01ba0f05956 100644 --- a/libs/fluxc-plugin/src/main/kotlin/org/wordpress/android/fluxc/network/rest/wpcom/wc/bookings/BookingsStore.kt +++ b/libs/fluxc-plugin/src/main/kotlin/org/wordpress/android/fluxc/network/rest/wpcom/wc/bookings/BookingsStore.kt @@ -66,13 +66,11 @@ class BookingsStore @Inject internal constructor( } filters.dateRange != null && filters.isTodayOrUpcoming -> { - // Delete only the rows that match the applied date range filter for Today/Upcoming - bookingsDao.deleteForSiteWithDateRangeFilter( + bookingsDao.cleanAndUpsertBookings( site.localId(), filters.dateRange, - entities.map { it.id.value } + entities ) - bookingsDao.insertOrReplace(entities) } else -> { diff --git a/libs/fluxc-plugin/src/main/kotlin/org/wordpress/android/fluxc/persistence/dao/BookingsDao.kt b/libs/fluxc-plugin/src/main/kotlin/org/wordpress/android/fluxc/persistence/dao/BookingsDao.kt index 10395921662..0bd1497b97e 100644 --- a/libs/fluxc-plugin/src/main/kotlin/org/wordpress/android/fluxc/persistence/dao/BookingsDao.kt +++ b/libs/fluxc-plugin/src/main/kotlin/org/wordpress/android/fluxc/persistence/dao/BookingsDao.kt @@ -89,7 +89,7 @@ interface BookingsDao { idsSize: Int, ) - suspend fun deleteForSiteWithDateRangeFilter( + private suspend fun deleteForSiteWithDateRangeFilter( localSiteId: LocalId, dateRange: BookingsFilterOption.DateRange, ids: List @@ -103,6 +103,23 @@ interface BookingsDao { ) } + /** + * Delete Booking entities that are not present in the new list and then insert the new entities + */ + @Transaction + suspend fun cleanAndUpsertBookings( + localSiteId: LocalId, + dateRange: BookingsFilterOption.DateRange, + entities: List, + ) { + deleteForSiteWithDateRangeFilter( + localSiteId = localSiteId, + dateRange = dateRange, + ids = entities.map { it.id.value }, + ) + insertOrReplace(entities) + } + fun observeBookings( localSiteId: LocalId, limit: Int? = null, diff --git a/libs/fluxc-plugin/src/test/java/org/wordpress/android/fluxc/network/rest/wpcom/wc/bookings/BookingsStoreTest.kt b/libs/fluxc-plugin/src/test/java/org/wordpress/android/fluxc/network/rest/wpcom/wc/bookings/BookingsStoreTest.kt index df878774a4d..546f920ca2e 100644 --- a/libs/fluxc-plugin/src/test/java/org/wordpress/android/fluxc/network/rest/wpcom/wc/bookings/BookingsStoreTest.kt +++ b/libs/fluxc-plugin/src/test/java/org/wordpress/android/fluxc/network/rest/wpcom/wc/bookings/BookingsStoreTest.kt @@ -208,12 +208,11 @@ class BookingsStoreTest { // then assertThat(result.isError).isFalse() // We delete only matching filtered rows then insert the fetched page - verify(bookingsDao).deleteForSiteWithDateRangeFilter( + verify(bookingsDao).cleanAndUpsertBookings( any(), any(), - any>() + any>() ) - verify(bookingsDao).insertOrReplace(any>()) verify(bookingsDao, never()).replaceAllForSite(any(), any()) } @@ -253,10 +252,10 @@ class BookingsStoreTest { assertThat(result.isError).isFalse() // We delete only matching filtered rows then insert the fetched page verify(bookingsDao).insertOrReplace(any>()) - verify(bookingsDao, never()).deleteForSiteWithDateRangeFilter( + verify(bookingsDao, never()).cleanAndUpsertBookings( any(), any(), - any>() + any>() ) verify(bookingsDao, never()).replaceAllForSite(any(), any()) } @@ -295,12 +294,11 @@ class BookingsStoreTest { // then assertThat(result.isError).isFalse() // We delete only matching filtered rows then insert the fetched page - verify(bookingsDao).deleteForSiteWithDateRangeFilter( + verify(bookingsDao).cleanAndUpsertBookings( any(), any(), - any>() + any>() ) - verify(bookingsDao).insertOrReplace(any>()) verify(bookingsDao, never()).replaceAllForSite(any(), any()) } @@ -342,7 +340,7 @@ class BookingsStoreTest { verify(bookingsDao).insertOrReplace(any>()) verify(bookingsDao, never()).replaceAllForSite(any(), any()) verify(bookingsDao, never()) - .deleteForSiteWithDateRangeFilter(any(), any(), any>()) + .cleanAndUpsertBookings(any(), any(), any>()) } @Test @@ -377,10 +375,10 @@ class BookingsStoreTest { // then assertThat(result.isError).isFalse() verify(bookingsDao).replaceAllForSite(any(), any()) - verify(bookingsDao, never()).deleteForSiteWithDateRangeFilter( + verify(bookingsDao, never()).cleanAndUpsertBookings( any(), any(), - any>() + any>() ) } @@ -420,10 +418,10 @@ class BookingsStoreTest { assertThat(result.isError).isFalse() verify(bookingsDao).insertOrReplace(any>()) verify(bookingsDao, never()).replaceAllForSite(any(), any()) - verify(bookingsDao, never()).deleteForSiteWithDateRangeFilter( + verify(bookingsDao, never()).cleanAndUpsertBookings( any(), any(), - any>() + any>() ) }