From feef35c3c5bc590926f13c907a68560aef40e4cf Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 11 Aug 2026 10:59:53 +0000 Subject: [PATCH 1/2] feat(loans): consume server-localized status_label from API 1.4.3 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The server's /me/loans payload now ships 'status_label' — the label from Pinakes' canonical translate_loan_status() helper — and its history includes cancelled (annullato) and pickup-expired (scaduto) loans. - LoanItem gains a nullable statusLabel (null on pre-1.4.3 servers; parsing already ignores unknown keys, so this is fully additive). - StatusMapping.loan() keeps the app's own localized resources for every KNOWN state (they follow the device language, while status_label follows the server's), but for states this app version doesn't know yet the server's wording now beats the humanized snake_case guess. - LoanRow: cancelled/expired history rows no longer show a misleading "Due " line — the book never went out — and fall through to the request date instead. - _contract/openapi.json mirrors the new field. Unit tests cover the server-label fallback, blank-label handling and known-state precedence (109 tests green). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_014KsK1ikTUGbUr5W3xrSJ6w --- _contract/openapi.json | 4 ++++ .../java/com/pinakes/app/data/model/Models.kt | 5 ++++- .../pinakes/app/ui/common/StatusMapping.kt | 14 +++++++++--- .../app/ui/screens/library/LibraryScreen.kt | 7 ++++-- .../com/pinakes/app/StatusMappingMoreTest.kt | 22 +++++++++++++++++++ 5 files changed, 46 insertions(+), 6 deletions(-) diff --git a/_contract/openapi.json b/_contract/openapi.json index b0f94e2..06e3d61 100644 --- a/_contract/openapi.json +++ b/_contract/openapi.json @@ -730,6 +730,10 @@ "type": "string", "description": "Raw prestiti.stato value." }, + "status_label": { + "type": "string", + "description": "Server-localized label for status (since 1.4.3). Prefer this over client-side status maps." + }, "loaned_at": { "type": "string", "format": "date", 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 ea8c17c..1a29ed1 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 @@ -338,7 +338,10 @@ data class LoanItem( @SerialName("book_id") val bookId: Int = 0, val title: String = "", @SerialName("cover_url") val coverUrl: String? = null, - val status: String = "", // in_corso | concluso | in_scadenza | scaduto | prenotato | in_attesa + val status: String = "", // raw prestiti.stato value (see StatusMapping.loan) + // Server-localized label for `status` (API >= 1.4.3, null on older servers). + // Used as the chip fallback for states this app version doesn't know yet. + @SerialName("status_label") val statusLabel: String? = null, @SerialName("loaned_at") val loanedAt: String? = null, @SerialName("due_at") val dueAt: String? = null, @SerialName("returned_at") val returnedAt: String? = null, diff --git a/app/src/main/java/com/pinakes/app/ui/common/StatusMapping.kt b/app/src/main/java/com/pinakes/app/ui/common/StatusMapping.kt index c6f9fb7..4837759 100644 --- a/app/src/main/java/com/pinakes/app/ui/common/StatusMapping.kt +++ b/app/src/main/java/com/pinakes/app/ui/common/StatusMapping.kt @@ -20,7 +20,12 @@ data class StatusLabel(@param:StringRes val resId: Int?, val fallback: String? = */ object StatusMapping { - fun loan(stato: String): Pair = when (stato) { + /** + * @param serverLabel the server-localized `status_label` (API >= 1.4.3), used as the + * fallback text for states this app version doesn't know yet — the server's own + * wording beats a humanized snake_case guess. Null/blank on older servers. + */ + fun loan(stato: String, serverLabel: String? = null): Pair = when (stato) { // Active, on time — available-green (this is the good state). "in_corso" -> AvailabilityStatus.Available to StatusLabel(R.string.loan_status_on_loan) // Overdue — RED, the most important alert state. @@ -42,8 +47,11 @@ object StatusMapping { "in_scadenza" -> AvailabilityStatus.DueSoon to StatusLabel(R.string.loan_status_due_soon) "concluso" -> AvailabilityStatus.Returned to StatusLabel(R.string.loan_status_returned) "in_attesa" -> AvailabilityStatus.DueSoon to StatusLabel(R.string.loan_status_pending_approval) - else -> AvailabilityStatus.LoanActive to - StatusLabel(null, stato.replace('_', ' ').replaceFirstChar { it.uppercase() }) + else -> AvailabilityStatus.LoanActive to StatusLabel( + null, + serverLabel?.takeIf { it.isNotBlank() } + ?: stato.replace('_', ' ').replaceFirstChar { it.uppercase() }, + ) } fun reservation(stato: String): Pair = when (stato) { diff --git a/app/src/main/java/com/pinakes/app/ui/screens/library/LibraryScreen.kt b/app/src/main/java/com/pinakes/app/ui/screens/library/LibraryScreen.kt index 64c0589..fda1388 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/library/LibraryScreen.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/library/LibraryScreen.kt @@ -208,15 +208,18 @@ private fun androidx.compose.foundation.lazy.LazyItemScope.LoanRow( loan: LoanItem, onBookClick: (Int) -> Unit, ) { - val (status, statusLabel) = StatusMapping.loan(loan.status) + val (status, statusLabel) = StatusMapping.loan(loan.status, loan.statusLabel) val label = statusLabel.resId?.let { stringResource(it) } ?: statusLabel.fallback val overdue = StatusMapping.loanGroup(loan.status) == StatusMapping.LoanGroup.Overdue + // Cancelled/expired loans never went out: a "Due " line would be misleading, + // so fall through to the request date instead. + val neverWentOut = loan.status in setOf("annullato", "scaduto") val dateLine = when { overdue && loan.dueAt != null -> stringResource(R.string.library_overdue_since, DateFormat.date(loan.dueAt)) overdue -> stringResource(R.string.library_overdue_label) loan.returnedAt != null -> stringResource(R.string.library_returned_on, DateFormat.date(loan.returnedAt)) - loan.dueAt != null -> stringResource(R.string.library_due_label, DateFormat.date(loan.dueAt)) + !neverWentOut && loan.dueAt != null -> stringResource(R.string.library_due_label, DateFormat.date(loan.dueAt)) loan.loanedAt != null -> stringResource(R.string.library_borrowed_on, DateFormat.date(loan.loanedAt)) else -> null } diff --git a/app/src/test/java/com/pinakes/app/StatusMappingMoreTest.kt b/app/src/test/java/com/pinakes/app/StatusMappingMoreTest.kt index 23f6140..cb6ea8c 100644 --- a/app/src/test/java/com/pinakes/app/StatusMappingMoreTest.kt +++ b/app/src/test/java/com/pinakes/app/StatusMappingMoreTest.kt @@ -23,6 +23,28 @@ class StatusMappingMoreTest { assertEquals("Qualche stato strano", label.fallback) } + @Test fun loanUnknownStatePrefersServerLabelOverHumanizedRaw() { + // API >= 1.4.3 ships a server-localized status_label: for states this app + // version doesn't know, the server's wording beats the snake_case guess. + val label = StatusMapping.loan("stato_futuro", serverLabel = "Etichetta dal server").second + assertNull(label.resId) + assertEquals("Etichetta dal server", label.fallback) + } + + @Test fun loanUnknownStateIgnoresBlankServerLabel() { + val label = StatusMapping.loan("stato_futuro", serverLabel = " ").second + assertEquals("Stato futuro", label.fallback) + } + + @Test fun loanKnownStateKeepsLocalizedResourceEvenWithServerLabel() { + // Known states keep the app's own translations (they follow the DEVICE + // language, while status_label follows the server's language). + val (status, label) = StatusMapping.loan("annullato", serverLabel = "Cancelled") + assertEquals(AvailabilityStatus.Returned, status) + assertNull(label.fallback) + assertTrue(label.resId != null) + } + @Test fun reservationMapsEnglishAndItalianSpellings() { assertEquals(AvailabilityStatus.Available, StatusMapping.reservation("attiva").first) assertEquals(AvailabilityStatus.Available, StatusMapping.reservation("active").first) From fc5fd16ca303793c76ff0371a52c0c2327dcf2d3 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 11 Aug 2026 11:24:11 +0000 Subject: [PATCH 2/2] fix(loans): address CodeRabbit review on PR #30 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Contract: status_label declared nullable the OpenAPI 3.1 way (type ["string","null"]) and its description now matches the actual precedence — a fallback for states without a local mapping, while known states keep device-localized resources. - New requested_at field (API >= 1.4.3, nullable): the date the loan request was created. Cancelled/expired history rows now show "Requested " instead of a misleading due/borrow line — loaned_at is the *requested start*, not a borrow date, for loans that never went out. On older servers (field absent) those rows simply show no date line. - library_requested_on added to all four i18n bundles. Full unit suite: 109 tests, 0 failures. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_014KsK1ikTUGbUr5W3xrSJ6w --- _contract/openapi.json | 15 +++++++++++++-- .../java/com/pinakes/app/data/model/Models.kt | 3 +++ .../app/ui/screens/library/LibraryScreen.kt | 10 +++++++--- i18n/de.json | 1 + i18n/en.json | 1 + i18n/fr.json | 1 + i18n/it.json | 1 + 7 files changed, 27 insertions(+), 5 deletions(-) diff --git a/_contract/openapi.json b/_contract/openapi.json index 06e3d61..9752c91 100644 --- a/_contract/openapi.json +++ b/_contract/openapi.json @@ -731,8 +731,19 @@ "description": "Raw prestiti.stato value." }, "status_label": { - "type": "string", - "description": "Server-localized label for status (since 1.4.3). Prefer this over client-side status maps." + "type": [ + "string", + "null" + ], + "description": "Server-localized label for status (since API 1.4.3, absent/null on older servers). Fallback for states this client has no local mapping for; known states keep device-localized resources." + }, + "requested_at": { + "type": [ + "string", + "null" + ], + "format": "date", + "description": "Date the loan request was created (since API 1.4.3). The honest date for cancelled/expired loans, which never went out." }, "loaned_at": { "type": "string", 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 1a29ed1..f5315d2 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 @@ -342,6 +342,9 @@ data class LoanItem( // Server-localized label for `status` (API >= 1.4.3, null on older servers). // Used as the chip fallback for states this app version doesn't know yet. @SerialName("status_label") val statusLabel: String? = null, + // Date the loan request was created (API >= 1.4.3, null on older servers). + // The honest date for cancelled/expired loans, which never went out. + @SerialName("requested_at") val requestedAt: String? = null, @SerialName("loaned_at") val loanedAt: String? = null, @SerialName("due_at") val dueAt: String? = null, @SerialName("returned_at") val returnedAt: String? = null, diff --git a/app/src/main/java/com/pinakes/app/ui/screens/library/LibraryScreen.kt b/app/src/main/java/com/pinakes/app/ui/screens/library/LibraryScreen.kt index fda1388..da256e8 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/library/LibraryScreen.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/library/LibraryScreen.kt @@ -211,15 +211,19 @@ private fun androidx.compose.foundation.lazy.LazyItemScope.LoanRow( val (status, statusLabel) = StatusMapping.loan(loan.status, loan.statusLabel) val label = statusLabel.resId?.let { stringResource(it) } ?: statusLabel.fallback val overdue = StatusMapping.loanGroup(loan.status) == StatusMapping.LoanGroup.Overdue - // Cancelled/expired loans never went out: a "Due " line would be misleading, - // so fall through to the request date instead. + // Cancelled/expired loans never went out: "Due " or "Borrowed " + // lines would be misleading, so show the request date (API >= 1.4.3) — + // loanedAt is the *requested start*, not a borrow date, for these states. val neverWentOut = loan.status in setOf("annullato", "scaduto") val dateLine = when { + neverWentOut -> loan.requestedAt?.let { + stringResource(R.string.library_requested_on, DateFormat.date(it)) + } overdue && loan.dueAt != null -> stringResource(R.string.library_overdue_since, DateFormat.date(loan.dueAt)) overdue -> stringResource(R.string.library_overdue_label) loan.returnedAt != null -> stringResource(R.string.library_returned_on, DateFormat.date(loan.returnedAt)) - !neverWentOut && loan.dueAt != null -> stringResource(R.string.library_due_label, DateFormat.date(loan.dueAt)) + loan.dueAt != null -> stringResource(R.string.library_due_label, DateFormat.date(loan.dueAt)) loan.loanedAt != null -> stringResource(R.string.library_borrowed_on, DateFormat.date(loan.loanedAt)) else -> null } diff --git a/i18n/de.json b/i18n/de.json index d6986c8..10b7c67 100644 --- a/i18n/de.json +++ b/i18n/de.json @@ -146,6 +146,7 @@ "library_returned_on": "Zurückgegeben am %1$s", "library_due_on": "Fällig %1$s", "library_borrowed_on": "Ausgeliehen am %1$s", + "library_requested_on": "Angefragt am %1$s", "library_queue_position": "Warteschlange #%1$d", "library_cancel": "Abbrechen", "library_confirm_cancel_title": "Reservierung stornieren?", diff --git a/i18n/en.json b/i18n/en.json index f379a3d..5eef74e 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -146,6 +146,7 @@ "library_returned_on": "Returned %1$s", "library_due_on": "Due %1$s", "library_borrowed_on": "Borrowed %1$s", + "library_requested_on": "Requested %1$s", "library_queue_position": "Queue #%1$d", "library_cancel": "Cancel", "library_confirm_cancel_title": "Cancel reservation?", diff --git a/i18n/fr.json b/i18n/fr.json index 851efb3..7e9d952 100644 --- a/i18n/fr.json +++ b/i18n/fr.json @@ -146,6 +146,7 @@ "library_returned_on": "Rendu le %1$s", "library_due_on": "Échéance %1$s", "library_borrowed_on": "Emprunté le %1$s", + "library_requested_on": "Demandé le %1$s", "library_queue_position": "File d'attente #%1$d", "library_cancel": "Annuler", "library_confirm_cancel_title": "Annuler la réservation ?", diff --git a/i18n/it.json b/i18n/it.json index d5bf393..1e1fdc4 100644 --- a/i18n/it.json +++ b/i18n/it.json @@ -146,6 +146,7 @@ "library_returned_on": "Restituito il %1$s", "library_due_on": "Scadenza %1$s", "library_borrowed_on": "Preso in prestito il %1$s", + "library_requested_on": "Richiesto il %1$s", "library_queue_position": "In coda #%1$d", "library_cancel": "Annulla", "library_confirm_cancel_title": "Annullare la prenotazione?",