Skip to content

Commit 1a0e8ca

Browse files
Merge pull request #14760 from woocommerce/issue/WOOMOB-1515_booking_duration
[WOOMOB-1515] - Format booking duration following web's logic
2 parents 2500867 + 272c4b2 commit 1a0e8ca

File tree

7 files changed

+266
-7
lines changed

7 files changed

+266
-7
lines changed

WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/BookingMapper.kt

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ import com.woocommerce.android.ui.bookings.compose.BookingSummaryModel
1515
import com.woocommerce.android.ui.bookings.details.CancelStatus
1616
import com.woocommerce.android.ui.bookings.list.BookingListItem
1717
import com.woocommerce.android.util.CurrencyFormatter
18+
import com.woocommerce.android.util.normalizeDuration
19+
import com.woocommerce.android.util.toHumanReadableFormat
20+
import com.woocommerce.android.viewmodel.ResourceProvider
1821
import kotlinx.coroutines.Dispatchers
1922
import kotlinx.coroutines.withContext
2023
import org.wordpress.android.fluxc.network.rest.wpcom.wc.bookings.BookingCustomerInfo
@@ -29,7 +32,8 @@ import javax.inject.Inject
2932

3033
class BookingMapper @Inject constructor(
3134
private val currencyFormatter: CurrencyFormatter,
32-
private val getLocations: GetLocations
35+
private val getLocations: GetLocations,
36+
private val resourceProvider: ResourceProvider
3337
) {
3438
private val summaryDateFormatter: DateTimeFormatter = DateTimeFormatter.ofLocalizedDateTime(
3539
FormatStyle.MEDIUM,
@@ -62,16 +66,18 @@ class BookingMapper @Inject constructor(
6266
staffMemberStatus: BookingStaffMemberStatus?,
6367
cancelStatus: CancelStatus,
6468
): BookingAppointmentDetailsModel {
65-
val durationMinutes = Duration.between(start, end).toMinutes()
69+
val duration = Duration.between(start, end)
70+
.normalizeDuration()
71+
.toHumanReadableFormat(resourceProvider)
6672
return BookingAppointmentDetailsModel(
6773
date = detailsDateFormatter.format(start),
6874
time = "${timeRangeFormatter.format(start)} - ${timeRangeFormatter.format(end)}",
6975
staff = staffMemberStatus,
7076
// TODO replace mocked values when available from API
7177
location = "238 Willow Creek Drive, Montgomery AL 36109",
72-
duration = "$durationMinutes min",
7378
price = currencyFormatter.formatCurrency(cost, currency),
7479
cancelStatus = cancelStatus,
80+
duration = duration,
7581
)
7682
}
7783

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
package com.woocommerce.android.util
2+
3+
import com.woocommerce.android.R
4+
import com.woocommerce.android.viewmodel.ResourceProvider
5+
import java.time.Duration
6+
7+
/**
8+
* Normalize duration by adjusting for precision issues.
9+
*
10+
* This function handles cases where a booking duration is very close to
11+
* common time boundaries (days/hours) but falls short due to precision issues.
12+
* It rounds up durations that are within one minute of these boundaries.
13+
*/
14+
fun Duration.normalizeDuration(): Duration {
15+
val dayInSeconds = Duration.ofDays(1).seconds
16+
val hourInSeconds = Duration.ofHours(1).seconds
17+
val minuteInSeconds = Duration.ofMinutes(1).seconds
18+
19+
var durationInSeconds = this.seconds
20+
val boundaries = listOf(dayInSeconds, hourInSeconds)
21+
for (boundary in boundaries) {
22+
val remainder = durationInSeconds % boundary
23+
val difference = if (remainder == 0L) 0L else boundary - remainder
24+
if (difference > 0 && difference <= minuteInSeconds) {
25+
durationInSeconds += difference
26+
}
27+
}
28+
return Duration.ofSeconds(durationInSeconds)
29+
}
30+
31+
@Suppress("LongMethod")
32+
fun Duration.toHumanReadableFormat(resourceProvider: ResourceProvider): String {
33+
if (this < Duration.ofMinutes(1)) {
34+
return resourceProvider.getQuantityString(
35+
quantity = seconds.toInt(),
36+
default = R.string.booking_duration_seconds,
37+
one = R.string.booking_duration_second
38+
)
39+
}
40+
41+
val days = toDays()
42+
val hours = minusDays(days).toHours()
43+
val minutes = minusDays(days).minusHours(hours).toMinutes()
44+
45+
return buildString {
46+
if (days > 0) {
47+
append(
48+
resourceProvider.getQuantityString(
49+
quantity = days.toInt(),
50+
default = R.string.booking_duration_days,
51+
one = R.string.booking_duration_day
52+
)
53+
)
54+
}
55+
if (hours > 0) {
56+
append(" ")
57+
append(
58+
resourceProvider.getQuantityString(
59+
quantity = hours.toInt(),
60+
default = R.string.booking_duration_hours,
61+
one = R.string.booking_duration_hour
62+
)
63+
)
64+
}
65+
if (minutes > 0) {
66+
append(" ")
67+
append(
68+
resourceProvider.getQuantityString(
69+
quantity = minutes.toInt(),
70+
default = R.string.booking_duration_minutes,
71+
one = R.string.booking_duration_minute
72+
)
73+
)
74+
}
75+
}.trim()
76+
}

WooCommerce/src/main/res/values/strings.xml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4261,6 +4261,14 @@
42614261
<string name="booking_payment_mark_as_paid">Mark as paid</string>
42624262
<string name="booking_payment_view_order">View order</string>
42634263
<string name="booking_fetch_error">Error fetching booking</string>
4264+
<string name="booking_duration_minute">%1$d minute</string>
4265+
<string name="booking_duration_minutes">%1$d minutes</string>
4266+
<string name="booking_duration_hour">%1$d hour</string>
4267+
<string name="booking_duration_hours">%1$d hours</string>
4268+
<string name="booking_duration_day">%1$d day</string>
4269+
<string name="booking_duration_days">%1$d days</string>
4270+
<string name="booking_duration_second">%1$d second</string>
4271+
<string name="booking_duration_seconds">%1$d seconds</string>
42644272

42654273
<string name="or_use_password" a8c-src-lib="module:login">Use password to sign in</string>
42664274
<string name="about_automattic_main_page_title">About %1$s</string>

WooCommerce/src/test/kotlin/com/woocommerce/android/ui/bookings/BookingMapperTest.kt

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,13 @@ import com.woocommerce.android.ui.bookings.compose.BookingStatus
99
import com.woocommerce.android.ui.bookings.details.CancelStatus
1010
import com.woocommerce.android.util.CurrencyFormatter
1111
import com.woocommerce.android.viewmodel.BaseUnitTest
12+
import com.woocommerce.android.viewmodel.ResourceProvider
1213
import kotlinx.coroutines.ExperimentalCoroutinesApi
1314
import org.assertj.core.api.Assertions.assertThat
1415
import org.junit.Before
1516
import org.junit.Test
1617
import org.mockito.kotlin.any
18+
import org.mockito.kotlin.anyOrNull
1719
import org.mockito.kotlin.doAnswer
1820
import org.mockito.kotlin.eq
1921
import org.mockito.kotlin.mock
@@ -42,11 +44,35 @@ class BookingMapperTest : BaseUnitTest() {
4244
}
4345
}
4446
private val getLocations: GetLocations = mock()
47+
private val resourceProvider: ResourceProvider = mock {
48+
on { getQuantityString(any(), any(), anyOrNull(), anyOrNull()) } doAnswer { invocation ->
49+
val quantity = invocation.arguments[0] as Int
50+
val default = invocation.arguments[1] as Int
51+
val one = invocation.arguments[3] as Int?
52+
when (quantity) {
53+
1 -> when (one) {
54+
R.string.booking_duration_second -> "1 second"
55+
R.string.booking_duration_minute -> "1 minute"
56+
R.string.booking_duration_hour -> "1 hour"
57+
R.string.booking_duration_day -> "1 day"
58+
else -> ""
59+
}
60+
61+
else -> when (default) {
62+
R.string.booking_duration_seconds -> "$quantity seconds"
63+
R.string.booking_duration_minutes -> "$quantity minutes"
64+
R.string.booking_duration_hours -> "$quantity hours"
65+
R.string.booking_duration_days -> "$quantity days"
66+
else -> ""
67+
}
68+
}
69+
}
70+
}
4571
private lateinit var mapper: BookingMapper
4672

4773
@Before
4874
fun setup() {
49-
mapper = BookingMapper(currencyFormatter, getLocations)
75+
mapper = BookingMapper(currencyFormatter, getLocations, resourceProvider)
5076
}
5177

5278
@Test
@@ -108,7 +134,7 @@ class BookingMapperTest : BaseUnitTest() {
108134
assertThat(model.time).isEqualTo(expectedTime)
109135
assertThat(model.staff).isEqualTo(staffMemberStatus)
110136
assertThat(model.location).isEqualTo("238 Willow Creek Drive, Montgomery AL 36109")
111-
assertThat(model.duration).isEqualTo("90 min")
137+
assertThat(model.duration).isEqualTo("1 hour 30 minutes")
112138
assertThat(model.price).isEqualTo("$55.00")
113139
assertThat(model.cancelStatus).isEqualTo(CancelStatus.Idle)
114140
}

WooCommerce/src/test/kotlin/com/woocommerce/android/ui/bookings/details/BookingDetailsViewModelTest.kt

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import org.assertj.core.api.Assertions.assertThat
2222
import org.junit.Before
2323
import org.junit.Test
2424
import org.mockito.kotlin.any
25+
import org.mockito.kotlin.anyOrNull
2526
import org.mockito.kotlin.doReturn
2627
import org.mockito.kotlin.doSuspendableAnswer
2728
import org.mockito.kotlin.eq
@@ -43,7 +44,7 @@ class BookingDetailsViewModelTest : BaseUnitTest() {
4344
private val currencyFormatter = mock<CurrencyFormatter>()
4445
private val resourceProvider = mock<ResourceProvider>()
4546
private val getLocations = mock<GetLocations>()
46-
private val bookingMapper = BookingMapper(currencyFormatter, getLocations)
47+
private val bookingMapper = BookingMapper(currencyFormatter, getLocations, resourceProvider)
4748
private val bookingsRepository = mock<BookingsRepository> {
4849
on { observeBooking(any()) } doReturn bookingFlow
4950
onBlocking { fetchBooking(any()) } doReturn Result.success(bookingFlow.value)
@@ -61,6 +62,19 @@ class BookingDetailsViewModelTest : BaseUnitTest() {
6162
any()
6263
)
6364
).thenReturn("Booking #${initialBooking.id.value}")
65+
66+
// Stub duration formatting strings used by BookingMapper for exact days
67+
whenever(
68+
resourceProvider.getQuantityString(
69+
quantity = any(),
70+
default = eq(R.string.booking_duration_days),
71+
zero = anyOrNull(),
72+
one = eq(R.string.booking_duration_day)
73+
)
74+
).thenAnswer {
75+
val qty = it.getArgument<Int>(0)
76+
if (qty == 1) "$qty day" else "$qty days"
77+
}
6478
}
6579

6680
@Test

WooCommerce/src/test/kotlin/com/woocommerce/android/ui/bookings/list/BookingListViewModelTest.kt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import com.woocommerce.android.util.captureValues
1111
import com.woocommerce.android.util.getOrAwaitValue
1212
import com.woocommerce.android.viewmodel.BaseUnitTest
1313
import com.woocommerce.android.viewmodel.MultiLiveEvent
14+
import com.woocommerce.android.viewmodel.ResourceProvider
1415
import kotlinx.coroutines.ExperimentalCoroutinesApi
1516
import kotlinx.coroutines.flow.MutableStateFlow
1617
import kotlinx.coroutines.flow.flowOf
@@ -52,7 +53,8 @@ class BookingListViewModelTest : BaseUnitTest() {
5253
private val filtersBuilder = BookingListFiltersBuilder(Clock.fixed(mockedNow, ZoneId.of("UTC")))
5354
private val currencyFormatter = mock<CurrencyFormatter>()
5455
private val getLocations = mock<GetLocations>()
55-
private val bookingMapper = BookingMapper(currencyFormatter, getLocations)
56+
private val resourceProvider = mock<ResourceProvider>()
57+
private val bookingMapper = BookingMapper(currencyFormatter, getLocations, resourceProvider)
5658
private val bookingFiltersFlow = MutableStateFlow(BookingFilters())
5759
private val bookingFilterRepository: BookingFilterRepository = mock {
5860
on { bookingFiltersFlow } doReturn bookingFiltersFlow
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
package com.woocommerce.android.util
2+
3+
import com.woocommerce.android.R
4+
import com.woocommerce.android.viewmodel.ResourceProvider
5+
import org.junit.Assert.assertEquals
6+
import org.junit.Before
7+
import org.junit.Test
8+
import org.mockito.kotlin.any
9+
import org.mockito.kotlin.anyOrNull
10+
import org.mockito.kotlin.doAnswer
11+
import org.mockito.kotlin.mock
12+
import java.time.Duration
13+
14+
class DurationExtTest {
15+
16+
private lateinit var resourceProvider: ResourceProvider
17+
18+
@Before
19+
fun setUp() {
20+
resourceProvider = mock {
21+
on { getQuantityString(any(), any(), anyOrNull(), anyOrNull()) } doAnswer { invocation ->
22+
val quantity = invocation.arguments[0] as Int
23+
val default = invocation.arguments[1] as Int
24+
val one = invocation.arguments[3] as Int?
25+
when (quantity) {
26+
1 -> when (one) {
27+
R.string.booking_duration_second -> "1 second"
28+
R.string.booking_duration_minute -> "1 minute"
29+
R.string.booking_duration_hour -> "1 hour"
30+
R.string.booking_duration_day -> "1 day"
31+
else -> ""
32+
}
33+
else -> when (default) {
34+
R.string.booking_duration_seconds -> "$quantity seconds"
35+
R.string.booking_duration_minutes -> "$quantity minutes"
36+
R.string.booking_duration_hours -> "$quantity hours"
37+
R.string.booking_duration_days -> "$quantity days"
38+
else -> ""
39+
}
40+
}
41+
}
42+
}
43+
}
44+
45+
@Test
46+
fun `given day without less than a minute, when normalizeDuration, then rounds up to nearest day`() {
47+
val duration = Duration.ofDays(1).minusSeconds(30)
48+
val expected = Duration.ofDays(1)
49+
assertEquals(expected, duration.normalizeDuration())
50+
}
51+
52+
@Test
53+
fun `given almost an hour without less than a minute, when normalizeDuration, then rounds up to nearest hour`() {
54+
val duration = Duration.ofHours(1).minusSeconds(30)
55+
val expected = Duration.ofHours(1)
56+
assertEquals(expected, duration.normalizeDuration())
57+
}
58+
59+
@Test
60+
fun `given an hour without more than a minute, when normalizeDuration, then does not change duration`() {
61+
val duration = Duration.ofHours(1).minusMinutes(2)
62+
assertEquals(duration, duration.normalizeDuration())
63+
}
64+
65+
@Test
66+
fun `given duration of 45 seconds, when toHumanReadableFormat, then formatted correctly`() {
67+
val duration = Duration.ofSeconds(45)
68+
val expected = "45 seconds"
69+
assertEquals(expected, duration.toHumanReadableFormat(resourceProvider))
70+
}
71+
72+
@Test
73+
fun `given duration of 1 minute, when toHumanReadableFormat, then formatted correctly`() {
74+
val duration = Duration.ofMinutes(1)
75+
val expected = "1 minute"
76+
assertEquals(expected, duration.toHumanReadableFormat(resourceProvider))
77+
}
78+
79+
@Test
80+
fun `given duration of 30 minutes, when toHumanReadableFormat, then formatted correctly`() {
81+
val duration = Duration.ofMinutes(30)
82+
val expected = "30 minutes"
83+
assertEquals(expected, duration.toHumanReadableFormat(resourceProvider))
84+
}
85+
86+
@Test
87+
fun `given duration of 1 hour, when toHumanReadableFormat, then formatted correctly`() {
88+
val duration = Duration.ofHours(1)
89+
val expected = "1 hour"
90+
assertEquals(expected, duration.toHumanReadableFormat(resourceProvider))
91+
}
92+
93+
@Test
94+
fun `given duration of 2 hours, when toHumanReadableFormat, then formatted correctly`() {
95+
val duration = Duration.ofHours(2)
96+
val expected = "2 hours"
97+
assertEquals(expected, duration.toHumanReadableFormat(resourceProvider))
98+
}
99+
100+
@Test
101+
fun `given duration of 1 day, when toHumanReadableFormat, then formatted correctly`() {
102+
val duration = Duration.ofDays(1)
103+
val expected = "1 day"
104+
assertEquals(expected, duration.toHumanReadableFormat(resourceProvider))
105+
}
106+
107+
@Test
108+
fun `given duration of 3 days, when toHumanReadableFormat, then formatted correctly`() {
109+
val duration = Duration.ofDays(3)
110+
val expected = "3 days"
111+
assertEquals(expected, duration.toHumanReadableFormat(resourceProvider))
112+
}
113+
114+
@Test
115+
fun `given duration of 1 day and 2 hours, when toHumanReadableFormat, then formatted correctly`() {
116+
val duration = Duration.ofDays(1).plusHours(2)
117+
val expected = "1 day 2 hours"
118+
assertEquals(expected, duration.toHumanReadableFormat(resourceProvider))
119+
}
120+
121+
@Test
122+
fun `given duration of 1 day, 2 hours and 30 minutes, when toHumanReadableFormat, then formatted correctly`() {
123+
val duration = Duration.ofDays(1).plusHours(2).plusMinutes(30)
124+
val expected = "1 day 2 hours 30 minutes"
125+
assertEquals(expected, duration.toHumanReadableFormat(resourceProvider))
126+
}
127+
}

0 commit comments

Comments
 (0)