From 86451559d94f75b3fdf89018536c92e8b7a8057c Mon Sep 17 00:00:00 2001 From: AdamGrzybkowski Date: Wed, 15 Oct 2025 15:24:56 +0200 Subject: [PATCH 1/3] Format booking duration following web's logic --- .../android/ui/bookings/BookingMapper.kt | 114 +++++++++- WooCommerce/src/main/res/values/strings.xml | 8 + .../android/ui/bookings/BookingMapperTest.kt | 202 +++++++++++++++++- .../details/BookingDetailsViewModelTest.kt | 16 +- .../bookings/list/BookingListViewModelTest.kt | 4 +- 5 files changed, 337 insertions(+), 7 deletions(-) diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/BookingMapper.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/BookingMapper.kt index ccc3e9b30bbd..dc823289f03f 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/BookingMapper.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/BookingMapper.kt @@ -15,6 +15,7 @@ import com.woocommerce.android.ui.bookings.compose.BookingSummaryModel import com.woocommerce.android.ui.bookings.details.CancelStatus import com.woocommerce.android.ui.bookings.list.BookingListItem import com.woocommerce.android.util.CurrencyFormatter +import com.woocommerce.android.viewmodel.ResourceProvider import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.wordpress.android.fluxc.network.rest.wpcom.wc.bookings.BookingCustomerInfo @@ -29,7 +30,8 @@ import javax.inject.Inject class BookingMapper @Inject constructor( private val currencyFormatter: CurrencyFormatter, - private val getLocations: GetLocations + private val getLocations: GetLocations, + private val resourceProvider: ResourceProvider ) { private val summaryDateFormatter: DateTimeFormatter = DateTimeFormatter.ofLocalizedDateTime( FormatStyle.MEDIUM, @@ -62,16 +64,18 @@ class BookingMapper @Inject constructor( staffMemberStatus: BookingStaffMemberStatus?, cancelStatus: CancelStatus, ): BookingAppointmentDetailsModel { - val durationMinutes = Duration.between(start, end).toMinutes() + val duration = Duration.between(start, end) + .normalizeBookingDuration() + .toHumanReadableFormat() return BookingAppointmentDetailsModel( date = detailsDateFormatter.format(start), time = "${timeRangeFormatter.format(start)} - ${timeRangeFormatter.format(end)}", staff = staffMemberStatus, // TODO replace mocked values when available from API location = "238 Willow Creek Drive, Montgomery AL 36109", - duration = "$durationMinutes min", price = currencyFormatter.formatCurrency(cost, currency), cancelStatus = cancelStatus, + duration = duration, ) } @@ -131,6 +135,110 @@ class BookingMapper @Inject constructor( ) } + /** + * Normalize booking duration by adjusting for precision issues. + * + * This function handles cases where a booking duration is very close to + * common time boundaries (days/hours) but falls short due to precision issues. + * It rounds up durations that are within one minute of these boundaries. + */ + private fun Duration.normalizeBookingDuration(): Duration { + val dayInSeconds = Duration.ofDays(1).seconds + val hourInSeconds = Duration.ofHours(1).seconds + val minuteInSeconds = Duration.ofMinutes(1).seconds + + var durationInSeconds = this.seconds + val boundaries = listOf(dayInSeconds, hourInSeconds) + for (boundary in boundaries) { + val remainder = durationInSeconds % boundary + val difference = if (remainder == 0L) 0L else boundary - remainder + if (difference > 0 && difference <= minuteInSeconds) { + durationInSeconds += difference + } + } + return Duration.ofSeconds(durationInSeconds) + } + + @Suppress("LongMethod") + private fun Duration.toHumanReadableFormat(): String { + val totalSeconds = seconds + val dayInSeconds = Duration.ofDays(1).toSeconds() + val hourInSeconds = Duration.ofHours(1).toSeconds() + val minuteInSeconds = Duration.ofMinutes(1).toSeconds() + + return when { + totalSeconds >= dayInSeconds -> { + val days = (totalSeconds / dayInSeconds).toInt() + val hours = ((totalSeconds % dayInSeconds) / hourInSeconds).toInt() + val minutes = ((totalSeconds % hourInSeconds) / minuteInSeconds).toInt() + + val parts = mutableListOf() + parts += resourceProvider.getQuantityString( + quantity = days, + default = R.string.booking_duration_days, + one = R.string.booking_duration_day + ) + if (hours > 0) { + parts += resourceProvider.getQuantityString( + quantity = hours, + default = R.string.booking_duration_hours, + one = R.string.booking_duration_hour + ) + } + if (minutes > 0) { + parts += resourceProvider.getQuantityString( + quantity = minutes, + default = R.string.booking_duration_minutes, + one = R.string.booking_duration_minute + ) + } + parts.joinToString(separator = " ") + } + + totalSeconds >= hourInSeconds -> { + val hours = (totalSeconds / hourInSeconds).toInt() + val minutes = ((totalSeconds % hourInSeconds) / minuteInSeconds).toInt() + if (minutes == 0) { + resourceProvider.getQuantityString( + quantity = hours, + default = R.string.booking_duration_hours, + one = R.string.booking_duration_hour + ) + } else { + val hoursPart = resourceProvider.getQuantityString( + quantity = hours, + default = R.string.booking_duration_hours, + one = R.string.booking_duration_hour + ) + val minutesPart = resourceProvider.getQuantityString( + quantity = minutes, + default = R.string.booking_duration_minutes, + one = R.string.booking_duration_minute + ) + "$hoursPart $minutesPart" + } + } + + totalSeconds >= minuteInSeconds -> { + val minutes = (totalSeconds / minuteInSeconds).toInt() + resourceProvider.getQuantityString( + quantity = minutes, + default = R.string.booking_duration_minutes, + one = R.string.booking_duration_minute + ) + } + + else -> { + val seconds = totalSeconds.toInt() + resourceProvider.getQuantityString( + quantity = seconds, + default = R.string.booking_duration_seconds, + one = R.string.booking_duration_second + ) + } + } + } + private suspend fun BookingCustomerInfo.address(): Address? { val countryCode = billingCountry ?: return null val (country, state) = withContext(Dispatchers.IO) { diff --git a/WooCommerce/src/main/res/values/strings.xml b/WooCommerce/src/main/res/values/strings.xml index e7e0d642b324..f11172b53a70 100644 --- a/WooCommerce/src/main/res/values/strings.xml +++ b/WooCommerce/src/main/res/values/strings.xml @@ -4234,6 +4234,14 @@ Mark as paid View order Error fetching booking + %1$d minute + %1$d minutes + %1$d hour + %1$d hours + %1$d day + %1$d days + %1$d second + %1$d seconds Use password to sign in About %1$s diff --git a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/bookings/BookingMapperTest.kt b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/bookings/BookingMapperTest.kt index ee1081feb77f..f586517618cb 100644 --- a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/bookings/BookingMapperTest.kt +++ b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/bookings/BookingMapperTest.kt @@ -9,11 +9,13 @@ import com.woocommerce.android.ui.bookings.compose.BookingStatus import com.woocommerce.android.ui.bookings.details.CancelStatus import com.woocommerce.android.util.CurrencyFormatter import com.woocommerce.android.viewmodel.BaseUnitTest +import com.woocommerce.android.viewmodel.ResourceProvider import kotlinx.coroutines.ExperimentalCoroutinesApi import org.assertj.core.api.Assertions.assertThat import org.junit.Before import org.junit.Test import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.doAnswer import org.mockito.kotlin.eq import org.mockito.kotlin.mock @@ -42,11 +44,58 @@ class BookingMapperTest : BaseUnitTest() { } } private val getLocations: GetLocations = mock() + private val resourceProvider: ResourceProvider = mock() private lateinit var mapper: BookingMapper @Before fun setup() { - mapper = BookingMapper(currencyFormatter, getLocations) + // Stub ResourceProvider localized strings for durations using quantity strings + whenever( + resourceProvider.getQuantityString( + quantity = any(), + default = eq(com.woocommerce.android.R.string.booking_duration_minutes), + zero = anyOrNull(), + one = eq(com.woocommerce.android.R.string.booking_duration_minute) + ) + ).thenAnswer { + val qty = it.getArgument(0) + if (qty == 1) "$qty minute" else "$qty minutes" + } + whenever( + resourceProvider.getQuantityString( + quantity = any(), + default = eq(com.woocommerce.android.R.string.booking_duration_hours), + zero = anyOrNull(), + one = eq(com.woocommerce.android.R.string.booking_duration_hour) + ) + ).thenAnswer { + val qty = it.getArgument(0) + if (qty == 1) "$qty hour" else "$qty hours" + } + whenever( + resourceProvider.getQuantityString( + quantity = any(), + default = eq(com.woocommerce.android.R.string.booking_duration_days), + zero = anyOrNull(), + one = eq(com.woocommerce.android.R.string.booking_duration_day) + ) + ).thenAnswer { + val qty = it.getArgument(0) + if (qty == 1) "$qty day" else "$qty days" + } + whenever( + resourceProvider.getQuantityString( + quantity = any(), + default = eq(com.woocommerce.android.R.string.booking_duration_seconds), + zero = anyOrNull(), + one = eq(com.woocommerce.android.R.string.booking_duration_second) + ) + ).thenAnswer { + val qty = it.getArgument(0) + if (qty == 1) "$qty second" else "$qty seconds" + } + + mapper = BookingMapper(currencyFormatter, getLocations, resourceProvider) } @Test @@ -108,7 +157,7 @@ class BookingMapperTest : BaseUnitTest() { assertThat(model.time).isEqualTo(expectedTime) assertThat(model.staff).isEqualTo(staffMemberStatus) assertThat(model.location).isEqualTo("238 Willow Creek Drive, Montgomery AL 36109") - assertThat(model.duration).isEqualTo("90 min") + assertThat(model.duration).isEqualTo("1 hour 30 minutes") assertThat(model.price).isEqualTo("$55.00") assertThat(model.cancelStatus).isEqualTo(CancelStatus.Idle) } @@ -227,6 +276,155 @@ class BookingMapperTest : BaseUnitTest() { ) } + @Test + fun `given duration under one hour, when mapped to appointment details, then formats minutes`() { + // GIVEN + val start = Instant.parse("2025-07-05T11:00:00Z") + val end = start.plusSeconds(45 * 60) // 45 minutes + val booking = sampleBooking( + start = start, + end = end, + ) + val staffMemberStatus = BookingStaffMemberStatus.Loaded("Alex Doe") + whenever(currencyFormatter.formatCurrency(eq("0.00"), eq("USD"), eq(true))).thenReturn("$0.00") + + // WHEN + val model = mapper.run { booking.toAppointmentDetailsModel(staffMemberStatus, CancelStatus.Idle) } + + // THEN + assertThat(model.duration).isEqualTo("45 minutes") + } + + @Test + fun `given duration is exact hours, when mapped to appointment details, then formats hours only`() { + // GIVEN + val start = Instant.parse("2025-07-05T11:00:00Z") + val end = start.plus(Duration.ofHours(2)) // 2 hours + val booking = sampleBooking( + start = start, + end = end, + ) + val staffMemberStatus = BookingStaffMemberStatus.Loaded("Alex Doe") + whenever(currencyFormatter.formatCurrency(eq("0.00"), eq("USD"), eq(true))).thenReturn("$0.00") + + // WHEN + val model = mapper.run { booking.toAppointmentDetailsModel(staffMemberStatus, CancelStatus.Idle) } + + // THEN + assertThat(model.duration).isEqualTo("2 hours") + } + + @Test + fun `given duration is exact days, when mapped to appointment details, then formats days only`() { + // GIVEN + val start = Instant.parse("2025-07-05T11:00:00Z") + val end = start.plus(Duration.ofHours(24)) // 1 day + val booking = sampleBooking( + start = start, + end = end, + ) + val staffMemberStatus = BookingStaffMemberStatus.Loaded("Alex Doe") + whenever(currencyFormatter.formatCurrency(eq("0.00"), eq("USD"), eq(true))).thenReturn("$0.00") + + // WHEN + val model = mapper.run { booking.toAppointmentDetailsModel(staffMemberStatus, CancelStatus.Idle) } + + // THEN + assertThat(model.duration).isEqualTo("1 day") + } + + @Test + fun `given duration is days plus hours, when mapped to appointment details, then formats days and hours`() { + // GIVEN + val start = Instant.parse("2025-07-05T11:00:00Z") + val end = start.plus(Duration.ofHours(27)) // 1 day 3 hours + val booking = sampleBooking( + start = start, + end = end, + ) + val staffMemberStatus = BookingStaffMemberStatus.Loaded("Alex Doe") + whenever(currencyFormatter.formatCurrency(eq("0.00"), eq("USD"), eq(true))).thenReturn("$0.00") + + // WHEN + val model = mapper.run { booking.toAppointmentDetailsModel(staffMemberStatus, CancelStatus.Idle) } + + // THEN + assertThat(model.duration).isEqualTo("1 day 3 hours") + } + + @Test + fun `given duration is days plus hours plus minutes, when mapped to appointment details, then formats days hours and minutes`() { + // GIVEN + val start = Instant.parse("2025-07-05T11:00:00Z") + val end = start + .plus(Duration.ofDays(1)) + .plus(Duration.ofHours(2)) + .plus(Duration.ofMinutes(15)) // 1 day 2 hours 15 minutes + val booking = sampleBooking( + start = start, + end = end, + ) + val staffMemberStatus = BookingStaffMemberStatus.Loaded("Alex Doe") + whenever(currencyFormatter.formatCurrency(eq("0.00"), eq("USD"), eq(true))).thenReturn("$0.00") + + // WHEN + val model = mapper.run { booking.toAppointmentDetailsModel(staffMemberStatus, CancelStatus.Idle) } + + // THEN + assertThat(model.duration).isEqualTo("1 day 2 hours 15 minutes") + } + + @Test + fun `given duration under one minute, when mapped to appointment details, then formats seconds`() { + // GIVEN + val start = Instant.parse("2025-07-05T11:00:00Z") + val end = start.plusSeconds(45) // 45 seconds + val booking = sampleBooking( + start = start, + end = end, + ) + val staffMemberStatus = BookingStaffMemberStatus.Loaded("Alex Doe") + whenever(currencyFormatter.formatCurrency(eq("0.00"), eq("USD"), eq(true))).thenReturn("$0.00") + + // WHEN + val model = mapper.run { booking.toAppointmentDetailsModel(staffMemberStatus, CancelStatus.Idle) } + + // THEN + assertThat(model.duration).isEqualTo("45 seconds") + } + + @Test + fun `given duration within one minute of one hour, when mapped, then rounds up to full hour`() { + // GIVEN + val start = Instant.parse("2025-07-05T11:00:00Z") + val end = start.plus(Duration.ofHours(1)).minusSeconds(30) // 59s or less short should round up + val booking = sampleBooking(start = start, end = end) + val staffMemberStatus = BookingStaffMemberStatus.Loaded("Alex Doe") + whenever(currencyFormatter.formatCurrency(eq("0.00"), eq("USD"), eq(true))).thenReturn("$0.00") + + // WHEN + val model = mapper.run { booking.toAppointmentDetailsModel(staffMemberStatus, CancelStatus.Idle) } + + // THEN + assertThat(model.duration).isEqualTo("1 hour") + } + + @Test + fun `given duration within one minute of one day, when mapped, then rounds up to full day`() { + // GIVEN + val start = Instant.parse("2025-07-05T11:00:00Z") + val end = start.plus(Duration.ofDays(1)).minusSeconds(45) // within 1 minute of full day + val booking = sampleBooking(start = start, end = end) + val staffMemberStatus = BookingStaffMemberStatus.Loaded("Alex Doe") + whenever(currencyFormatter.formatCurrency(eq("0.00"), eq("USD"), eq(true))).thenReturn("$0.00") + + // WHEN + val model = mapper.run { booking.toAppointmentDetailsModel(staffMemberStatus, CancelStatus.Idle) } + + // THEN + assertThat(model.duration).isEqualTo("1 day") + } + private fun sampleBooking( status: BookingEntity.Status = BookingEntity.Status.Confirmed, start: Instant = Instant.parse("2025-07-05T11:00:00Z"), diff --git a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/bookings/details/BookingDetailsViewModelTest.kt b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/bookings/details/BookingDetailsViewModelTest.kt index e092895cb05b..f6c7bb9b17cc 100644 --- a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/bookings/details/BookingDetailsViewModelTest.kt +++ b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/bookings/details/BookingDetailsViewModelTest.kt @@ -22,6 +22,7 @@ import org.assertj.core.api.Assertions.assertThat import org.junit.Before import org.junit.Test import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.doReturn import org.mockito.kotlin.doSuspendableAnswer import org.mockito.kotlin.eq @@ -43,7 +44,7 @@ class BookingDetailsViewModelTest : BaseUnitTest() { private val currencyFormatter = mock() private val resourceProvider = mock() private val getLocations = mock() - private val bookingMapper = BookingMapper(currencyFormatter, getLocations) + private val bookingMapper = BookingMapper(currencyFormatter, getLocations, resourceProvider) private val bookingsRepository = mock { on { observeBooking(any()) } doReturn bookingFlow onBlocking { fetchBooking(any()) } doReturn Result.success(bookingFlow.value) @@ -61,6 +62,19 @@ class BookingDetailsViewModelTest : BaseUnitTest() { any() ) ).thenReturn("Booking #${initialBooking.id.value}") + + // Stub duration formatting strings used by BookingMapper for exact days + whenever( + resourceProvider.getQuantityString( + quantity = any(), + default = eq(R.string.booking_duration_days), + zero = anyOrNull(), + one = eq(R.string.booking_duration_day) + ) + ).thenAnswer { + val qty = it.getArgument(0) + if (qty == 1) "$qty day" else "$qty days" + } } @Test diff --git a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/bookings/list/BookingListViewModelTest.kt b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/bookings/list/BookingListViewModelTest.kt index bf87a00e72e2..e5a77082b483 100644 --- a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/bookings/list/BookingListViewModelTest.kt +++ b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/bookings/list/BookingListViewModelTest.kt @@ -11,6 +11,7 @@ import com.woocommerce.android.util.captureValues import com.woocommerce.android.util.getOrAwaitValue import com.woocommerce.android.viewmodel.BaseUnitTest import com.woocommerce.android.viewmodel.MultiLiveEvent +import com.woocommerce.android.viewmodel.ResourceProvider import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.flowOf @@ -52,7 +53,8 @@ class BookingListViewModelTest : BaseUnitTest() { private val filtersBuilder = BookingListFiltersBuilder(Clock.fixed(mockedNow, ZoneId.of("UTC"))) private val currencyFormatter = mock() private val getLocations = mock() - private val bookingMapper = BookingMapper(currencyFormatter, getLocations) + private val resourceProvider = mock() + private val bookingMapper = BookingMapper(currencyFormatter, getLocations, resourceProvider) private val bookingFiltersFlow = MutableStateFlow(BookingFilters()) private val bookingFilterRepository: BookingFilterRepository = mock { on { bookingFiltersFlow } doReturn bookingFiltersFlow From e48286a63d951682bf8aa000639eba3959591dbe Mon Sep 17 00:00:00 2001 From: AdamGrzybkowski Date: Mon, 20 Oct 2025 10:18:17 +0200 Subject: [PATCH 2/3] Update Duration.toHumanReadableFormat() --- .../android/ui/bookings/BookingMapper.kt | 93 ++++++------------- 1 file changed, 30 insertions(+), 63 deletions(-) diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/BookingMapper.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/BookingMapper.kt index dc823289f03f..607620920a94 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/BookingMapper.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/BookingMapper.kt @@ -161,82 +161,49 @@ class BookingMapper @Inject constructor( @Suppress("LongMethod") private fun Duration.toHumanReadableFormat(): String { - val totalSeconds = seconds - val dayInSeconds = Duration.ofDays(1).toSeconds() - val hourInSeconds = Duration.ofHours(1).toSeconds() - val minuteInSeconds = Duration.ofMinutes(1).toSeconds() + if (this < Duration.ofMinutes(1)) { + return resourceProvider.getQuantityString( + quantity = seconds.toInt(), + default = R.string.booking_duration_seconds, + one = R.string.booking_duration_second + ) + } - return when { - totalSeconds >= dayInSeconds -> { - val days = (totalSeconds / dayInSeconds).toInt() - val hours = ((totalSeconds % dayInSeconds) / hourInSeconds).toInt() - val minutes = ((totalSeconds % hourInSeconds) / minuteInSeconds).toInt() + val days = toDays() + val hours = minusDays(days).toHours() + val minutes = minusDays(days).minusHours(hours).toMinutes() - val parts = mutableListOf() - parts += resourceProvider.getQuantityString( - quantity = days, - default = R.string.booking_duration_days, - one = R.string.booking_duration_day - ) - if (hours > 0) { - parts += resourceProvider.getQuantityString( - quantity = hours, - default = R.string.booking_duration_hours, - one = R.string.booking_duration_hour - ) - } - if (minutes > 0) { - parts += resourceProvider.getQuantityString( - quantity = minutes, - default = R.string.booking_duration_minutes, - one = R.string.booking_duration_minute + return buildString { + if (days > 0) { + append( + resourceProvider.getQuantityString( + quantity = days.toInt(), + default = R.string.booking_duration_days, + one = R.string.booking_duration_day ) - } - parts.joinToString(separator = " ") + ) } - - totalSeconds >= hourInSeconds -> { - val hours = (totalSeconds / hourInSeconds).toInt() - val minutes = ((totalSeconds % hourInSeconds) / minuteInSeconds).toInt() - if (minutes == 0) { + if (hours > 0) { + append(" ") + append( resourceProvider.getQuantityString( - quantity = hours, + quantity = hours.toInt(), default = R.string.booking_duration_hours, one = R.string.booking_duration_hour ) - } else { - val hoursPart = resourceProvider.getQuantityString( - quantity = hours, - default = R.string.booking_duration_hours, - one = R.string.booking_duration_hour - ) - val minutesPart = resourceProvider.getQuantityString( - quantity = minutes, + ) + } + if (minutes > 0) { + append(" ") + append( + resourceProvider.getQuantityString( + quantity = minutes.toInt(), default = R.string.booking_duration_minutes, one = R.string.booking_duration_minute ) - "$hoursPart $minutesPart" - } - } - - totalSeconds >= minuteInSeconds -> { - val minutes = (totalSeconds / minuteInSeconds).toInt() - resourceProvider.getQuantityString( - quantity = minutes, - default = R.string.booking_duration_minutes, - one = R.string.booking_duration_minute ) } - - else -> { - val seconds = totalSeconds.toInt() - resourceProvider.getQuantityString( - quantity = seconds, - default = R.string.booking_duration_seconds, - one = R.string.booking_duration_second - ) - } - } + }.trim() } private suspend fun BookingCustomerInfo.address(): Address? { From 272c4b2b492812f7a862652f8af241f07ec22f06 Mon Sep 17 00:00:00 2001 From: AdamGrzybkowski Date: Mon, 20 Oct 2025 11:02:32 +0200 Subject: [PATCH 3/3] Extract Duration extension to a separate file --- .../android/ui/bookings/BookingMapper.kt | 77 +----- .../woocommerce/android/util/DurationExt.kt | 76 ++++++ .../android/ui/bookings/BookingMapperTest.kt | 220 ++---------------- .../android/util/DurationExtTest.kt | 127 ++++++++++ 4 files changed, 231 insertions(+), 269 deletions(-) create mode 100644 WooCommerce/src/main/kotlin/com/woocommerce/android/util/DurationExt.kt create mode 100644 WooCommerce/src/test/kotlin/com/woocommerce/android/util/DurationExtTest.kt diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/BookingMapper.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/BookingMapper.kt index 607620920a94..0a8551f2273c 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/BookingMapper.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/BookingMapper.kt @@ -15,6 +15,8 @@ import com.woocommerce.android.ui.bookings.compose.BookingSummaryModel import com.woocommerce.android.ui.bookings.details.CancelStatus import com.woocommerce.android.ui.bookings.list.BookingListItem import com.woocommerce.android.util.CurrencyFormatter +import com.woocommerce.android.util.normalizeDuration +import com.woocommerce.android.util.toHumanReadableFormat import com.woocommerce.android.viewmodel.ResourceProvider import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -65,8 +67,8 @@ class BookingMapper @Inject constructor( cancelStatus: CancelStatus, ): BookingAppointmentDetailsModel { val duration = Duration.between(start, end) - .normalizeBookingDuration() - .toHumanReadableFormat() + .normalizeDuration() + .toHumanReadableFormat(resourceProvider) return BookingAppointmentDetailsModel( date = detailsDateFormatter.format(start), time = "${timeRangeFormatter.format(start)} - ${timeRangeFormatter.format(end)}", @@ -135,77 +137,6 @@ class BookingMapper @Inject constructor( ) } - /** - * Normalize booking duration by adjusting for precision issues. - * - * This function handles cases where a booking duration is very close to - * common time boundaries (days/hours) but falls short due to precision issues. - * It rounds up durations that are within one minute of these boundaries. - */ - private fun Duration.normalizeBookingDuration(): Duration { - val dayInSeconds = Duration.ofDays(1).seconds - val hourInSeconds = Duration.ofHours(1).seconds - val minuteInSeconds = Duration.ofMinutes(1).seconds - - var durationInSeconds = this.seconds - val boundaries = listOf(dayInSeconds, hourInSeconds) - for (boundary in boundaries) { - val remainder = durationInSeconds % boundary - val difference = if (remainder == 0L) 0L else boundary - remainder - if (difference > 0 && difference <= minuteInSeconds) { - durationInSeconds += difference - } - } - return Duration.ofSeconds(durationInSeconds) - } - - @Suppress("LongMethod") - private fun Duration.toHumanReadableFormat(): String { - if (this < Duration.ofMinutes(1)) { - return resourceProvider.getQuantityString( - quantity = seconds.toInt(), - default = R.string.booking_duration_seconds, - one = R.string.booking_duration_second - ) - } - - val days = toDays() - val hours = minusDays(days).toHours() - val minutes = minusDays(days).minusHours(hours).toMinutes() - - return buildString { - if (days > 0) { - append( - resourceProvider.getQuantityString( - quantity = days.toInt(), - default = R.string.booking_duration_days, - one = R.string.booking_duration_day - ) - ) - } - if (hours > 0) { - append(" ") - append( - resourceProvider.getQuantityString( - quantity = hours.toInt(), - default = R.string.booking_duration_hours, - one = R.string.booking_duration_hour - ) - ) - } - if (minutes > 0) { - append(" ") - append( - resourceProvider.getQuantityString( - quantity = minutes.toInt(), - default = R.string.booking_duration_minutes, - one = R.string.booking_duration_minute - ) - ) - } - }.trim() - } - private suspend fun BookingCustomerInfo.address(): Address? { val countryCode = billingCountry ?: return null val (country, state) = withContext(Dispatchers.IO) { diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/util/DurationExt.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/util/DurationExt.kt new file mode 100644 index 000000000000..7cb01c338b55 --- /dev/null +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/util/DurationExt.kt @@ -0,0 +1,76 @@ +package com.woocommerce.android.util + +import com.woocommerce.android.R +import com.woocommerce.android.viewmodel.ResourceProvider +import java.time.Duration + +/** + * Normalize duration by adjusting for precision issues. + * + * This function handles cases where a booking duration is very close to + * common time boundaries (days/hours) but falls short due to precision issues. + * It rounds up durations that are within one minute of these boundaries. + */ +fun Duration.normalizeDuration(): Duration { + val dayInSeconds = Duration.ofDays(1).seconds + val hourInSeconds = Duration.ofHours(1).seconds + val minuteInSeconds = Duration.ofMinutes(1).seconds + + var durationInSeconds = this.seconds + val boundaries = listOf(dayInSeconds, hourInSeconds) + for (boundary in boundaries) { + val remainder = durationInSeconds % boundary + val difference = if (remainder == 0L) 0L else boundary - remainder + if (difference > 0 && difference <= minuteInSeconds) { + durationInSeconds += difference + } + } + return Duration.ofSeconds(durationInSeconds) +} + +@Suppress("LongMethod") +fun Duration.toHumanReadableFormat(resourceProvider: ResourceProvider): String { + if (this < Duration.ofMinutes(1)) { + return resourceProvider.getQuantityString( + quantity = seconds.toInt(), + default = R.string.booking_duration_seconds, + one = R.string.booking_duration_second + ) + } + + val days = toDays() + val hours = minusDays(days).toHours() + val minutes = minusDays(days).minusHours(hours).toMinutes() + + return buildString { + if (days > 0) { + append( + resourceProvider.getQuantityString( + quantity = days.toInt(), + default = R.string.booking_duration_days, + one = R.string.booking_duration_day + ) + ) + } + if (hours > 0) { + append(" ") + append( + resourceProvider.getQuantityString( + quantity = hours.toInt(), + default = R.string.booking_duration_hours, + one = R.string.booking_duration_hour + ) + ) + } + if (minutes > 0) { + append(" ") + append( + resourceProvider.getQuantityString( + quantity = minutes.toInt(), + default = R.string.booking_duration_minutes, + one = R.string.booking_duration_minute + ) + ) + } + }.trim() +} diff --git a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/bookings/BookingMapperTest.kt b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/bookings/BookingMapperTest.kt index f586517618cb..c2e0204635fa 100644 --- a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/bookings/BookingMapperTest.kt +++ b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/bookings/BookingMapperTest.kt @@ -44,57 +44,34 @@ class BookingMapperTest : BaseUnitTest() { } } private val getLocations: GetLocations = mock() - private val resourceProvider: ResourceProvider = mock() + private val resourceProvider: ResourceProvider = mock { + on { getQuantityString(any(), any(), anyOrNull(), anyOrNull()) } doAnswer { invocation -> + val quantity = invocation.arguments[0] as Int + val default = invocation.arguments[1] as Int + val one = invocation.arguments[3] as Int? + when (quantity) { + 1 -> when (one) { + R.string.booking_duration_second -> "1 second" + R.string.booking_duration_minute -> "1 minute" + R.string.booking_duration_hour -> "1 hour" + R.string.booking_duration_day -> "1 day" + else -> "" + } + + else -> when (default) { + R.string.booking_duration_seconds -> "$quantity seconds" + R.string.booking_duration_minutes -> "$quantity minutes" + R.string.booking_duration_hours -> "$quantity hours" + R.string.booking_duration_days -> "$quantity days" + else -> "" + } + } + } + } private lateinit var mapper: BookingMapper @Before fun setup() { - // Stub ResourceProvider localized strings for durations using quantity strings - whenever( - resourceProvider.getQuantityString( - quantity = any(), - default = eq(com.woocommerce.android.R.string.booking_duration_minutes), - zero = anyOrNull(), - one = eq(com.woocommerce.android.R.string.booking_duration_minute) - ) - ).thenAnswer { - val qty = it.getArgument(0) - if (qty == 1) "$qty minute" else "$qty minutes" - } - whenever( - resourceProvider.getQuantityString( - quantity = any(), - default = eq(com.woocommerce.android.R.string.booking_duration_hours), - zero = anyOrNull(), - one = eq(com.woocommerce.android.R.string.booking_duration_hour) - ) - ).thenAnswer { - val qty = it.getArgument(0) - if (qty == 1) "$qty hour" else "$qty hours" - } - whenever( - resourceProvider.getQuantityString( - quantity = any(), - default = eq(com.woocommerce.android.R.string.booking_duration_days), - zero = anyOrNull(), - one = eq(com.woocommerce.android.R.string.booking_duration_day) - ) - ).thenAnswer { - val qty = it.getArgument(0) - if (qty == 1) "$qty day" else "$qty days" - } - whenever( - resourceProvider.getQuantityString( - quantity = any(), - default = eq(com.woocommerce.android.R.string.booking_duration_seconds), - zero = anyOrNull(), - one = eq(com.woocommerce.android.R.string.booking_duration_second) - ) - ).thenAnswer { - val qty = it.getArgument(0) - if (qty == 1) "$qty second" else "$qty seconds" - } - mapper = BookingMapper(currencyFormatter, getLocations, resourceProvider) } @@ -276,155 +253,6 @@ class BookingMapperTest : BaseUnitTest() { ) } - @Test - fun `given duration under one hour, when mapped to appointment details, then formats minutes`() { - // GIVEN - val start = Instant.parse("2025-07-05T11:00:00Z") - val end = start.plusSeconds(45 * 60) // 45 minutes - val booking = sampleBooking( - start = start, - end = end, - ) - val staffMemberStatus = BookingStaffMemberStatus.Loaded("Alex Doe") - whenever(currencyFormatter.formatCurrency(eq("0.00"), eq("USD"), eq(true))).thenReturn("$0.00") - - // WHEN - val model = mapper.run { booking.toAppointmentDetailsModel(staffMemberStatus, CancelStatus.Idle) } - - // THEN - assertThat(model.duration).isEqualTo("45 minutes") - } - - @Test - fun `given duration is exact hours, when mapped to appointment details, then formats hours only`() { - // GIVEN - val start = Instant.parse("2025-07-05T11:00:00Z") - val end = start.plus(Duration.ofHours(2)) // 2 hours - val booking = sampleBooking( - start = start, - end = end, - ) - val staffMemberStatus = BookingStaffMemberStatus.Loaded("Alex Doe") - whenever(currencyFormatter.formatCurrency(eq("0.00"), eq("USD"), eq(true))).thenReturn("$0.00") - - // WHEN - val model = mapper.run { booking.toAppointmentDetailsModel(staffMemberStatus, CancelStatus.Idle) } - - // THEN - assertThat(model.duration).isEqualTo("2 hours") - } - - @Test - fun `given duration is exact days, when mapped to appointment details, then formats days only`() { - // GIVEN - val start = Instant.parse("2025-07-05T11:00:00Z") - val end = start.plus(Duration.ofHours(24)) // 1 day - val booking = sampleBooking( - start = start, - end = end, - ) - val staffMemberStatus = BookingStaffMemberStatus.Loaded("Alex Doe") - whenever(currencyFormatter.formatCurrency(eq("0.00"), eq("USD"), eq(true))).thenReturn("$0.00") - - // WHEN - val model = mapper.run { booking.toAppointmentDetailsModel(staffMemberStatus, CancelStatus.Idle) } - - // THEN - assertThat(model.duration).isEqualTo("1 day") - } - - @Test - fun `given duration is days plus hours, when mapped to appointment details, then formats days and hours`() { - // GIVEN - val start = Instant.parse("2025-07-05T11:00:00Z") - val end = start.plus(Duration.ofHours(27)) // 1 day 3 hours - val booking = sampleBooking( - start = start, - end = end, - ) - val staffMemberStatus = BookingStaffMemberStatus.Loaded("Alex Doe") - whenever(currencyFormatter.formatCurrency(eq("0.00"), eq("USD"), eq(true))).thenReturn("$0.00") - - // WHEN - val model = mapper.run { booking.toAppointmentDetailsModel(staffMemberStatus, CancelStatus.Idle) } - - // THEN - assertThat(model.duration).isEqualTo("1 day 3 hours") - } - - @Test - fun `given duration is days plus hours plus minutes, when mapped to appointment details, then formats days hours and minutes`() { - // GIVEN - val start = Instant.parse("2025-07-05T11:00:00Z") - val end = start - .plus(Duration.ofDays(1)) - .plus(Duration.ofHours(2)) - .plus(Duration.ofMinutes(15)) // 1 day 2 hours 15 minutes - val booking = sampleBooking( - start = start, - end = end, - ) - val staffMemberStatus = BookingStaffMemberStatus.Loaded("Alex Doe") - whenever(currencyFormatter.formatCurrency(eq("0.00"), eq("USD"), eq(true))).thenReturn("$0.00") - - // WHEN - val model = mapper.run { booking.toAppointmentDetailsModel(staffMemberStatus, CancelStatus.Idle) } - - // THEN - assertThat(model.duration).isEqualTo("1 day 2 hours 15 minutes") - } - - @Test - fun `given duration under one minute, when mapped to appointment details, then formats seconds`() { - // GIVEN - val start = Instant.parse("2025-07-05T11:00:00Z") - val end = start.plusSeconds(45) // 45 seconds - val booking = sampleBooking( - start = start, - end = end, - ) - val staffMemberStatus = BookingStaffMemberStatus.Loaded("Alex Doe") - whenever(currencyFormatter.formatCurrency(eq("0.00"), eq("USD"), eq(true))).thenReturn("$0.00") - - // WHEN - val model = mapper.run { booking.toAppointmentDetailsModel(staffMemberStatus, CancelStatus.Idle) } - - // THEN - assertThat(model.duration).isEqualTo("45 seconds") - } - - @Test - fun `given duration within one minute of one hour, when mapped, then rounds up to full hour`() { - // GIVEN - val start = Instant.parse("2025-07-05T11:00:00Z") - val end = start.plus(Duration.ofHours(1)).minusSeconds(30) // 59s or less short should round up - val booking = sampleBooking(start = start, end = end) - val staffMemberStatus = BookingStaffMemberStatus.Loaded("Alex Doe") - whenever(currencyFormatter.formatCurrency(eq("0.00"), eq("USD"), eq(true))).thenReturn("$0.00") - - // WHEN - val model = mapper.run { booking.toAppointmentDetailsModel(staffMemberStatus, CancelStatus.Idle) } - - // THEN - assertThat(model.duration).isEqualTo("1 hour") - } - - @Test - fun `given duration within one minute of one day, when mapped, then rounds up to full day`() { - // GIVEN - val start = Instant.parse("2025-07-05T11:00:00Z") - val end = start.plus(Duration.ofDays(1)).minusSeconds(45) // within 1 minute of full day - val booking = sampleBooking(start = start, end = end) - val staffMemberStatus = BookingStaffMemberStatus.Loaded("Alex Doe") - whenever(currencyFormatter.formatCurrency(eq("0.00"), eq("USD"), eq(true))).thenReturn("$0.00") - - // WHEN - val model = mapper.run { booking.toAppointmentDetailsModel(staffMemberStatus, CancelStatus.Idle) } - - // THEN - assertThat(model.duration).isEqualTo("1 day") - } - private fun sampleBooking( status: BookingEntity.Status = BookingEntity.Status.Confirmed, start: Instant = Instant.parse("2025-07-05T11:00:00Z"), diff --git a/WooCommerce/src/test/kotlin/com/woocommerce/android/util/DurationExtTest.kt b/WooCommerce/src/test/kotlin/com/woocommerce/android/util/DurationExtTest.kt new file mode 100644 index 000000000000..0b77ee69b917 --- /dev/null +++ b/WooCommerce/src/test/kotlin/com/woocommerce/android/util/DurationExtTest.kt @@ -0,0 +1,127 @@ +package com.woocommerce.android.util + +import com.woocommerce.android.R +import com.woocommerce.android.viewmodel.ResourceProvider +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.mock +import java.time.Duration + +class DurationExtTest { + + private lateinit var resourceProvider: ResourceProvider + + @Before + fun setUp() { + resourceProvider = mock { + on { getQuantityString(any(), any(), anyOrNull(), anyOrNull()) } doAnswer { invocation -> + val quantity = invocation.arguments[0] as Int + val default = invocation.arguments[1] as Int + val one = invocation.arguments[3] as Int? + when (quantity) { + 1 -> when (one) { + R.string.booking_duration_second -> "1 second" + R.string.booking_duration_minute -> "1 minute" + R.string.booking_duration_hour -> "1 hour" + R.string.booking_duration_day -> "1 day" + else -> "" + } + else -> when (default) { + R.string.booking_duration_seconds -> "$quantity seconds" + R.string.booking_duration_minutes -> "$quantity minutes" + R.string.booking_duration_hours -> "$quantity hours" + R.string.booking_duration_days -> "$quantity days" + else -> "" + } + } + } + } + } + + @Test + fun `given day without less than a minute, when normalizeDuration, then rounds up to nearest day`() { + val duration = Duration.ofDays(1).minusSeconds(30) + val expected = Duration.ofDays(1) + assertEquals(expected, duration.normalizeDuration()) + } + + @Test + fun `given almost an hour without less than a minute, when normalizeDuration, then rounds up to nearest hour`() { + val duration = Duration.ofHours(1).minusSeconds(30) + val expected = Duration.ofHours(1) + assertEquals(expected, duration.normalizeDuration()) + } + + @Test + fun `given an hour without more than a minute, when normalizeDuration, then does not change duration`() { + val duration = Duration.ofHours(1).minusMinutes(2) + assertEquals(duration, duration.normalizeDuration()) + } + + @Test + fun `given duration of 45 seconds, when toHumanReadableFormat, then formatted correctly`() { + val duration = Duration.ofSeconds(45) + val expected = "45 seconds" + assertEquals(expected, duration.toHumanReadableFormat(resourceProvider)) + } + + @Test + fun `given duration of 1 minute, when toHumanReadableFormat, then formatted correctly`() { + val duration = Duration.ofMinutes(1) + val expected = "1 minute" + assertEquals(expected, duration.toHumanReadableFormat(resourceProvider)) + } + + @Test + fun `given duration of 30 minutes, when toHumanReadableFormat, then formatted correctly`() { + val duration = Duration.ofMinutes(30) + val expected = "30 minutes" + assertEquals(expected, duration.toHumanReadableFormat(resourceProvider)) + } + + @Test + fun `given duration of 1 hour, when toHumanReadableFormat, then formatted correctly`() { + val duration = Duration.ofHours(1) + val expected = "1 hour" + assertEquals(expected, duration.toHumanReadableFormat(resourceProvider)) + } + + @Test + fun `given duration of 2 hours, when toHumanReadableFormat, then formatted correctly`() { + val duration = Duration.ofHours(2) + val expected = "2 hours" + assertEquals(expected, duration.toHumanReadableFormat(resourceProvider)) + } + + @Test + fun `given duration of 1 day, when toHumanReadableFormat, then formatted correctly`() { + val duration = Duration.ofDays(1) + val expected = "1 day" + assertEquals(expected, duration.toHumanReadableFormat(resourceProvider)) + } + + @Test + fun `given duration of 3 days, when toHumanReadableFormat, then formatted correctly`() { + val duration = Duration.ofDays(3) + val expected = "3 days" + assertEquals(expected, duration.toHumanReadableFormat(resourceProvider)) + } + + @Test + fun `given duration of 1 day and 2 hours, when toHumanReadableFormat, then formatted correctly`() { + val duration = Duration.ofDays(1).plusHours(2) + val expected = "1 day 2 hours" + assertEquals(expected, duration.toHumanReadableFormat(resourceProvider)) + } + + @Test + fun `given duration of 1 day, 2 hours and 30 minutes, when toHumanReadableFormat, then formatted correctly`() { + val duration = Duration.ofDays(1).plusHours(2).plusMinutes(30) + val expected = "1 day 2 hours 30 minutes" + assertEquals(expected, duration.toHumanReadableFormat(resourceProvider)) + } +}