diff --git a/STATUS.md b/STATUS.md index 7acba30..529dd78 100644 --- a/STATUS.md +++ b/STATUS.md @@ -1,5 +1,13 @@ # Pinakes Android — Build Status +## 1.10.0 (versionCode 24): loan requests bounded by a scheduled loan + +Pairs with Pinakes 0.8.7 (Mobile API 1.8.2). Works with older servers: every new field is optional and its absence keeps the 1.9.0 behaviour. + +- **Loan requests stop before a scheduled loan** ([#53](https://github.com/fabiodalez-dev/Pinakes-Android/pull/53), Pinakes #490): when a scheduled loan or a reservation on every copy begins inside the requested period, the sheet ends the loan the day before, says how long it can be and from when the book is booked, and sends that day as `end_date` of `POST /reservations`. A start whose next day is already booked says no loan is possible from it and keeps the request button off. The window also respects the library's `max_loan_duration_days`. One predicate decides which days are fully booked, for the calendar and the cap alike. The cap needs a server that reads `end_date`, which is the one that sends `max_loan_duration_days`: on Pinakes 0.8.6 (Mobile API 1.8.1) the sheet keeps the 1.9.0 behaviour, uncapped and without `end_date`. +- No change to the local cache database (Room version 3). +- Verification on this release branch: 446 debug unit tests with none failed or skipped, lint and `assembleDebug`; 17 device tests on an Android 15 emulator (`LoanRequestSheetUiTest`, `CalendarLayoutTest`, `DesiderataStaffFlowTest`, `DigitalLinkRefreshFlowTest`), none failed. + ## 1.9.0 (versionCode 23): digital file access, loan request sheet, wanted-title receipts Pairs with Pinakes 0.8.6 (Mobile API 1.8.1). Works with older servers: every new field is optional and its absence keeps the 1.8.0 behaviour. diff --git a/_contract/openapi.json b/_contract/openapi.json index e4c7764..f17de8d 100644 --- a/_contract/openapi.json +++ b/_contract/openapi.json @@ -839,7 +839,7 @@ "type": "string", "format": "date", "nullable": true, - "deprecated": true + "description": "Optional return date (Mobile API 1.8.2). Omitted, the library's loan_duration_days is added to the start. Send the day before the first fully-booked day of GET /catalog/books/{id}/availability when the default would cross it; must not precede the start nor exceed max_loan_duration_days (422 max_duration_exceeded)." } } }, diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 9461502..ccab9b0 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -166,8 +166,8 @@ android { testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" minSdk = 26 targetSdk = 35 - versionCode = 23 - versionName = "1.9.0" + versionCode = 24 + versionName = "1.10.0" vectorDrawables { useSupportLibrary = true diff --git a/app/src/androidTest/java/com/pinakes/app/CalendarLayoutTest.kt b/app/src/androidTest/java/com/pinakes/app/CalendarLayoutTest.kt index 189db7b..c579167 100644 --- a/app/src/androidTest/java/com/pinakes/app/CalendarLayoutTest.kt +++ b/app/src/androidTest/java/com/pinakes/app/CalendarLayoutTest.kt @@ -54,7 +54,7 @@ class CalendarLayoutTest { } @Test fun loanDialogMonthFitsThePhoneScreenAndNavigates() { - compose.setContent { PinakesTheme { LoanRequestSheet(loading = false, calendar = calendar(), fallback = false, submitting = false, onDismiss = {}, onConfirm = {}) } } + compose.setContent { PinakesTheme { LoanRequestSheet(loading = false, calendar = calendar(), fallback = false, submitting = false, onDismiss = {}, onConfirm = { _, _ -> }) } } val metrics = context.resources.displayMetrics val screen = Rect(0f, 0f, metrics.widthPixels.toFloat(), metrics.heightPixels.toFloat()) val dialog = compose.onNodeWithTag("loan-dialog").fetchSemanticsNode().boundsInRoot @@ -70,7 +70,7 @@ class CalendarLayoutTest { } @Test fun fallbackDialogUsesTheSameFittingGrid() { - compose.setContent { PinakesTheme { LoanRequestSheet(loading = false, calendar = null, fallback = true, submitting = false, onDismiss = {}, onConfirm = {}) } } + compose.setContent { PinakesTheme { LoanRequestSheet(loading = false, calendar = null, fallback = true, submitting = false, onDismiss = {}, onConfirm = { _, _ -> }) } } val dialog = compose.onNodeWithTag("loan-dialog").fetchSemanticsNode().boundsInRoot assertMonthInside(YearMonth.now(), dialog) } diff --git a/app/src/androidTest/java/com/pinakes/app/LoanRequestSheetUiTest.kt b/app/src/androidTest/java/com/pinakes/app/LoanRequestSheetUiTest.kt index 16087cd..2ed5887 100644 --- a/app/src/androidTest/java/com/pinakes/app/LoanRequestSheetUiTest.kt +++ b/app/src/androidTest/java/com/pinakes/app/LoanRequestSheetUiTest.kt @@ -44,7 +44,7 @@ class LoanRequestSheetUiTest { fallback: Boolean = false, submitting: Boolean = false, onDismiss: () -> Unit = {}, - onConfirm: (String) -> Unit = {}, + onConfirm: (String, String?) -> Unit = { _, _ -> }, ) = compose.setContent { PinakesTheme { LoanRequestSheet( @@ -61,8 +61,9 @@ class LoanRequestSheetUiTest { @Test fun bothButtonsSpanTheSheetAndConfirmSendsTheEarliestDay() { var confirmed: String? = null + var confirmedEnd: String? = "unset" var dismissed = false - sheet(onDismiss = { dismissed = true }, onConfirm = { confirmed = it }) + sheet(onDismiss = { dismissed = true }, onConfirm = { start, end -> confirmed = start; confirmedEnd = end }) val sheet = compose.onNodeWithTag("loan-sheet").getBoundsInRoot() val confirm = compose.onNodeWithTag("loan-confirm").assertIsDisplayed().assertIsEnabled().getBoundsInRoot() @@ -73,6 +74,8 @@ class LoanRequestSheetUiTest { compose.onNodeWithTag("loan-confirm").performClick() compose.runOnIdle { assertEquals(today.plusDays(1).toString(), confirmed) } + // No loan duration in this calendar: nothing caps the window, so no end_date is sent. + compose.runOnIdle { assertNull(confirmedEnd) } compose.onNodeWithTag("loan-cancel").performClick() compose.runOnIdle { assertTrue(dismissed) } } @@ -94,7 +97,7 @@ class LoanRequestSheetUiTest { compose.setContent { PinakesTheme { Box(Modifier.width(360.dp)) { - LoanRequestSheetContent(loading = false, calendar = null, fallback = true, submitting = false, onDismiss = {}, onConfirm = {}, today = today) + LoanRequestSheetContent(loading = false, calendar = null, fallback = true, submitting = false, onDismiss = {}, onConfirm = { _, _ -> }, today = today) } } } diff --git a/app/src/main/java/com/pinakes/app/data/model/Models.kt b/app/src/main/java/com/pinakes/app/data/model/Models.kt index 1a6ef06..eddfc99 100644 --- a/app/src/main/java/com/pinakes/app/data/model/Models.kt +++ b/app/src/main/java/com/pinakes/app/data/model/Models.kt @@ -41,6 +41,12 @@ data class Meta( val replayed: Boolean? = null, // 409 `merge_error` on an author/publisher merge: ids that no longer exist (already merged). val missing: List? = null, + // 409 `not_available` on POST /reservations (Mobile API 1.8.2): when the start day is free + // but a scheduled loan or reservation begins inside the requested window, the longest loan + // still possible, so the app can propose that return date. Absent otherwise and on older servers. + @SerialName("max_end_date") val maxEndDate: String? = null, + @SerialName("max_duration_days") val maxDurationDays: Int? = null, + @SerialName("first_conflict_date") val firstConflictDate: String? = null, ) @Serializable @@ -516,6 +522,8 @@ data class AvailabilityCalendar( // Days POST /reservations adds to the chosen start to set the return date (Mobile API // 1.8.1). Null on older servers: the dialog then names the standard period without a date. @SerialName("loan_duration_days") val loanDurationDays: Int? = null, + // Longest loan POST /reservations accepts through end_date (Mobile API 1.8.2); null on older servers. + @SerialName("max_loan_duration_days") val maxLoanDurationDays: Int? = null, ) @Serializable @@ -535,7 +543,9 @@ data class ReservationRequest( // request flow sends. @SerialName("desired_date") val desiredDate: String? = null, @SerialName("start_date") val startDate: String? = null, // legacy/compat, yyyy-MM-dd - @SerialName("end_date") val endDate: String? = null, // legacy/compat, yyyy-MM-dd + // Optional return date (Mobile API 1.8.2): the day before a fully-booked day when the + // library's duration would cross it (see LoanWindow). Omitted, the server adds the duration. + @SerialName("end_date") val endDate: String? = null, // yyyy-MM-dd ) /** Authoritative result of POST /reservations (loan request or FIFO reservation). */ diff --git a/app/src/main/java/com/pinakes/app/data/repository/LibraryRepository.kt b/app/src/main/java/com/pinakes/app/data/repository/LibraryRepository.kt index f433437..27efa62 100644 --- a/app/src/main/java/com/pinakes/app/data/repository/LibraryRepository.kt +++ b/app/src/main/java/com/pinakes/app/data/repository/LibraryRepository.kt @@ -63,17 +63,19 @@ class LibraryRepository( /** * Request a loan/reservation for [bookId] starting on [desiredDate] (yyyy-MM-dd). The * backend sets the return date from the library's configured loan duration - * (loan_duration_days), matching the website behaviour. + * (loan_duration_days), matching the website behaviour, unless [endDate] names an earlier + * one: the day before a fully-booked day the default would cross (Mobile API 1.8.2). */ suspend fun reserve( bookId: Int, desiredDate: String? = null, + endDate: String? = null, ): ApiResult { val api = network.api() // The request lands in pending loans or in reservations; the caller refreshes the // offline copy afterwards (refreshCache) so the confirmation is not held back by // two more round trips. - return apiCall { api.createReservation(ReservationRequest(bookId = bookId, desiredDate = desiredDate)) } + return apiCall { api.createReservation(ReservationRequest(bookId = bookId, desiredDate = desiredDate, endDate = endDate)) } } suspend fun cancelReservation(reservationId: Int): ApiResult { diff --git a/app/src/main/java/com/pinakes/app/ui/screens/detail/BookDetailScreen.kt b/app/src/main/java/com/pinakes/app/ui/screens/detail/BookDetailScreen.kt index 9218c57..d2d9986 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/detail/BookDetailScreen.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/detail/BookDetailScreen.kt @@ -221,7 +221,7 @@ fun BookDetailScreen( fallback = state.availabilityFallback, submitting = state.reserveBusy, onDismiss = vm::dismissLoanSheet, - onConfirm = vm::reserve, + onConfirm = { start, end -> vm.reserve(start, end) }, ) } } @@ -238,7 +238,8 @@ internal fun LoanRequestSheet( fallback: Boolean, submitting: Boolean, onDismiss: () -> Unit, - onConfirm: (isoDate: String) -> Unit, + /** The chosen start and, when a fully-booked day shortens the default duration, the return date. */ + onConfirm: (isoDate: String, endIso: String?) -> Unit, /** Passed to [LoanCalendar]; null reads the device clock. */ today: LocalDate? = null, ) { @@ -269,7 +270,8 @@ internal fun LoanRequestSheetContent( fallback: Boolean, submitting: Boolean, onDismiss: () -> Unit, - onConfirm: (isoDate: String) -> Unit, + /** The chosen start and, when a fully-booked day shortens the default duration, the return date. */ + onConfirm: (isoDate: String, endIso: String?) -> Unit, /** Passed to [LoanCalendar]; null reads the device clock. */ today: LocalDate? = null, ) { @@ -277,6 +279,11 @@ internal fun LoanRequestSheetContent( var selectedIso by remember(calendar) { mutableStateOf(calendar?.earliestAvailable?.takeIf { it.isNotBlank() }) } + // A fully-booked day the reader tapped: explained under the calendar until the next pick. + var blockedIso by remember(calendar) { mutableStateOf(null) } + // The loan still possible from the chosen start: the default duration, or shorter when a + // scheduled loan or reservation begins inside it (then the request carries end_date). + val window = selectedIso?.let { loanWindowFrom(it, calendar, calendar?.loanDurationDays) } Surface( shape = RoundedCornerShape(20.dp), @@ -322,8 +329,9 @@ internal fun LoanRequestSheetContent( LoanCalendar( calendar = calendar, selected = selectedIso, - onSelect = { selectedIso = it }, + onSelect = { selectedIso = it; blockedIso = null }, today = today, + onBlockedTap = { blockedIso = it }, ) } @@ -345,18 +353,47 @@ internal fun LoanRequestSheetContent( } } - // The return date uses the server's configured duration (#51). + // The return date uses the server's configured duration (#51), stopped the day + // before a fully-booked day when one falls inside it. val hint = selectedIso?.let { start -> - calendar?.loanDurationDays?.let { loanReturnDate(start, it) }?.let { end -> - stringResource(R.string.book_loan_period_range, formatDisplayDate(start), formatDisplayDate(end)) + window?.let { w -> + stringResource(R.string.book_loan_period_range, formatDisplayDate(start), formatDisplayDate(w.endDate)) } ?: stringResource(R.string.book_loan_period_hint, formatDisplayDate(start)) } ?: stringResource(R.string.book_loan_pick_prompt) Text( text = hint, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(vertical = Spacing.md), + modifier = Modifier.padding(top = Spacing.md, bottom = if (window?.capped == true || blockedIso != null) Spacing.xs else Spacing.md), ) + // Why the loan is shorter than the library's standard duration, and why a tapped + // red day cannot be chosen: the same explanation the website gives. + val limitText = window?.takeIf { it.capped }?.let { w -> + if (!w.possible) { + // The next day is blocked: no loan of at least one day from this start. + stringResource(R.string.book_loan_no_window, formatDisplayDate(w.firstBlocked ?: w.endDate)) + } else if (w.maxDays == 1) { + // The string generator has no plurals: a singular variant instead of "1 days". + stringResource(R.string.book_loan_limit_note_one, formatDisplayDate(w.endDate), formatDisplayDate(w.firstBlocked ?: w.endDate)) + } else { + stringResource( + R.string.book_loan_limit_note, + w.maxDays, + formatDisplayDate(w.endDate), + formatDisplayDate(w.firstBlocked ?: w.endDate), + ) + } + } + val blockedText = blockedIso?.let { stringResource(R.string.book_loan_blocked_day, formatDisplayDate(it)) } + val explanation = listOfNotNull(blockedText, limitText).joinToString(" ") + if (explanation.isNotEmpty()) { + Text( + text = explanation, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.tertiary, + modifier = Modifier.padding(bottom = Spacing.md).testTag("loan-limit"), + ) + } Column( Modifier.padding(bottom = Spacing.lg), @@ -364,8 +401,8 @@ internal fun LoanRequestSheetContent( ) { PrimaryButton( label = stringResource(R.string.book_loan_confirm), - onClick = { selectedIso?.let(onConfirm) }, - enabled = selectedIso != null, + onClick = { selectedIso?.let { start -> onConfirm(start, window?.takeIf { it.requiresEndDate }?.endDate) } }, + enabled = selectedIso != null && window?.possible != false, loading = submitting, modifier = Modifier.fillMaxWidth().testTag("loan-confirm"), ) diff --git a/app/src/main/java/com/pinakes/app/ui/screens/detail/BookDetailViewModel.kt b/app/src/main/java/com/pinakes/app/ui/screens/detail/BookDetailViewModel.kt index b758c9d..c3e43d0 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/detail/BookDetailViewModel.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/detail/BookDetailViewModel.kt @@ -179,14 +179,16 @@ class BookDetailViewModel @Inject constructor( /** * Submit a loan/reservation request for [desiredDate] (yyyy-MM-dd, today-or-future). The - * backend sets the return from the library's configured loan duration. On success we surface - * the chosen date so the UI can confirm exactly what was requested. + * backend sets the return from the library's configured loan duration, unless [endDate] + * asks for an earlier one (the day before a fully-booked day the default would cross, see + * LoanWindow). On success we surface the chosen date so the UI can confirm exactly what + * was requested. */ - fun reserve(desiredDate: String) { + fun reserve(desiredDate: String, endDate: String? = null) { if (_state.value.reserveBusy) return _state.update { it.copy(reserveBusy = true) } viewModelScope.launch { - when (val res = library.reserve(bookId, desiredDate)) { + when (val res = library.reserve(bookId, desiredDate, endDate)) { is ApiResult.Success -> { _state.update { it.copy( @@ -207,6 +209,9 @@ class BookDetailViewModel @Inject constructor( is ApiResult.Failure -> { // Close the calendar dialog so the error is visible: the snackbar // lives at the screen Scaffold level, behind the open DatePickerDialog. + // A `not_available` refusal carries the server's own explanation (the + // longest loan still possible, and from which day the book is booked): + // that message, not the generic conflict text, is what the reader sees. when { res.code == ErrorCodes.CONFLICT -> _state.update { it.copy(reserveBusy = false, showLoanSheet = false, snackbar = null, snackbarRes = R.string.snackbar_request_conflict) } diff --git a/app/src/main/java/com/pinakes/app/ui/screens/detail/LoanCalendar.kt b/app/src/main/java/com/pinakes/app/ui/screens/detail/LoanCalendar.kt index 7bc1e0a..ef79bfb 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/detail/LoanCalendar.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/detail/LoanCalendar.kt @@ -87,12 +87,10 @@ private fun ruled(container: Color, onContainer: Color) = DayTint(container, onContainer, mix(onContainer, container, .22f)) /** Index per-day data by ISO date for O(1) lookup, and the set of unavailable dates. */ -private class CalendarModel(calendar: AvailabilityCalendar) { +private class CalendarModel(private val source: AvailabilityCalendar) { private val byDate: Map = - calendar.days.associate { it.date to it.state } // date -> state - private val available: Map = - calendar.days.associate { it.date to it.available } - private val unavailable: Set = calendar.unavailableDates.toSet() + source.days.associate { it.date to it.state } // date -> state + private val unavailable: Set = source.unavailableDates.toSet() private val knownDates: Set = byDate.keys /** True only when we have fetched data covering this day at all. */ @@ -101,11 +99,11 @@ private class CalendarModel(calendar: AvailabilityCalendar) { fun kind(date: LocalDate, today: LocalDate): DayKind { val iso = date.toString() if (date.isBefore(today)) return DayKind.PastOrUnknown - if (unavailable.contains(iso)) return DayKind.Full + // Same predicate as loanWindowFrom(): a red day is a day that caps the loan. + if (source.isFullyBooked(iso)) return DayKind.Full return when (byDate[iso]) { - "free" -> if ((available[iso] ?: 1) > 0) DayKind.Free else DayKind.Full + "free" -> DayKind.Free "partial" -> DayKind.Partial - "full" -> DayKind.Full else -> DayKind.PastOrUnknown // beyond the fetched window → unknown/disabled } } @@ -136,6 +134,8 @@ fun LoanCalendar( anyFutureDay: Boolean = false, /** The day the grid treats as today; null reads the device clock once. Tests pin it. */ today: LocalDate? = null, + /** A fully-booked future day cannot be picked; tapping it calls this so the sheet can say why. */ + onBlockedTap: ((isoDate: String) -> Unit)? = null, ) { val dark = LocalPinakesColors.current.darkMode val deviceToday = remember { LocalDate.now() } @@ -167,6 +167,7 @@ fun LoanCalendar( dark = dark, onSelect = onSelect, anyFutureDay = anyFutureDay, + onBlockedTap = onBlockedTap, ) if (!anyFutureDay) { Spacer(Modifier.height(Spacing.md)) @@ -234,6 +235,7 @@ private fun MonthGrid( dark: Boolean, onSelect: (String) -> Unit, anyFutureDay: Boolean = false, + onBlockedTap: ((String) -> Unit)? = null, ) { val firstOfMonth = month.atDay(1) // Mon=1 .. Sun=7 → leading blanks before day 1. @@ -262,6 +264,7 @@ private fun MonthGrid( dark = dark, onSelect = onSelect, anyFutureDay = anyFutureDay, + onBlockedTap = onBlockedTap, ) } } @@ -280,6 +283,7 @@ private fun DayCell( dark: Boolean, onSelect: (String) -> Unit, anyFutureDay: Boolean = false, + onBlockedTap: ((String) -> Unit)? = null, ) { val iso = date.toString() val kind = model.kind(date, today) @@ -310,7 +314,12 @@ private fun DayCell( if (rule != Color.Transparent) Modifier.border(1.dp, rule, CircleShape) else Modifier ) .then( - if (selectable) Modifier.clickable { onSelect(iso) } else Modifier + when { + selectable -> Modifier.clickable { onSelect(iso) } + // Not pickable, but a tap must still explain why (every copy is booked). + onBlockedTap != null && kind == DayKind.Full -> Modifier.clickable { onBlockedTap(iso) } + else -> Modifier + } ) Box(cellModifier, contentAlignment = Alignment.Center) { diff --git a/app/src/main/java/com/pinakes/app/ui/screens/detail/LoanWindow.kt b/app/src/main/java/com/pinakes/app/ui/screens/detail/LoanWindow.kt new file mode 100644 index 0000000..06dc6ed --- /dev/null +++ b/app/src/main/java/com/pinakes/app/ui/screens/detail/LoanWindow.kt @@ -0,0 +1,86 @@ +package com.pinakes.app.ui.screens.detail + +import com.pinakes.app.data.model.AvailabilityCalendar +import java.time.LocalDate +import java.time.temporal.ChronoUnit + +/** + * The loan the reader can still ask for from a start day, read off the availability calendar. + * + * The server adds the library's `loan_duration_days` to the chosen start to set the return + * date. When a scheduled loan or a reservation on every copy (a `full` day) begins before that + * default return date, the server refuses the whole window, so the request must stop the day + * before it instead: that is [endDate], sent as `end_date` of POST /reservations (Mobile API + * 1.8.2), and [capped] says the default was shortened, which the sheet explains under the + * calendar with [maxDays] and [firstBlocked]. Same rule as the website's calendar. + */ +data class LoanWindow( + /** The return date to request: start + duration, or the day before [firstBlocked]. */ + val endDate: String, + /** The first fully-booked day inside the default duration, or null. */ + val firstBlocked: String?, + /** Days from the start to [endDate]. */ + val maxDays: Int, + /** True when [firstBlocked] shortened the default duration. */ + val capped: Boolean, + /** Send the displayed date when either a commitment or the configured maximum shortened it. */ + val requiresEndDate: Boolean = capped, +) { + /** False when the very next day is blocked: no loan of at least one day exists from this start. */ + val possible: Boolean get() = maxDays >= 1 +} + +/** + * A day the grid paints red and refuses to pick: listed in `unavailable_dates`, marked `full`, + * or labelled `free` with no free copy (a stale label the server can send). One predicate for + * the calendar and the window, so what the reader sees as blocked is what caps the loan. + */ +internal fun AvailabilityCalendar.isFullyBooked(isoDate: String): Boolean { + if (unavailableDates.contains(isoDate)) return true + val day = days.firstOrNull { it.date == isoDate } ?: return false + return day.state == "full" || (day.state == "free" && day.available <= 0) +} + +/** + * The window from [startIso] (yyyy-MM-dd), or null when the duration is unknown (an older + * server omits `loan_duration_days`) or the start is not a date: then the server's default + * applies and nothing can be said about it. + * + * Only a server that reads `end_date` (Mobile API 1.8.2, Pinakes 0.8.7) can grant a shortened + * window, and it is the one that sends `max_loan_duration_days`. Pinakes 0.8.6 (Mobile API + * 1.8.1) sends `loan_duration_days` but ignores `end_date`: there the window stays the default + * one, uncapped and without `end_date`, as in 1.9.0, instead of promising a shorter loan the + * server would refuse or end on another day. + */ +internal fun loanWindowFrom(startIso: String, calendar: AvailabilityCalendar?, loanDurationDays: Int?): LoanWindow? { + if (loanDurationDays == null || loanDurationDays < 1) return null + val start = runCatching { LocalDate.parse(startIso) }.getOrNull() ?: return null + if (calendar?.maxLoanDurationDays == null) { + return LoanWindow( + endDate = start.plusDays(loanDurationDays.toLong()).toString(), + firstBlocked = null, + maxDays = loanDurationDays, + capped = false, + requiresEndDate = false, + ) + } + val durationDays = calendar?.maxLoanDurationDays?.takeIf { it > 0 } + ?.let { minOf(loanDurationDays, it) } ?: loanDurationDays + var firstBlocked: String? = null + for (offset in 1..durationDays) { + val day = start.plusDays(offset.toLong()).toString() + if (calendar?.isFullyBooked(day) == true) { + firstBlocked = day + break + } + } + val defaultEnd = start.plusDays(durationDays.toLong()) + val end = firstBlocked?.let { LocalDate.parse(it).minusDays(1) } ?: defaultEnd + return LoanWindow( + endDate = end.toString(), + firstBlocked = firstBlocked, + maxDays = ChronoUnit.DAYS.between(start, end).toInt(), + capped = firstBlocked != null, + requiresEndDate = end.isBefore(start.plusDays(loanDurationDays.toLong())), + ) +} diff --git a/app/src/test/java/com/pinakes/app/LoanCalendarRegressionTest.kt b/app/src/test/java/com/pinakes/app/LoanCalendarRegressionTest.kt index 1758f1b..14f6d41 100644 --- a/app/src/test/java/com/pinakes/app/LoanCalendarRegressionTest.kt +++ b/app/src/test/java/com/pinakes/app/LoanCalendarRegressionTest.kt @@ -138,7 +138,7 @@ class LoanCalendarRegressionTest { @Test fun loadingLeavesCancelAvailableAndPreventsAnEmptyRequest() { compose.setContent { PinakesTheme { - LoanRequestSheetContent(true, null, false, false, {}, { picked = it }) + LoanRequestSheetContent(true, null, false, false, {}, { start, _ -> picked = start }) } } compose.onNodeWithTag("loan-confirm").assertIsNotEnabled() compose.onNodeWithTag("loan-cancel").assertIsEnabled() @@ -149,7 +149,7 @@ class LoanCalendarRegressionTest { @Test fun availabilityArrivingAfterLoadingPreselectsTheServerSuggestedDay() { var data by mutableStateOf(null) compose.setContent { PinakesTheme { - LoanRequestSheetContent(data == null, data, false, false, {}, { picked = it }) + LoanRequestSheetContent(data == null, data, false, false, {}, { start, _ -> picked = start }) } } compose.runOnIdle { data = calendar() } compose.onNodeWithTag("loan-confirm").performScrollTo().assertIsEnabled().performClick() @@ -161,7 +161,7 @@ class LoanCalendarRegressionTest { val data = calendar().copy(days = listOf(AvailabilityDay(target.toString(), available = 1), AvailabilityDay(next.toString(), available = 1))) compose.setContent { PinakesTheme { - LoanRequestSheetContent(false, data, false, false, {}, { picked = it }) + LoanRequestSheetContent(false, data, false, false, {}, { start, _ -> picked = start }) } } day(next).performScrollTo().performClick() compose.onNodeWithTag("loan-confirm").performScrollTo().performClick() @@ -171,7 +171,7 @@ class LoanCalendarRegressionTest { @Test fun fallbackSelectionIsConfirmedAsAnIsoDate() { val today = LocalDate.now() compose.setContent { PinakesTheme { - LoanRequestSheetContent(false, null, true, false, {}, { picked = it }) + LoanRequestSheetContent(false, null, true, false, {}, { start, _ -> picked = start }) } } compose.onNodeWithTag("loan-confirm").assertIsNotEnabled() day(today).performScrollTo().performClick() @@ -183,7 +183,7 @@ class LoanCalendarRegressionTest { var dismissed = false compose.setContent { PinakesTheme { Box(Modifier.width(320.dp).height(240.dp)) { - LoanRequestSheetContent(false, calendar(), false, false, { dismissed = true }, {}) + LoanRequestSheetContent(false, calendar(), false, false, { dismissed = true }, { _, _ -> }) } } } compose.onNodeWithTag("loan-cancel").performScrollTo().assertIsDisplayed() diff --git a/app/src/test/java/com/pinakes/app/LoanRequestReturnDateTest.kt b/app/src/test/java/com/pinakes/app/LoanRequestReturnDateTest.kt index 45ab92d..9524c8e 100644 --- a/app/src/test/java/com/pinakes/app/LoanRequestReturnDateTest.kt +++ b/app/src/test/java/com/pinakes/app/LoanRequestReturnDateTest.kt @@ -48,7 +48,7 @@ class LoanRequestReturnDateTest { private fun show(calendar: AvailabilityCalendar) { compose.setContent { PinakesTheme { LoanRequestSheet(loading = false, calendar = calendar, fallback = false, submitting = false, - onDismiss = {}, onConfirm = {}) + onDismiss = {}, onConfirm = { _, _ -> }) } } } diff --git a/app/src/test/java/com/pinakes/app/LoanRequestWindowTest.kt b/app/src/test/java/com/pinakes/app/LoanRequestWindowTest.kt new file mode 100644 index 0000000..8b2e208 --- /dev/null +++ b/app/src/test/java/com/pinakes/app/LoanRequestWindowTest.kt @@ -0,0 +1,132 @@ +package com.pinakes.app + +import android.app.Application +import android.content.ComponentName +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import androidx.test.core.app.ApplicationProvider +import com.pinakes.app.data.model.AvailabilityCalendar +import com.pinakes.app.data.model.AvailabilityDay +import com.pinakes.app.ui.common.DateFormat +import com.pinakes.app.ui.screens.detail.LoanRequestSheetContent +import com.pinakes.app.ui.theme.PinakesTheme +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Rule +import org.junit.Test +import org.junit.rules.RuleChain +import org.junit.rules.TestWatcher +import org.junit.runner.Description +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +/** + * The loan-request sheet stops the return date the day before a fully-booked day inside the + * library's loan duration, says so, sends that date as end_date, and explains a tapped red day. + * Same rule as the website calendar and the server's LoanWindow (Mobile API 1.8.2). + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class, qualifiers = "w411dp-h891dp-xxhdpi") +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class LoanRequestWindowTest { + private val compose = createComposeRule() + private val context get() = ApplicationProvider.getApplicationContext() + @get:Rule val rules: RuleChain = RuleChain.outerRule(object : TestWatcher() { + override fun starting(description: Description) { + val app = ApplicationProvider.getApplicationContext() + shadowOf(app.packageManager).addActivityIfNotPresent(ComponentName(app.packageName, ComponentActivity::class.java.name)) + } + }).around(compose) + + private var pickedStart: String? = null + private var pickedEnd: String? = null + + /** A single-copy title, free from 2030-03-04, fully booked on [full]. */ + private fun calendar(vararg full: String, duration: Int = 14) = AvailabilityCalendar( + totalCopies = 1, + earliestAvailable = "2030-03-04", + loanDurationDays = duration, + maxLoanDurationDays = 90, + days = (1..40).map { i -> + val date = java.time.LocalDate.parse("2030-03-04").plusDays(i.toLong() - 1).toString() + if (date in full) AvailabilityDay(date = date, available = 0, loaned = 1, state = "full") + else AvailabilityDay(date = date, available = 1, state = "free") + }, + ) + + private fun show(calendar: AvailabilityCalendar) { + compose.setContent { PinakesTheme { + LoanRequestSheetContent(false, calendar, false, false, {}, { start, end -> pickedStart = start; pickedEnd = end }) + } } + } + + @Test fun aScheduledLoanInsideTheDurationShortensTheReturnDateAndSaysSo() { + show(calendar("2030-03-09")) + compose.onNodeWithText(context.getString(R.string.book_loan_period_range, DateFormat.date("2030-03-04"), DateFormat.date("2030-03-08"))) + .performScrollTo().assertIsDisplayed() + compose.onNodeWithTag("loan-limit").performScrollTo().assertIsDisplayed() + compose.onNodeWithText(context.getString(R.string.book_loan_limit_note, 4, DateFormat.date("2030-03-08"), DateFormat.date("2030-03-09"))) + .assertIsDisplayed() + } + + @Test fun confirmingACappedRequestSendsTheShortenedReturnDate() { + show(calendar("2030-03-09")) + compose.onNodeWithTag("loan-confirm").performScrollTo().assertIsEnabled().performClick() + compose.runOnIdle { + assertEquals("2030-03-04", pickedStart) + assertEquals("2030-03-08", pickedEnd) + } + } + + @Test fun withNoConflictTheServerKeepsItsDefaultAndNothingIsExplained() { + show(calendar("2030-04-20")) + compose.onNodeWithText(context.getString(R.string.book_loan_period_range, DateFormat.date("2030-03-04"), DateFormat.date("2030-03-18"))) + .performScrollTo().assertIsDisplayed() + compose.onNodeWithTag("loan-limit").assertDoesNotExist() + compose.onNodeWithTag("loan-confirm").performScrollTo().performClick() + compose.runOnIdle { + assertEquals("2030-03-04", pickedStart) + assertNull(pickedEnd) + } + } + + @Test fun tappingAFullyBookedDayExplainsWhyItCannotBePicked() { + show(calendar("2030-03-09")) + compose.onNodeWithTag("calendar-day-2030-03-09", useUnmergedTree = true).performScrollTo().performClick() + val expected = context.getString(R.string.book_loan_blocked_day, DateFormat.date("2030-03-09")) + " " + + context.getString(R.string.book_loan_limit_note, 4, DateFormat.date("2030-03-08"), DateFormat.date("2030-03-09")) + compose.onNodeWithText(expected).performScrollTo().assertIsDisplayed() + // The pick is unchanged: the blocked day never becomes the start. + compose.onNodeWithTag("loan-confirm").performScrollTo().performClick() + compose.runOnIdle { assertEquals("2030-03-04", pickedStart) } + } + + @Test fun aCommitmentOnTheVeryNextDayLeavesNoLoanAndDisablesTheRequest() { + show(calendar("2030-03-05")) + compose.onNodeWithText(context.getString(R.string.book_loan_no_window, DateFormat.date("2030-03-05"))) + .performScrollTo().assertIsDisplayed() + compose.onNodeWithTag("loan-confirm").performScrollTo().assertIsNotEnabled() + } + + @Test fun theMaximumDurationIsShownAndSentWithoutABookingExplanation() { + show(calendar("2030-03-14", duration = 30).copy(maxLoanDurationDays = 7)) + compose.onNodeWithText(context.getString(R.string.book_loan_period_range, DateFormat.date("2030-03-04"), DateFormat.date("2030-03-11"))) + .performScrollTo().assertIsDisplayed() + compose.onNodeWithTag("loan-limit").assertDoesNotExist() + compose.onNodeWithTag("loan-confirm").performScrollTo().performClick() + compose.runOnIdle { + assertEquals("2030-03-04", pickedStart) + assertEquals("2030-03-11", pickedEnd) + } + } +} diff --git a/app/src/test/java/com/pinakes/app/LoanWindowTest.kt b/app/src/test/java/com/pinakes/app/LoanWindowTest.kt new file mode 100644 index 0000000..83e2f01 --- /dev/null +++ b/app/src/test/java/com/pinakes/app/LoanWindowTest.kt @@ -0,0 +1,130 @@ +package com.pinakes.app + +import com.pinakes.app.data.model.AvailabilityCalendar +import com.pinakes.app.data.model.AvailabilityDay +import com.pinakes.app.ui.screens.detail.isFullyBooked +import com.pinakes.app.ui.screens.detail.loanWindowFrom +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The loan-request sheet stops the return date the day before a fully-booked day inside the + * library's loan duration, and says so; otherwise it proposes start + duration. Mirrors the + * website calendar and the server's LoanWindow (Mobile API 1.8.2). + */ +class LoanWindowTest { + /** A Pinakes 0.8.7 calendar: it always sends max_loan_duration_days (90 by default). */ + private fun calendar(vararg full: String, partial: List = emptyList()) = AvailabilityCalendar( + totalCopies = 1, + maxLoanDurationDays = 90, + days = (full.map { AvailabilityDay(date = it, available = 0, state = "full") } + + partial.map { AvailabilityDay(date = it, available = 1, state = "partial") }), + ) + + @Test fun aScheduledLoanInsideTheDurationCapsTheReturnDate() { + val window = loanWindowFrom("2030-03-04", calendar("2030-03-09", "2030-03-10"), 30) + assertEquals("2030-03-08", window?.endDate) + assertEquals("2030-03-09", window?.firstBlocked) + assertEquals(4, window?.maxDays) + assertTrue(window!!.capped) + } + + @Test fun noConflictMeansStartPlusTheDuration() { + val window = loanWindowFrom("2030-03-04", calendar("2030-05-01"), 14) + assertEquals("2030-03-18", window?.endDate) + assertNull(window?.firstBlocked) + assertEquals(14, window?.maxDays) + assertFalse(window!!.capped) + } + + @Test fun aConflictBeyondTheDurationDoesNotCap() { + val window = loanWindowFrom("2030-03-04", calendar("2030-03-19"), 14) + assertEquals("2030-03-18", window?.endDate) + assertFalse(window!!.capped) + } + + @Test fun thePickedDayItselfIsNeverTheConflict() { + // The chosen start is free by construction; only the days after it are searched. + val window = loanWindowFrom("2030-03-04", calendar("2030-03-04", "2030-03-05"), 14) + assertEquals("2030-03-05", window?.firstBlocked) + assertEquals("2030-03-04", window?.endDate) + assertEquals(0, window?.maxDays) + // A window shorter than a day is no loan: the sheet says so and disables the request. + assertFalse(window!!.possible) + assertTrue(loanWindowFrom("2030-03-04", calendar("2030-03-06"), 14)!!.possible) + } + + @Test fun aFreeLabelWithNoFreeCopyIsBlockedLikeTheCalendarPaintsIt() { + // CalendarModel paints state "free" with available 0 red and unpickable: the window agrees. + val stale = AvailabilityCalendar(totalCopies = 1, maxLoanDurationDays = 90, days = listOf(AvailabilityDay(date = "2030-03-07", available = 0, state = "free"))) + val window = loanWindowFrom("2030-03-04", stale, 14) + assertEquals("2030-03-06", window?.endDate) + assertTrue(window!!.capped) + assertTrue(stale.isFullyBooked("2030-03-07")) + assertFalse(stale.isFullyBooked("2030-03-06")) + } + + @Test fun aPartialDayStillHasAFreeCopy() { + val window = loanWindowFrom("2030-03-04", calendar(partial = listOf("2030-03-06")), 14) + assertFalse(window!!.capped) + } + + @Test fun unavailableDatesCountAsBlockedEvenWithoutADayRow() { + val window = loanWindowFrom("2030-03-04", AvailabilityCalendar(totalCopies = 1, maxLoanDurationDays = 90, unavailableDates = listOf("2030-03-07")), 14) + assertEquals("2030-03-06", window?.endDate) + assertTrue(window!!.capped) + } + + @Test fun unknownDurationOrBadStartGivesNoWindow() { + assertNull(loanWindowFrom("2030-03-04", calendar("2030-03-09"), null)) + assertNull(loanWindowFrom("2030-03-04", calendar("2030-03-09"), 0)) + assertNull(loanWindowFrom("not a date", calendar("2030-03-09"), 14)) + assertNull(loanWindowFrom("2030-03-04", null, null)) + } + + @Test fun theConfiguredMaximumWinsBeforeALaterCommitment() { + val calendar = calendar("2030-03-14").copy(maxLoanDurationDays = 7) + val window = loanWindowFrom("2030-03-04", calendar, 30)!! + assertEquals("2030-03-11", window.endDate) + assertEquals(7, window.maxDays) + assertNull(window.firstBlocked) + assertFalse(window.capped) // No booking explanation for a settings limit. + assertTrue(window.requiresEndDate) + } + + @Test fun aCommitmentInsideTheMaximumCanStillShortenTheLoan() { + val calendar = calendar("2030-03-09").copy(maxLoanDurationDays = 7) + val window = loanWindowFrom("2030-03-04", calendar, 30)!! + assertEquals("2030-03-08", window.endDate) + assertTrue(window.capped) + assertTrue(window.requiresEndDate) + } + + @Test fun anAlreadyBoundedDefaultNeedsNoExplicitEndDate() { + val window = loanWindowFrom("2030-03-04", calendar().copy(maxLoanDurationDays = 7), 7)!! + assertEquals("2030-03-11", window.endDate) + assertFalse(window.requiresEndDate) + } + + @Test fun aServerThatIgnoresEndDateKeepsTheDefaultWindow() { + // Pinakes 0.8.6 (Mobile API 1.8.1): loan_duration_days but no max_loan_duration_days, + // and POST /reservations ignores end_date. A booked day inside the window caps nothing. + val old = calendar("2030-03-09").copy(maxLoanDurationDays = null) + val window = loanWindowFrom("2030-03-04", old, 14)!! + assertEquals("2030-03-18", window.endDate) + assertFalse(window.capped) + assertFalse(window.requiresEndDate) + assertNull(window.firstBlocked) + assertEquals(14, window.maxDays) + assertTrue(window.possible) + } + + @Test fun aServerThatIgnoresEndDateNeverSaysNoLoanIsPossible() { + // A blocked next day would make the 0.8.7 sheet refuse the start; 0.8.6 decides itself. + val old = calendar("2030-03-05").copy(maxLoanDurationDays = null) + assertTrue(loanWindowFrom("2030-03-04", old, 14)!!.possible) + } +} diff --git a/app/src/test/java/com/pinakes/app/ReservationRefusalEnvelopeTest.kt b/app/src/test/java/com/pinakes/app/ReservationRefusalEnvelopeTest.kt new file mode 100644 index 0000000..6d49a3d --- /dev/null +++ b/app/src/test/java/com/pinakes/app/ReservationRefusalEnvelopeTest.kt @@ -0,0 +1,62 @@ +package com.pinakes.app + +import com.pinakes.app.data.model.AvailabilityCalendar +import com.pinakes.app.data.model.ReservationRequest +import com.pinakes.app.data.network.parseErrorBody +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * What the server says to the app on POST /reservations (Mobile API 1.8.2): a `not_available` + * refusal carries its own sentence as the message and the longest loan still possible in meta; + * the request carries `end_date` only when the sheet shortened the loan; the availability + * payload names both durations. + */ +class ReservationRefusalEnvelopeTest { + private val json = Json { ignoreUnknownKeys = true; encodeDefaults = false } + + @Test fun aNotAvailableRefusalCarriesTheSentenceAndTheFigures() { + val body = """{"data":null,"meta":{"max_duration_days":4,"max_end_date":"2030-03-08","first_conflict_date":"2030-03-09"}, + "error":{"code":"not_available","message":"Durata massima del prestito: 4 giorni (restituzione entro il 08-03-2030). Dal 09-03-2030 il libro è già prenotato."}}""" + val failure = parseErrorBody(body, 409) + assertEquals("not_available", failure.code) + assertTrue(failure.fromEnvelope) + assertTrue(failure.message.contains("4 giorni")) + assertEquals("2030-03-08", failure.meta?.maxEndDate) + assertEquals(4, failure.meta?.maxDurationDays) + assertEquals("2030-03-09", failure.meta?.firstConflictDate) + } + + @Test fun aRefusalWithoutFiguresLeavesThemNull() { + val body = """{"data":null,"meta":{},"error":{"code":"not_available","message":"Il libro non è disponibile per il periodo richiesto."}}""" + val failure = parseErrorBody(body, 409) + assertEquals("not_available", failure.code) + assertNull(failure.meta?.maxEndDate) + assertNull(failure.meta?.maxDurationDays) + } + + @Test fun anOverLongReturnDateIsAValidationRefusalWithItsOwnCode() { + val failure = parseErrorBody("""{"data":null,"meta":{},"error":{"code":"max_duration_exceeded","message":"x"}}""", 422) + assertEquals("max_duration_exceeded", failure.code) + assertEquals(422, failure.httpStatus) + } + + @Test fun theRequestCarriesEndDateOnlyWhenTheSheetShortenedTheLoan() { + val plain = json.encodeToString(ReservationRequest(bookId = 7, desiredDate = "2030-03-04")) + assertFalse(plain.contains("end_date")) + val capped = json.encodeToString(ReservationRequest(bookId = 7, desiredDate = "2030-03-04", endDate = "2030-03-08")) + assertTrue(capped.contains("\"end_date\":\"2030-03-08\"")) + } + + @Test fun theAvailabilityPayloadNamesBothDurationsAndOlderServersOmitTheMaximum() { + val both = json.decodeFromString("""{"total_copies":1,"loan_duration_days":21,"max_loan_duration_days":90}""") + assertEquals(21, both.loanDurationDays) + assertEquals(90, both.maxLoanDurationDays) + assertNull(json.decodeFromString("""{"total_copies":1,"loan_duration_days":21}""").maxLoanDurationDays) + } +} diff --git a/i18n/de.json b/i18n/de.json index d6fbfdc..d8fd814 100644 --- a/i18n/de.json +++ b/i18n/de.json @@ -260,6 +260,10 @@ "book_loan_availability_loading": "Verfügbarkeit wird geprüft…", "book_loan_availability_error": "Verfügbarkeitskalender konnte nicht geladen werden; wählen Sie ein zukünftiges Datum.", "book_loan_pick_prompt": "Wählen Sie einen verfügbaren Tag", + "book_loan_limit_note": "Maximale Leihdauer: %1$d Tage (Rückgabe bis %2$s). Ab dem %3$s ist das Buch bereits vorgemerkt.", + "book_loan_limit_note_one": "Maximale Leihdauer: 1 Tag (Rückgabe bis %1$s). Ab dem %2$s ist das Buch bereits vorgemerkt.", + "book_loan_blocked_day": "Tag nicht verfügbar (%1$s): alle Exemplare sind bereits ausgeliehen oder vorgemerkt.", + "book_loan_no_window": "Ab diesem Startdatum ist keine Ausleihe möglich: ab dem %1$s ist das Buch bereits vorgemerkt. Wählen Sie ein anderes Datum.", "cd_prev_month": "Voriger Monat", "cd_next_month": "Nächster Monat", "snackbar_request_for_date": "Ausleihe angefragt für %1$s", diff --git a/i18n/en.json b/i18n/en.json index e22b8d7..33f4962 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -260,6 +260,10 @@ "book_loan_availability_loading": "Checking availability…", "book_loan_availability_error": "Couldn't load the availability calendar; pick any future date.", "book_loan_pick_prompt": "Select an available day", + "book_loan_limit_note": "Maximum loan length: %1$d days (return by %2$s). The book is already booked from %3$s.", + "book_loan_limit_note_one": "Maximum loan length: 1 day (return by %1$s). The book is already booked from %2$s.", + "book_loan_blocked_day": "Day not available (%1$s): every copy is already on loan or booked.", + "book_loan_no_window": "No loan is possible from this start date: the book is already booked from %1$s. Choose another date.", "cd_prev_month": "Previous month", "cd_next_month": "Next month", "snackbar_request_for_date": "Loan requested for %1$s", diff --git a/i18n/fr.json b/i18n/fr.json index 6ff4500..f51d7c5 100644 --- a/i18n/fr.json +++ b/i18n/fr.json @@ -260,6 +260,10 @@ "book_loan_availability_loading": "Vérification de la disponibilité…", "book_loan_availability_error": "Impossible de charger le calendrier de disponibilité; choisissez une date future.", "book_loan_pick_prompt": "Sélectionnez un jour disponible", + "book_loan_limit_note": "Durée maximale du prêt : %1$d jours (retour avant le %2$s). À partir du %3$s, le livre est déjà réservé.", + "book_loan_limit_note_one": "Durée maximale du prêt : 1 jour (retour avant le %1$s). À partir du %2$s, le livre est déjà réservé.", + "book_loan_blocked_day": "Jour non disponible (%1$s) : tous les exemplaires sont déjà empruntés ou réservés.", + "book_loan_no_window": "Aucun prêt n'est possible à partir de cette date : à partir du %1$s, le livre est déjà réservé. Choisissez une autre date.", "cd_prev_month": "Mois précédent", "cd_next_month": "Mois suivant", "snackbar_request_for_date": "Prêt demandé pour le %1$s", diff --git a/i18n/it.json b/i18n/it.json index 8cbd58a..580b07f 100644 --- a/i18n/it.json +++ b/i18n/it.json @@ -260,6 +260,10 @@ "book_loan_availability_loading": "Verifica disponibilità…", "book_loan_availability_error": "Impossibile caricare il calendario di disponibilità; scegli una data futura.", "book_loan_pick_prompt": "Seleziona un giorno disponibile", + "book_loan_limit_note": "Durata massima del prestito: %1$d giorni (restituzione entro il %2$s). Dal %3$s il libro è già prenotato.", + "book_loan_limit_note_one": "Durata massima del prestito: 1 giorno (restituzione entro il %1$s). Dal %2$s il libro è già prenotato.", + "book_loan_blocked_day": "Giorno non disponibile (%1$s): tutte le copie sono già in prestito o prenotate.", + "book_loan_no_window": "Da questa data di inizio non è possibile alcun prestito: dal %1$s il libro è già prenotato. Scegli un'altra data.", "cd_prev_month": "Mese precedente", "cd_next_month": "Mese successivo", "snackbar_request_for_date": "Prestito richiesto per il %1$s",