From e58e1825162b92122b4a5302d7b2fb60e3db5a4b Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 21 Oct 2026 12:14:00 +0000 Subject: [PATCH 1/4] Stop the loan request before a scheduled loan and say how long it can be The loan-request sheet proposed the chosen start plus the library's loan_duration_days whatever the calendar showed. When a scheduled loan or a reservation on every copy began inside that window the server refused the whole request with a bare "not available", although a shorter loan was possible, and the reader was never told how long. LoanWindow.kt reads the availability calendar the sheet already holds: the first fully-booked day inside the duration bounds the loan, the return date becomes the day before it, and the sheet explains it under the calendar: maximum loan length, return date, and the day from which the book is already booked. The request then carries that date as end_date of POST /reservations (Mobile API 1.8.2), so the server accepts the shortened loan the way the website calendar does. Tapping a red day, which cannot be picked, now says why. A not_available refusal from the server carries its own explanation and meta figures (max_end_date, max_duration_days, first_conflict_date); the message is what the reader sees and the figures are modelled for later use. Strings in it, en, de, fr. LoanWindowTest covers the capped, uncapped, conflict-beyond-duration, next-day-blocked, partial-day, unavailable- dates and unknown-duration cases; the sheet tests take the two-argument confirm callback. The API contract documents end_date. --- _contract/openapi.json | 2 +- .../com/pinakes/app/CalendarLayoutTest.kt | 4 +- .../java/com/pinakes/app/data/model/Models.kt | 10 ++- .../app/data/repository/LibraryRepository.kt | 6 +- .../app/ui/screens/detail/BookDetailScreen.kt | 47 +++++++++--- .../ui/screens/detail/BookDetailViewModel.kt | 13 ++-- .../app/ui/screens/detail/LoanCalendar.kt | 13 +++- .../app/ui/screens/detail/LoanWindow.kt | 54 ++++++++++++++ .../pinakes/app/LoanCalendarRegressionTest.kt | 10 +-- .../pinakes/app/LoanRequestReturnDateTest.kt | 2 +- .../java/com/pinakes/app/LoanWindowTest.kt | 71 +++++++++++++++++++ i18n/de.json | 2 + i18n/en.json | 2 + i18n/fr.json | 2 + i18n/it.json | 2 + 15 files changed, 214 insertions(+), 26 deletions(-) create mode 100644 app/src/main/java/com/pinakes/app/ui/screens/detail/LoanWindow.kt create mode 100644 app/src/test/java/com/pinakes/app/LoanWindowTest.kt 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/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/main/java/com/pinakes/app/data/model/Models.kt b/app/src/main/java/com/pinakes/app/data/model/Models.kt index 1a6ef06..24d9ce3 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 @@ -535,7 +541,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..0684ba3 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,39 @@ 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 -> + 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,7 +393,7 @@ internal fun LoanRequestSheetContent( ) { PrimaryButton( label = stringResource(R.string.book_loan_confirm), - onClick = { selectedIso?.let(onConfirm) }, + onClick = { selectedIso?.let { start -> onConfirm(start, window?.takeIf { it.capped }?.endDate) } }, enabled = selectedIso != null, 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..a3b3259 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 @@ -136,6 +136,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 +169,7 @@ fun LoanCalendar( dark = dark, onSelect = onSelect, anyFutureDay = anyFutureDay, + onBlockedTap = onBlockedTap, ) if (!anyFutureDay) { Spacer(Modifier.height(Spacing.md)) @@ -234,6 +237,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 +266,7 @@ private fun MonthGrid( dark = dark, onSelect = onSelect, anyFutureDay = anyFutureDay, + onBlockedTap = onBlockedTap, ) } } @@ -280,6 +285,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 +316,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..185c9bd --- /dev/null +++ b/app/src/main/java/com/pinakes/app/ui/screens/detail/LoanWindow.kt @@ -0,0 +1,54 @@ +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, +) + +/** + * 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. + */ +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 + val states = calendar?.days?.associate { it.date to it.state }.orEmpty() + val unavailable = calendar?.unavailableDates?.toSet().orEmpty() + var firstBlocked: String? = null + for (offset in 1..loanDurationDays) { + val day = start.plusDays(offset.toLong()).toString() + if (unavailable.contains(day) || states[day] == "full") { + firstBlocked = day + break + } + } + val defaultEnd = start.plusDays(loanDurationDays.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, + ) +} 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/LoanWindowTest.kt b/app/src/test/java/com/pinakes/app/LoanWindowTest.kt new file mode 100644 index 0000000..66fe561 --- /dev/null +++ b/app/src/test/java/com/pinakes/app/LoanWindowTest.kt @@ -0,0 +1,71 @@ +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.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 { + private fun calendar(vararg full: String, partial: List = emptyList()) = AvailabilityCalendar( + totalCopies = 1, + 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) + } + + @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, 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)) + } +} diff --git a/i18n/de.json b/i18n/de.json index d6fbfdc..6452c3b 100644 --- a/i18n/de.json +++ b/i18n/de.json @@ -260,6 +260,8 @@ "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_blocked_day": "Tag nicht verfügbar (%1$s): alle Exemplare sind bereits ausgeliehen oder vorgemerkt.", "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..2991cfb 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -260,6 +260,8 @@ "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_blocked_day": "Day not available (%1$s): every copy is already on loan or booked.", "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..abeee8b 100644 --- a/i18n/fr.json +++ b/i18n/fr.json @@ -260,6 +260,8 @@ "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_blocked_day": "Jour non disponible (%1$s) : tous les exemplaires sont déjà empruntés ou réservés.", "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..8fd403e 100644 --- a/i18n/it.json +++ b/i18n/it.json @@ -260,6 +260,8 @@ "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_blocked_day": "Giorno non disponibile (%1$s): tutte le copie sono già in prestito o prenotate.", "cd_prev_month": "Mese precedente", "cd_next_month": "Mese successivo", "snackbar_request_for_date": "Prestito richiesto per il %1$s", From 4cbd54ef632eff09961d9b35cfff323033d4ab90 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 21 Oct 2026 12:24:25 +0000 Subject: [PATCH 2/4] Explain a start with no possible loan, read the maximum, test the sheet and the envelope A commitment on the very next day leaves no loan of at least one day: LoanWindow.possible is false, the sheet says so ("no loan is possible from this start date: the book is already booked from D") and disables the request instead of proposing a same-day return. The availability payload's max_loan_duration_days (Mobile API 1.8.2) is modelled. LoanRequestWindowTest renders the sheet: a scheduled loan inside the duration shortens the return date and the note says so; confirming sends the shortened date as end_date; with no conflict nothing is explained and no end_date is sent; tapping a fully-booked day explains why it cannot be picked and leaves the pick unchanged; a commitment on the next day disables the request. ReservationRefusalEnvelopeTest pins what the server says to the app: the not_available sentence and meta figures, a refusal without figures, the max_duration_exceeded code, end_date sent only when shortened, both durations in the availability payload. --- .../java/com/pinakes/app/data/model/Models.kt | 2 + .../app/ui/screens/detail/BookDetailScreen.kt | 19 +-- .../app/ui/screens/detail/LoanWindow.kt | 5 +- .../com/pinakes/app/LoanRequestWindowTest.kt | 119 ++++++++++++++++++ .../java/com/pinakes/app/LoanWindowTest.kt | 3 + .../app/ReservationRefusalEnvelopeTest.kt | 62 +++++++++ i18n/de.json | 1 + i18n/en.json | 1 + i18n/fr.json | 1 + i18n/it.json | 1 + 10 files changed, 206 insertions(+), 8 deletions(-) create mode 100644 app/src/test/java/com/pinakes/app/LoanRequestWindowTest.kt create mode 100644 app/src/test/java/com/pinakes/app/ReservationRefusalEnvelopeTest.kt 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 24d9ce3..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 @@ -522,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 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 0684ba3..433b2a4 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 @@ -369,12 +369,17 @@ internal fun LoanRequestSheetContent( // 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 -> - stringResource( - R.string.book_loan_limit_note, - w.maxDays, - formatDisplayDate(w.endDate), - formatDisplayDate(w.firstBlocked ?: w.endDate), - ) + 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 { + 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(" ") @@ -394,7 +399,7 @@ internal fun LoanRequestSheetContent( PrimaryButton( label = stringResource(R.string.book_loan_confirm), onClick = { selectedIso?.let { start -> onConfirm(start, window?.takeIf { it.capped }?.endDate) } }, - enabled = selectedIso != null, + 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/LoanWindow.kt b/app/src/main/java/com/pinakes/app/ui/screens/detail/LoanWindow.kt index 185c9bd..273afac 100644 --- 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 @@ -23,7 +23,10 @@ data class LoanWindow( val maxDays: Int, /** True when [firstBlocked] shortened the default duration. */ val capped: Boolean, -) +) { + /** 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 +} /** * The window from [startIso] (yyyy-MM-dd), or null when the duration is unknown (an older 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..4ba23d1 --- /dev/null +++ b/app/src/test/java/com/pinakes/app/LoanRequestWindowTest.kt @@ -0,0 +1,119 @@ +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, + 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() + } +} diff --git a/app/src/test/java/com/pinakes/app/LoanWindowTest.kt b/app/src/test/java/com/pinakes/app/LoanWindowTest.kt index 66fe561..23ba9c5 100644 --- a/app/src/test/java/com/pinakes/app/LoanWindowTest.kt +++ b/app/src/test/java/com/pinakes/app/LoanWindowTest.kt @@ -49,6 +49,9 @@ class LoanWindowTest { 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 aPartialDayStillHasAFreeCopy() { 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 6452c3b..de7ef39 100644 --- a/i18n/de.json +++ b/i18n/de.json @@ -262,6 +262,7 @@ "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_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 2991cfb..67d9dff 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -262,6 +262,7 @@ "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_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 abeee8b..ac28a68 100644 --- a/i18n/fr.json +++ b/i18n/fr.json @@ -262,6 +262,7 @@ "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_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 8fd403e..2859485 100644 --- a/i18n/it.json +++ b/i18n/it.json @@ -262,6 +262,7 @@ "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_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", From 4dba5901933db803e2b0fc2fecf299ea22ac70b4 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 21 Oct 2026 12:29:43 +0000 Subject: [PATCH 3/4] One fully-booked predicate for the calendar and the window; a singular note CalendarModel painted a day labelled free with no free copy red and unpickable, but loanWindowFrom only looked at unavailable_dates and the full state, so such a day inside the duration did not cap the request and the server refused it. AvailabilityCalendar.isFullyBooked() is now the one predicate both use, with a test for the stale label. A cap one day out read "1 days": book_loan_limit_note_one is the singular variant (the string generator has no plurals). The zero-day case already reads as "no loan is possible from this start date". --- .../app/ui/screens/detail/BookDetailScreen.kt | 3 +++ .../pinakes/app/ui/screens/detail/LoanCalendar.kt | 14 ++++++-------- .../pinakes/app/ui/screens/detail/LoanWindow.kt | 15 ++++++++++++--- .../test/java/com/pinakes/app/LoanWindowTest.kt | 11 +++++++++++ i18n/de.json | 1 + i18n/en.json | 1 + i18n/fr.json | 1 + i18n/it.json | 1 + 8 files changed, 36 insertions(+), 11 deletions(-) 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 433b2a4..c93470c 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 @@ -372,6 +372,9 @@ internal fun LoanRequestSheetContent( 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, 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 a3b3259..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 } } 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 index 273afac..07303c0 100644 --- 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 @@ -28,6 +28,17 @@ data class LoanWindow( 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 @@ -36,12 +47,10 @@ data class LoanWindow( 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 - val states = calendar?.days?.associate { it.date to it.state }.orEmpty() - val unavailable = calendar?.unavailableDates?.toSet().orEmpty() var firstBlocked: String? = null for (offset in 1..loanDurationDays) { val day = start.plusDays(offset.toLong()).toString() - if (unavailable.contains(day) || states[day] == "full") { + if (calendar?.isFullyBooked(day) == true) { firstBlocked = day break } diff --git a/app/src/test/java/com/pinakes/app/LoanWindowTest.kt b/app/src/test/java/com/pinakes/app/LoanWindowTest.kt index 23ba9c5..2ce93c3 100644 --- a/app/src/test/java/com/pinakes/app/LoanWindowTest.kt +++ b/app/src/test/java/com/pinakes/app/LoanWindowTest.kt @@ -2,6 +2,7 @@ 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 @@ -54,6 +55,16 @@ class LoanWindowTest { 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, 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) diff --git a/i18n/de.json b/i18n/de.json index de7ef39..d8fd814 100644 --- a/i18n/de.json +++ b/i18n/de.json @@ -261,6 +261,7 @@ "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", diff --git a/i18n/en.json b/i18n/en.json index 67d9dff..33f4962 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -261,6 +261,7 @@ "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", diff --git a/i18n/fr.json b/i18n/fr.json index ac28a68..f51d7c5 100644 --- a/i18n/fr.json +++ b/i18n/fr.json @@ -261,6 +261,7 @@ "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", diff --git a/i18n/it.json b/i18n/it.json index 2859485..580b07f 100644 --- a/i18n/it.json +++ b/i18n/it.json @@ -261,6 +261,7 @@ "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", From 0c54704f537b151b69c9cf531a1a37a89c986dfc Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 21 Oct 2026 15:06:15 +0200 Subject: [PATCH 4/4] Bound loan requests by the configured maximum duration --- .../app/ui/screens/detail/BookDetailScreen.kt | 2 +- .../app/ui/screens/detail/LoanWindow.kt | 9 +++++-- .../com/pinakes/app/LoanRequestWindowTest.kt | 12 ++++++++++ .../java/com/pinakes/app/LoanWindowTest.kt | 24 +++++++++++++++++++ 4 files changed, 44 insertions(+), 3 deletions(-) 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 c93470c..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 @@ -401,7 +401,7 @@ internal fun LoanRequestSheetContent( ) { PrimaryButton( label = stringResource(R.string.book_loan_confirm), - onClick = { selectedIso?.let { start -> onConfirm(start, window?.takeIf { it.capped }?.endDate) } }, + 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/LoanWindow.kt b/app/src/main/java/com/pinakes/app/ui/screens/detail/LoanWindow.kt index 07303c0..ed4331d 100644 --- 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 @@ -23,6 +23,8 @@ data class LoanWindow( 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 @@ -47,20 +49,23 @@ internal fun AvailabilityCalendar.isFullyBooked(isoDate: String): Boolean { 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 + val durationDays = calendar?.maxLoanDurationDays?.takeIf { it > 0 } + ?.let { minOf(loanDurationDays, it) } ?: loanDurationDays var firstBlocked: String? = null - for (offset in 1..loanDurationDays) { + for (offset in 1..durationDays) { val day = start.plusDays(offset.toLong()).toString() if (calendar?.isFullyBooked(day) == true) { firstBlocked = day break } } - val defaultEnd = start.plusDays(loanDurationDays.toLong()) + 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/LoanRequestWindowTest.kt b/app/src/test/java/com/pinakes/app/LoanRequestWindowTest.kt index 4ba23d1..a3cf66d 100644 --- a/app/src/test/java/com/pinakes/app/LoanRequestWindowTest.kt +++ b/app/src/test/java/com/pinakes/app/LoanRequestWindowTest.kt @@ -116,4 +116,16 @@ class LoanRequestWindowTest { .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 index 2ce93c3..5e9337e 100644 --- a/app/src/test/java/com/pinakes/app/LoanWindowTest.kt +++ b/app/src/test/java/com/pinakes/app/LoanWindowTest.kt @@ -82,4 +82,28 @@ class LoanWindowTest { 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) + } }