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 ccc3e9b30bb..dc823289f03 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 e7e0d642b32..f11172b53a7 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 ee1081feb77..f586517618c 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 e092895cb05..f6c7bb9b17c 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 bf87a00e72e..e5a77082b48 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