Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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,
)
}

Expand Down Expand Up @@ -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)
}

Comment on lines +153 to +161
Copy link

Copilot AI Oct 15, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] The normalization logic could be simplified by extracting the boundary checking into a separate function. This would make the algorithm clearer and more testable.

Suggested change
val remainder = durationInSeconds % boundary
val difference = if (remainder == 0L) 0L else boundary - remainder
if (difference > 0 && difference <= minuteInSeconds) {
durationInSeconds += difference
}
}
return Duration.ofSeconds(durationInSeconds)
}
durationInSeconds = adjustToBoundary(durationInSeconds, boundary, minuteInSeconds)
}
return Duration.ofSeconds(durationInSeconds)
}
private fun adjustToBoundary(durationInSeconds: Long, boundary: Long, minuteInSeconds: Long): Long {
val remainder = durationInSeconds % boundary
val difference = if (remainder == 0L) 0L else boundary - remainder
return if (difference > 0 && difference <= minuteInSeconds) {
durationInSeconds + difference
} else {
durationInSeconds
}
}

Copilot uses AI. Check for mistakes.

@Suppress("LongMethod")
private fun Duration.toHumanReadableFormat(): String {
Comment on lines +162 to +163
Copy link

Copilot AI Oct 15, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] The toHumanReadableFormat() method is quite long and handles multiple formatting scenarios. Consider breaking it down into smaller, more focused methods for each time unit range (formatDaysHoursMinutes, formatHoursMinutes, formatMinutes, formatSeconds) to improve readability and maintainability.

Copilot uses AI. Check for mistakes.

Copy link
Member

@hichamboushaba hichamboushaba Oct 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think AI has a point here, the method is quite long, and the multiple branches complicate the code a bit.

I'm suggesting a simpler version below, please check it out:

    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()
    }

This gives the same results, and the tests are green.

But please feel free to ignore the suggestion if you prefer the other version 🙂

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<String>()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

np, we can probably use buildString here instead of a mutableListOf<String>, for example for this first formatting logic:

                buildString {
                    append(
                        resourceProvider.getQuantityString(
                            quantity = days,
                            default = R.string.booking_duration_days,
                            one = R.string.booking_duration_day
                        )
                    )
                    if (hours > 0) {
                        append(" ")
                        append(
                            resourceProvider.getQuantityString(
                                quantity = hours,
                                default = R.string.booking_duration_hours,
                                one = R.string.booking_duration_hour
                            )
                        )
                    }
                    if (minutes > 0) {
                        append(" ")
                        append(
                            resourceProvider.getQuantityString(
                                quantity = minutes,
                                default = R.string.booking_duration_minutes,
                                one = R.string.booking_duration_minute
                            )
                        )
                    }
                }

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
)
Comment on lines +208 to +212
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure this is enough for other languages that we support, but from what I've checked, this is how we do plurals here.

For example, that wouldn't work for Polish, but we don't support Polish, so maybe that's fine.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, our plurals support is quite limited, a limitation of Glotpress, this won't work well for Arabic too, but I don't think we have much to do here.

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) {
Expand Down
8 changes: 8 additions & 0 deletions WooCommerce/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -4234,6 +4234,14 @@
<string name="booking_payment_mark_as_paid">Mark as paid</string>
<string name="booking_payment_view_order">View order</string>
<string name="booking_fetch_error">Error fetching booking</string>
<string name="booking_duration_minute">%1$d minute</string>
<string name="booking_duration_minutes">%1$d minutes</string>
<string name="booking_duration_hour">%1$d hour</string>
<string name="booking_duration_hours">%1$d hours</string>
<string name="booking_duration_day">%1$d day</string>
<string name="booking_duration_days">%1$d days</string>
<string name="booking_duration_second">%1$d second</string>
<string name="booking_duration_seconds">%1$d seconds</string>

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