From e58e1825162b92122b4a5302d7b2fb60e3db5a4b Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 21 Oct 2026 12:14:00 +0000 Subject: [PATCH 1/6] 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/6] 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/6] 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/6] 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) + } } From 495d0f7b1ea72e4fc8bcafdf575ac5c4a3e16c00 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 21 Oct 2026 21:45:17 +0200 Subject: [PATCH 5/6] Release 1.10.0 (versionCode 24) Bumps the version and records in STATUS what 1.10.0 brings: loan requests bounded by a scheduled loan and by the configured maximum (#53, Pinakes #490). LoanRequestSheetUiTest follows the sheet's new onConfirm(start, end) and checks that no end date is sent when nothing caps the window. --- STATUS.md | 8 ++++++++ app/build.gradle.kts | 4 ++-- .../java/com/pinakes/app/LoanRequestSheetUiTest.kt | 9 ++++++--- 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/STATUS.md b/STATUS.md index 7acba30..0bbd7d0 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. +- No change to the local cache database (Room version 3). +- Verification on this release branch: 444 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/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/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) } } } From aa5feb723ec15c3f7e1e5ae2113cac14f2478020 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 21 Oct 2026 22:18:44 +0200 Subject: [PATCH 6/6] Cap the loan window only on a server that reads end_date Pinakes 0.8.6 (Mobile API 1.8.1) already sends loan_duration_days but ignores end_date, so the sheet showed a shortened return date the server would refuse or end on another day. loanWindowFrom() caps only when the calendar carries max_loan_duration_days, which Pinakes 0.8.7 always sends; without it the window is the default one, uncapped and without end_date, as in 1.9.0. LoanWindowTest covers both cases (they fail without the check), and the fixtures of the capped cases now carry the maximum as a 0.8.7 server sends it. --- STATUS.md | 4 +-- .../app/ui/screens/detail/LoanWindow.kt | 15 +++++++++++ .../com/pinakes/app/LoanRequestWindowTest.kt | 1 + .../java/com/pinakes/app/LoanWindowTest.kt | 25 +++++++++++++++++-- 4 files changed, 41 insertions(+), 4 deletions(-) diff --git a/STATUS.md b/STATUS.md index 0bbd7d0..529dd78 100644 --- a/STATUS.md +++ b/STATUS.md @@ -4,9 +4,9 @@ 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. +- **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: 444 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. +- 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 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 ed4331d..06dc6ed 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 @@ -45,10 +45,25 @@ internal fun AvailabilityCalendar.isFullyBooked(isoDate: String): 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. + * + * 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 diff --git a/app/src/test/java/com/pinakes/app/LoanRequestWindowTest.kt b/app/src/test/java/com/pinakes/app/LoanRequestWindowTest.kt index a3cf66d..8b2e208 100644 --- a/app/src/test/java/com/pinakes/app/LoanRequestWindowTest.kt +++ b/app/src/test/java/com/pinakes/app/LoanRequestWindowTest.kt @@ -56,6 +56,7 @@ class LoanRequestWindowTest { 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") diff --git a/app/src/test/java/com/pinakes/app/LoanWindowTest.kt b/app/src/test/java/com/pinakes/app/LoanWindowTest.kt index 5e9337e..83e2f01 100644 --- a/app/src/test/java/com/pinakes/app/LoanWindowTest.kt +++ b/app/src/test/java/com/pinakes/app/LoanWindowTest.kt @@ -16,8 +16,10 @@ import org.junit.Test * 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") }), ) @@ -57,7 +59,7 @@ class LoanWindowTest { @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 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) @@ -71,7 +73,7 @@ class LoanWindowTest { } @Test fun unavailableDatesCountAsBlockedEvenWithoutADayRow() { - val window = loanWindowFrom("2030-03-04", AvailabilityCalendar(totalCopies = 1, unavailableDates = listOf("2030-03-07")), 14) + 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) } @@ -106,4 +108,23 @@ class LoanWindowTest { 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) + } }