From 897c948acd4faf28679cf017de4f3b90f62fea40 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Sun, 8 Nov 2026 23:54:57 +0100 Subject: [PATCH 1/4] Show readers the last pickup day of a ready loan A da_ritirare loan in the reader's loans list now reads "Pick up by " instead of its return date, from the pickup_deadline that Mobile API 1.12.0 adds to /me/loans: the server has already moved it past the copy branch closures, as the staff desk shows it. On older servers the field is absent and the row keeps its return date. The README gains a What's New section with the last three releases and drops the outdated Android 1.6 parity note. --- README.md | 37 +++++++++---------- .../java/com/pinakes/app/data/model/Models.kt | 3 ++ .../pinakes/app/ui/common/StatusMapping.kt | 6 ++- .../app/ui/screens/library/LibraryScreen.kt | 2 + .../pinakes/app/CalendarLoanAlignmentTest.kt | 20 ++++++++++ i18n/de.json | 1 + i18n/en.json | 1 + i18n/fr.json | 1 + i18n/it.json | 1 + 9 files changed, 51 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 1e3d41c1..c3e800c1 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,22 @@ server advertises their APIs. The five bottom navigation destinations are retain > These are **debug-signed** builds for direct sideloading, not Play Store releases. Each release bumps `versionCode`, so a newer APK installs over the previous one in place. +## What's New in v1.13.0 + +Pinakes Android 1.13.0 follows Pinakes 0.9.2 (Mobile API 1.11.0). In the staff area the visitor log checks in a member without the card: *Find member* opens the same member search as the loan desk, and the visit is recorded exactly as with the card, only once even after a retry. The button appears only when the server announces `staff_attendance_member_pick` in `/health`; on older servers the app behaves as 1.12.0. + +## What's New + +The complete history is on the [Releases page](https://github.com/fabiodalez-dev/Pinakes-Android/releases). + +### v1.12.0 + +Follows Pinakes 0.9.0 and 0.9.1 (Mobile API 1.10.0). Reader and staff loans show the usable return day the server computes from the branch closures calendar, and lateness follows the server in the library timezone, so a loan due on a closed day is no longer shown as late. Library settings → Loans gains the closures calendar section with the same-day handover switch. + +### v1.11.0 + +Follows Pinakes 0.9.0: everything the 0.9.0 admin adds can be done from the staff area, including library settings, patron types and loan rules, the closures calendar, stocktake, patron ledger, MARC import and export, and the visitor log. The reader side follows the new circulation rules; on older servers every new field is optional and the new screens stay hidden. + ## What it is A single self-contained app any Pinakes library can hand to its members. On first @@ -271,7 +287,7 @@ _contract/ OpenAPI snapshot + API spec the app is built against All core screens are implemented and verified end-to-end on an emulator against a live instance (onboarding, login, catalog, detail, loans, reservations, audio, PDF, themes, languages). UnifiedPush registration is wired in the data layer; the -distributor receiver is a follow-up. See `STATUS.md` for the full breakdown. +distributor receiver is a follow-up. See `STATUS.md` for the full breakdown, and [the request-by-request Uwe verification](docs/UWE-PARITY.md) for the reader-side parity with the website. ## License @@ -286,22 +302,3 @@ and published online resources follow the server data. Public PDFs use the URL supplied by the server; the website action opens the article’s full page. Older servers retain the existing periodicals browser. See [the article integration notes](docs/emeroteca-standalone-articles.md). -## Android 1.6 / current server parity - -The updated client reads Mobile API 1.5.0, Desiderata 1.2.0, Emeroteca 1.13.0 and -Archives 1.5.1. Update the server/plugins before expecting the new optional -collection screens. Older instances remain usable; unsupported article facets -show an upgrade message rather than silently returning unfiltered results. - -Desiderata is the library's wanted collection, separate from a member's wishlist. -Donation contact details come from the verified account. Proposals require -consent and survive transport retries/process death without creating inventory. -Archives supports paged hierarchy, text/level/year search, authorities, all -public documents and export links. Article/book citations are formatted by the -server, with five styles and text/HTML clipboard copies. Every digital book -attachment is shown; audio switches one native player at a time. - -Administrative cataloguing and uploads open the protected PHP website, including -the existing article form. No native administrative CRUD API is invented. - -See [the request-by-request Uwe verification](docs/UWE-PARITY.md). 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 8ad599db..1755a37d 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 @@ -534,6 +534,9 @@ data class LoanItem( @SerialName("is_overdue") val isOverdue: Boolean? = null, // Computed in the library timezone; do not re-derive from device LocalDate. @SerialName("due_attention") val dueAttention: Boolean = false, + // Mobile API 1.12.0: last day to collect a da_ritirare loan, already moved past the + // copy branch closures by the server. Null for other states and on older servers. + @SerialName("pickup_deadline") val pickupDeadline: String? = null, @SerialName("returned_at") val returnedAt: String? = null, val renewals: Int? = null, // Mobile API 1.4.4 presentation hint; DELETE /loans/{id} revalidates it. 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 8e79dc26..510beb21 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 @@ -103,7 +103,7 @@ object StatusMapping { fun loanNeedsAttention(loan: LoanItem): Boolean = loan.dueAttention || loanGroup(loan) == LoanGroup.Overdue - enum class LoanDateKind { Overdue, Returned, Requested, Due, Borrowed } + enum class LoanDateKind { Overdue, Returned, Requested, Pickup, Due, Borrowed } data class LoanDate(val kind: LoanDateKind, val value: String? = null) @@ -119,6 +119,10 @@ object StatusMapping { return loan.requestedAt?.let { LoanDate(LoanDateKind.Requested, it) } } if (loan.returnedAt != null) return LoanDate(LoanDateKind.Returned, loan.returnedAt) + // A ready loan is not in hand yet: the date that matters is the last pickup day. + if (loan.status == "da_ritirare" && loan.pickupDeadline != null) { + return LoanDate(LoanDateKind.Pickup, loan.pickupDeadline) + } if (loan.displayDueAt != null) return LoanDate(LoanDateKind.Due, loan.displayDueAt) if (loan.loanedAt != null) return LoanDate(LoanDateKind.Borrowed, loan.loanedAt) return 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 1ae40f68..c14ad409 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 @@ -263,6 +263,8 @@ private fun androidx.compose.foundation.lazy.LazyItemScope.LoanRow( stringResource(R.string.library_returned_on, DateFormat.date(requireNotNull(date.value))) StatusMapping.LoanDateKind.Requested -> stringResource(R.string.library_requested_on, DateFormat.date(requireNotNull(date.value))) + StatusMapping.LoanDateKind.Pickup -> + stringResource(R.string.library_pickup_by, DateFormat.date(requireNotNull(date.value))) StatusMapping.LoanDateKind.Due -> stringResource(R.string.library_due_label, DateFormat.date(requireNotNull(date.value))) StatusMapping.LoanDateKind.Borrowed -> diff --git a/app/src/test/java/com/pinakes/app/CalendarLoanAlignmentTest.kt b/app/src/test/java/com/pinakes/app/CalendarLoanAlignmentTest.kt index c1d14183..cafdb322 100644 --- a/app/src/test/java/com/pinakes/app/CalendarLoanAlignmentTest.kt +++ b/app/src/test/java/com/pinakes/app/CalendarLoanAlignmentTest.kt @@ -45,6 +45,26 @@ class CalendarLoanAlignmentTest { assertEquals(R.string.loan_status_on_loan, StatusMapping.loan(loan).second.resId) } + @Test fun readyLoanShowsTheLastPickupDayTheServerMovedPastAClosure() { + val loan = json.decodeFromString("""{ + "id":8,"status":"da_ritirare","due_at":"2030-12-30","effective_due_at":"2030-12-30", + "pickup_deadline":"2030-12-27","is_overdue":false + }""") + assertEquals("2030-12-27", loan.pickupDeadline) + assertEquals(StatusMapping.LoanDate(StatusMapping.LoanDateKind.Pickup, "2030-12-27"), StatusMapping.loanDate(loan)) + } + + @Test fun readyLoanFromAnOlderServerFallsBackToTheReturnDay() { + val loan = json.decodeFromString("""{"status":"da_ritirare","due_at":"2030-12-30"}""") + assertNull(loan.pickupDeadline) + assertEquals(StatusMapping.LoanDate(StatusMapping.LoanDateKind.Due, "2030-12-30"), StatusMapping.loanDate(loan)) + } + + @Test fun pickupDayIsIgnoredOnceTheLoanIsCollected() { + val loan = LoanItem(status = "in_corso", dueAt = "2030-12-30", pickupDeadline = "2030-12-27", isOverdue = false) + assertEquals(StatusMapping.LoanDate(StatusMapping.LoanDateKind.Due, "2030-12-30"), StatusMapping.loanDate(loan)) + } + @Test fun olderReaderPayloadKeepsItsPreviousStatusAndDate() { val loan = json.decodeFromString("""{"status":"in_ritardo","due_at":"2030-12-25"}""") assertNull(loan.isOverdue) diff --git a/i18n/de.json b/i18n/de.json index 1cb396d3..ada02972 100644 --- a/i18n/de.json +++ b/i18n/de.json @@ -305,6 +305,7 @@ "library_section_upcoming": "Abholbereit & geplant", "library_section_pending": "Ausstehende Anfragen", "library_due_label": "Fällig am %1$s", + "library_pickup_by": "Abholen bis %1$s", "library_overdue_label": "Überfällig", "library_overdue_since": "Überfällig, war fällig am %1$s", "book_personal_on_loan_title": "Du hast es ausgeliehen", diff --git a/i18n/en.json b/i18n/en.json index 734cf9e1..11bec0b4 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -305,6 +305,7 @@ "library_section_upcoming": "Ready & scheduled", "library_section_pending": "Pending requests", "library_due_label": "Due %1$s", + "library_pickup_by": "Pick up by %1$s", "library_overdue_label": "Overdue", "library_overdue_since": "Overdue, was due %1$s", "book_personal_on_loan_title": "You have this on loan", diff --git a/i18n/fr.json b/i18n/fr.json index 2c8fa746..3e3b13e2 100644 --- a/i18n/fr.json +++ b/i18n/fr.json @@ -305,6 +305,7 @@ "library_section_upcoming": "À retirer et programmés", "library_section_pending": "Demandes en attente", "library_due_label": "À rendre le %1$s", + "library_pickup_by": "À retirer avant le %1$s", "library_overdue_label": "En retard", "library_overdue_since": "En retard, devait être rendu le %1$s", "book_personal_on_loan_title": "Vous l'avez emprunté", diff --git a/i18n/it.json b/i18n/it.json index 02312132..b8a60d6a 100644 --- a/i18n/it.json +++ b/i18n/it.json @@ -305,6 +305,7 @@ "library_section_upcoming": "Da ritirare e programmati", "library_section_pending": "Richieste in attesa", "library_due_label": "Scade il %1$s", + "library_pickup_by": "Da ritirare entro il %1$s", "library_overdue_label": "In ritardo", "library_overdue_since": "In ritardo, scaduto il %1$s", "book_personal_on_loan_title": "Ce l'hai in prestito", From 6fbb92acdbfe3cb820910aa6297bb98c236a8896 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Mon, 9 Nov 2026 10:55:00 +0100 Subject: [PATCH 2/4] Keep pickup deadlines in the Android offline cache --- .../com/pinakes/app/data/local/AppDatabase.kt | 22 +++++++++++++------ .../pinakes/app/data/local/LibraryCache.kt | 6 +++-- .../java/com/pinakes/app/LibraryDaoTest.kt | 18 ++++++++++++++- i18n/fr.json | 4 ++-- 4 files changed, 38 insertions(+), 12 deletions(-) diff --git a/app/src/main/java/com/pinakes/app/data/local/AppDatabase.kt b/app/src/main/java/com/pinakes/app/data/local/AppDatabase.kt index d488aa32..811f37ca 100644 --- a/app/src/main/java/com/pinakes/app/data/local/AppDatabase.kt +++ b/app/src/main/java/com/pinakes/app/data/local/AppDatabase.kt @@ -4,17 +4,17 @@ import android.content.Context import androidx.room.Database import androidx.room.Room import androidx.room.RoomDatabase +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase /** - * Local cache database. Everything in it can be rebuilt from the server, so schema changes use - * destructive migration: v1 (40-row snapshot) → v2 (whole catalogue + loans + wishlist + sync - * bookkeeping) → v3 (folded search key) simply starts empty and the next sync refills it - * (the sync watermark lives in the same database, so it restarts with a full pass). Excluded from backups - * (`backup_rules.xml`, `data_extraction_rules.xml`): it belongs to one instance and one user. + * Local cache database. V4 adds the pickup deadline while keeping data cached by v3. + * Older schemas can be rebuilt from the server; clearing the sync watermark triggers a full sync. + * The database is excluded from backups because it belongs to one instance and one user. */ @Database( entities = [CachedBook::class, CachedLoan::class, CachedWishlistItem::class, SyncMeta::class], - version = 3, + version = 4, exportSchema = false, ) abstract class AppDatabase : RoomDatabase() { @@ -25,6 +25,12 @@ abstract class AppDatabase : RoomDatabase() { companion object { const val FILE_NAME = "pinakes-cache.db" + private val migration3To4 = object : Migration(3, 4) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE cached_loans ADD COLUMN pickupDeadline TEXT") + } + } + @Volatile private var INSTANCE: AppDatabase? = null @@ -34,7 +40,9 @@ abstract class AppDatabase : RoomDatabase() { context.applicationContext, AppDatabase::class.java, FILE_NAME, - ).fallbackToDestructiveMigration().build().also { INSTANCE = it } + ).addMigrations(migration3To4) + .fallbackToDestructiveMigration() + .build().also { INSTANCE = it } } } } diff --git a/app/src/main/java/com/pinakes/app/data/local/LibraryCache.kt b/app/src/main/java/com/pinakes/app/data/local/LibraryCache.kt index 245947f5..f8e9adcb 100644 --- a/app/src/main/java/com/pinakes/app/data/local/LibraryCache.kt +++ b/app/src/main/java/com/pinakes/app/data/local/LibraryCache.kt @@ -26,6 +26,7 @@ data class CachedLoan( val loanedAt: String? = null, val dueAt: String? = null, val dueAttention: Boolean = false, + val pickupDeadline: String? = null, val returnedAt: String? = null, val renewals: Int? = null, val cancellable: Boolean = false, @@ -75,13 +76,14 @@ data class SyncMeta( fun LoanItem.toCached(bucket: String, position: Int): CachedLoan = CachedLoan( bucket = bucket, id = id, position = position, bookId = bookId, title = title, coverUrl = coverUrl, status = status, statusLabel = statusLabel, requestedAt = requestedAt, loanedAt = loanedAt, dueAt = dueAt, - dueAttention = dueAttention, returnedAt = returnedAt, renewals = renewals, cancellable = cancellable, + dueAttention = dueAttention, pickupDeadline = pickupDeadline, returnedAt = returnedAt, + renewals = renewals, cancellable = cancellable, ) fun CachedLoan.toLoanItem(): LoanItem = LoanItem( id = id, bookId = bookId, title = title, coverUrl = coverUrl, status = status, statusLabel = statusLabel, requestedAt = requestedAt, loanedAt = loanedAt, dueAt = dueAt, dueAttention = dueAttention, - returnedAt = returnedAt, renewals = renewals, cancellable = cancellable, + pickupDeadline = pickupDeadline, returnedAt = returnedAt, renewals = renewals, cancellable = cancellable, ) fun ReservationItem.toCached(position: Int): CachedLoan = CachedLoan( diff --git a/app/src/test/java/com/pinakes/app/LibraryDaoTest.kt b/app/src/test/java/com/pinakes/app/LibraryDaoTest.kt index ff3e4ea0..664f80a3 100644 --- a/app/src/test/java/com/pinakes/app/LibraryDaoTest.kt +++ b/app/src/test/java/com/pinakes/app/LibraryDaoTest.kt @@ -15,6 +15,7 @@ import com.pinakes.app.data.model.LoanItem import com.pinakes.app.data.model.LoansData import com.pinakes.app.data.model.ReservationItem import com.pinakes.app.data.model.WishlistItem +import com.pinakes.app.ui.common.StatusMapping import kotlinx.coroutines.runBlocking import org.junit.After import org.junit.Assert.assertEquals @@ -26,7 +27,7 @@ import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config -/** Offline copies of the personal lists and the sync bookkeeping (Room v2). */ +/** Offline copies of the personal lists and the sync bookkeeping. */ @RunWith(RobolectricTestRunner::class) @Config(sdk = [34], application = android.app.Application::class) class LibraryDaoTest { @@ -59,6 +60,21 @@ class LibraryDaoTest { assertEquals(loans, back) } + @Test fun readyLoanKeepsItsPickupDateOffline() = runBlocking { + val ready = LoanItem( + id = 5, + status = "da_ritirare", + dueAt = "2030-12-30", + pickupDeadline = "2030-12-27", + ) + dao.replaceLoans(CachedLoan.LOAN_BUCKETS, LoansData(active = listOf(ready)).toCached()) + val cached = dao.loans(CachedLoan.LOAN_BUCKETS).toLoansData().active.single() + assertEquals( + StatusMapping.LoanDate(StatusMapping.LoanDateKind.Pickup, "2030-12-27"), + StatusMapping.loanDate(cached), + ) + } + @Test fun replacingLoansKeepsReservations() = runBlocking { dao.replaceLoans(listOf(CachedLoan.RESERVATION), listOf(ReservationItem(id = 9, bookId = 90, title = "R", queuePosition = 2).toCached(0))) dao.replaceLoans(CachedLoan.LOAN_BUCKETS, loans.toCached()) diff --git a/i18n/fr.json b/i18n/fr.json index 3e3b13e2..a489c7e4 100644 --- a/i18n/fr.json +++ b/i18n/fr.json @@ -305,7 +305,7 @@ "library_section_upcoming": "À retirer et programmés", "library_section_pending": "Demandes en attente", "library_due_label": "À rendre le %1$s", - "library_pickup_by": "À retirer avant le %1$s", + "library_pickup_by": "À retirer au plus tard le %1$s", "library_overdue_label": "En retard", "library_overdue_since": "En retard, devait être rendu le %1$s", "book_personal_on_loan_title": "Vous l'avez emprunté", @@ -902,7 +902,7 @@ "staff_loan_recalls": "Relances", "staff_loan_processed_by": "Traité par", "staff_loan_days_overdue": "Jours de retard : %1$d", - "staff_loan_pickup_by": "À retirer avant le %1$s", + "staff_loan_pickup_by": "À retirer au plus tard le %1$s", "staff_loan_starts_on": "Commence le %1$s", "staff_penalty": "Pénalité", "staff_penalty_invalid": "Un montant comme 12,50 (exemplaires perdus ou abîmés seulement)", From 313c2f4affd0c922f7bac39ea3b2e549b5458b41 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Mon, 9 Nov 2026 11:06:57 +0100 Subject: [PATCH 3/4] Keep the server calendar of a loan in the offline cache The offline copy dropped effective_due_at and is_overdue, so a cached loan due on a day the branch later closed showed the stored date, and a stale in_ritardo row the server had cleared turned red again. Both now travel through CachedLoan in the same v4 migration that adds the pickup day. CacheMigrationTest upgrades a real v3 file without the destructive fallback: the v3 rows and the sync watermark survive, the new fields start empty, and a column added to an entity without a migration fails the test. The DAO round trip now covers every calendar field. --- .../com/pinakes/app/data/local/AppDatabase.kt | 10 ++- .../pinakes/app/data/local/LibraryCache.kt | 8 +- .../com/pinakes/app/CacheMigrationTest.kt | 83 +++++++++++++++++++ .../java/com/pinakes/app/LibraryDaoTest.kt | 16 +++- 4 files changed, 109 insertions(+), 8 deletions(-) create mode 100644 app/src/test/java/com/pinakes/app/CacheMigrationTest.kt diff --git a/app/src/main/java/com/pinakes/app/data/local/AppDatabase.kt b/app/src/main/java/com/pinakes/app/data/local/AppDatabase.kt index 811f37ca..a3502094 100644 --- a/app/src/main/java/com/pinakes/app/data/local/AppDatabase.kt +++ b/app/src/main/java/com/pinakes/app/data/local/AppDatabase.kt @@ -8,7 +8,8 @@ import androidx.room.migration.Migration import androidx.sqlite.db.SupportSQLiteDatabase /** - * Local cache database. V4 adds the pickup deadline while keeping data cached by v3. + * Local cache database. V4 adds the usable return day, the server lateness and the pickup deadline + * of a loan while keeping data cached by v3. * Older schemas can be rebuilt from the server; clearing the sync watermark triggers a full sync. * The database is excluded from backups because it belongs to one instance and one user. */ @@ -25,8 +26,11 @@ abstract class AppDatabase : RoomDatabase() { companion object { const val FILE_NAME = "pinakes-cache.db" - private val migration3To4 = object : Migration(3, 4) { + /** v4 keeps the v3 rows and adds the server calendar facts of a loan, null until the next refresh. */ + internal val MIGRATION_3_4 = object : Migration(3, 4) { override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE cached_loans ADD COLUMN effectiveDueAt TEXT") + db.execSQL("ALTER TABLE cached_loans ADD COLUMN isOverdue INTEGER") db.execSQL("ALTER TABLE cached_loans ADD COLUMN pickupDeadline TEXT") } } @@ -40,7 +44,7 @@ abstract class AppDatabase : RoomDatabase() { context.applicationContext, AppDatabase::class.java, FILE_NAME, - ).addMigrations(migration3To4) + ).addMigrations(MIGRATION_3_4) .fallbackToDestructiveMigration() .build().also { INSTANCE = it } } diff --git a/app/src/main/java/com/pinakes/app/data/local/LibraryCache.kt b/app/src/main/java/com/pinakes/app/data/local/LibraryCache.kt index f8e9adcb..1d51d84c 100644 --- a/app/src/main/java/com/pinakes/app/data/local/LibraryCache.kt +++ b/app/src/main/java/com/pinakes/app/data/local/LibraryCache.kt @@ -25,6 +25,9 @@ data class CachedLoan( val requestedAt: String? = null, val loanedAt: String? = null, val dueAt: String? = null, + // Server calendar facts (Mobile API 1.10.0): offline rows must not fall back to the stored due date. + val effectiveDueAt: String? = null, + val isOverdue: Boolean? = null, val dueAttention: Boolean = false, val pickupDeadline: String? = null, val returnedAt: String? = null, @@ -76,14 +79,15 @@ data class SyncMeta( fun LoanItem.toCached(bucket: String, position: Int): CachedLoan = CachedLoan( bucket = bucket, id = id, position = position, bookId = bookId, title = title, coverUrl = coverUrl, status = status, statusLabel = statusLabel, requestedAt = requestedAt, loanedAt = loanedAt, dueAt = dueAt, + effectiveDueAt = effectiveDueAt, isOverdue = isOverdue, dueAttention = dueAttention, pickupDeadline = pickupDeadline, returnedAt = returnedAt, renewals = renewals, cancellable = cancellable, ) fun CachedLoan.toLoanItem(): LoanItem = LoanItem( id = id, bookId = bookId, title = title, coverUrl = coverUrl, status = status, statusLabel = statusLabel, - requestedAt = requestedAt, loanedAt = loanedAt, dueAt = dueAt, dueAttention = dueAttention, - pickupDeadline = pickupDeadline, returnedAt = returnedAt, renewals = renewals, cancellable = cancellable, + requestedAt = requestedAt, loanedAt = loanedAt, dueAt = dueAt, effectiveDueAt = effectiveDueAt, + isOverdue = isOverdue, dueAttention = dueAttention, pickupDeadline = pickupDeadline, returnedAt = returnedAt, renewals = renewals, cancellable = cancellable, ) fun ReservationItem.toCached(position: Int): CachedLoan = CachedLoan( diff --git a/app/src/test/java/com/pinakes/app/CacheMigrationTest.kt b/app/src/test/java/com/pinakes/app/CacheMigrationTest.kt new file mode 100644 index 00000000..ca1b3aa8 --- /dev/null +++ b/app/src/test/java/com/pinakes/app/CacheMigrationTest.kt @@ -0,0 +1,83 @@ +package com.pinakes.app + +import android.database.sqlite.SQLiteDatabase +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import com.pinakes.app.data.local.AppDatabase +import com.pinakes.app.data.local.CachedLoan +import com.pinakes.app.data.local.toLoansData +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.io.File + +/** + * Upgrading the offline cache keeps what v3 stored. The v3 schema below is the one Room generated + * for version 3 and never changes; Room validates the migrated tables against the current + * entities, so a column added to an entity without a migration fails here instead of on a phone. + * No destructive fallback in this builder: a missing migration is an error, not an empty cache. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = android.app.Application::class) +class CacheMigrationTest { + private val context get() = ApplicationProvider.getApplicationContext() + private lateinit var file: File + + @Before fun setup() { + file = context.getDatabasePath("migration-test.db") + file.parentFile?.mkdirs() + file.delete() + } + + @After fun teardown() { + file.delete() + } + + private fun createV3() { + val raw = SQLiteDatabase.openOrCreateDatabase(file, null) + V3_SCHEMA.forEach(raw::execSQL) + raw.execSQL("INSERT INTO cached_loans (bucket,id,position,bookId,title,status,dueAt,dueAttention,cancellable) VALUES ('active',7,0,70,'Kept','da_ritirare','2030-12-30',0,1)") + raw.execSQL("INSERT INTO sync_meta (`key`,`value`) VALUES ('watermark','w3')") + raw.version = 3 + raw.close() + } + + @Test fun v3RowsSurviveAndTheNewCalendarFieldsStartEmpty() = runBlocking { + createV3() + val db = Room.databaseBuilder(context, AppDatabase::class.java, file.absolutePath) + .addMigrations(AppDatabase.MIGRATION_3_4) + .allowMainThreadQueries() + .build() + try { + val loan = db.libraryDao().loans(CachedLoan.LOAN_BUCKETS).toLoansData().active.single() + assertEquals("Kept", loan.title) + assertEquals("2030-12-30", loan.dueAt) + assertNull(loan.effectiveDueAt) + assertNull(loan.isOverdue) + assertNull(loan.pickupDeadline) + assertEquals("w3", db.syncMetaDao().get("watermark")) + assertEquals(4, db.openHelper.readableDatabase.version) + } finally { + db.close() + } + } + + private companion object { + val V3_SCHEMA = listOf( + "CREATE TABLE IF NOT EXISTS `cached_books` (`id` INTEGER NOT NULL, `title` TEXT NOT NULL, `subtitle` TEXT, `author` TEXT, `publisher` TEXT, `genre` TEXT, `year` INTEGER, `language` TEXT, `mediaType` TEXT, `isbn13` TEXT, `isbn10` TEXT, `ean` TEXT, `pages` INTEGER, `description` TEXT, `locationLabel` TEXT, `coverUrl` TEXT, `copiesTotal` INTEGER NOT NULL, `copiesAvailable` INTEGER NOT NULL, `loanableNow` INTEGER NOT NULL, `isWanted` INTEGER NOT NULL, `updatedAt` TEXT NOT NULL, `searchKey` TEXT NOT NULL, PRIMARY KEY(`id`))", + "CREATE INDEX IF NOT EXISTS `index_cached_books_title` ON `cached_books` (`title`)", + "CREATE INDEX IF NOT EXISTS `index_cached_books_updatedAt` ON `cached_books` (`updatedAt`)", + "CREATE TABLE IF NOT EXISTS `cached_loans` (`bucket` TEXT NOT NULL, `id` INTEGER NOT NULL, `position` INTEGER NOT NULL, `bookId` INTEGER NOT NULL, `title` TEXT NOT NULL, `coverUrl` TEXT, `status` TEXT NOT NULL, `statusLabel` TEXT, `requestedAt` TEXT, `loanedAt` TEXT, `dueAt` TEXT, `dueAttention` INTEGER NOT NULL, `returnedAt` TEXT, `renewals` INTEGER, `cancellable` INTEGER NOT NULL, `queuePosition` INTEGER, `requestedFrom` TEXT, `requestedTo` TEXT, `reservedAt` TEXT, `expiresAt` TEXT, PRIMARY KEY(`bucket`, `id`))", + "CREATE TABLE IF NOT EXISTS `cached_wishlist` (`bookId` INTEGER NOT NULL, `position` INTEGER NOT NULL, `title` TEXT NOT NULL, `author` TEXT, `year` INTEGER, `coverUrl` TEXT, `copiesAvailable` INTEGER NOT NULL, `loanableNow` INTEGER NOT NULL, PRIMARY KEY(`bookId`))", + "CREATE TABLE IF NOT EXISTS `sync_meta` (`key` TEXT NOT NULL, `value` TEXT NOT NULL, PRIMARY KEY(`key`))", + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'v3')", + ) + } +} diff --git a/app/src/test/java/com/pinakes/app/LibraryDaoTest.kt b/app/src/test/java/com/pinakes/app/LibraryDaoTest.kt index 664f80a3..e1fc3a0a 100644 --- a/app/src/test/java/com/pinakes/app/LibraryDaoTest.kt +++ b/app/src/test/java/com/pinakes/app/LibraryDaoTest.kt @@ -48,8 +48,9 @@ class LibraryDaoTest { private val loans = LoansData( pending = listOf(LoanItem(id = 3, bookId = 30, title = "Pending", status = "pendente", cancellable = true)), active = listOf( - LoanItem(id = 1, bookId = 10, title = "First", status = "in_corso", dueAt = "2026-11-01"), - LoanItem(id = 2, bookId = 20, title = "Second", status = "in_ritardo", dueAttention = true), + LoanItem(id = 1, bookId = 10, title = "First", status = "in_corso", dueAt = "2026-11-01", effectiveDueAt = "2026-11-03", isOverdue = false), + LoanItem(id = 2, bookId = 20, title = "Second", status = "in_ritardo", isOverdue = true, dueAttention = true), + LoanItem(id = 5, bookId = 50, title = "Ready", status = "da_ritirare", dueAt = "2026-11-20", pickupDeadline = "2026-11-06"), ), history = listOf(LoanItem(id = 4, bookId = 40, title = "Old", status = "restituito", returnedAt = "2026-01-01")), ) @@ -75,6 +76,15 @@ class LibraryDaoTest { ) } + @Test fun offlineLoanKeepsTheServerCalendar() = runBlocking { + // Due on a day the branch later closed: the server moved it and cleared the stale late state. + val moved = LoanItem(id = 6, status = "in_ritardo", dueAt = "2030-12-25", effectiveDueAt = "2030-12-27", isOverdue = false) + dao.replaceLoans(CachedLoan.LOAN_BUCKETS, LoansData(active = listOf(moved)).toCached()) + val cached = dao.loans(CachedLoan.LOAN_BUCKETS).toLoansData().active.single() + assertEquals(StatusMapping.LoanGroup.OnLoan, StatusMapping.loanGroup(cached)) + assertEquals(StatusMapping.LoanDate(StatusMapping.LoanDateKind.Due, "2030-12-27"), StatusMapping.loanDate(cached)) + } + @Test fun replacingLoansKeepsReservations() = runBlocking { dao.replaceLoans(listOf(CachedLoan.RESERVATION), listOf(ReservationItem(id = 9, bookId = 90, title = "R", queuePosition = 2).toCached(0))) dao.replaceLoans(CachedLoan.LOAN_BUCKETS, loans.toCached()) @@ -124,7 +134,7 @@ class LibraryDaoTest { dao.deleteLoan(9, listOf(CachedLoan.RESERVATION)) val back = dao.loans(CachedLoan.LOAN_BUCKETS).toLoansData() assertTrue(back.pending.isEmpty()) - assertEquals(2, back.active.size) + assertEquals(loans.active, back.active) // the active bucket is untouched assertEquals(1, back.history.size) // other buckets untouched assertTrue(dao.loans(listOf(CachedLoan.RESERVATION)).isEmpty()) From 28191628f52b19456a16b576413f118ff6599eec Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Mon, 9 Nov 2026 12:22:00 +0100 Subject: [PATCH 4/4] Treat a blank pickup day as absent A ready loan whose pickup_deadline is blank now shows its return day instead of an empty pickup label. --- app/src/main/java/com/pinakes/app/ui/common/StatusMapping.kt | 2 +- .../test/java/com/pinakes/app/CalendarLoanAlignmentTest.kt | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) 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 510beb21..51b373e1 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 @@ -120,7 +120,7 @@ object StatusMapping { } if (loan.returnedAt != null) return LoanDate(LoanDateKind.Returned, loan.returnedAt) // A ready loan is not in hand yet: the date that matters is the last pickup day. - if (loan.status == "da_ritirare" && loan.pickupDeadline != null) { + if (loan.status == "da_ritirare" && !loan.pickupDeadline.isNullOrBlank()) { return LoanDate(LoanDateKind.Pickup, loan.pickupDeadline) } if (loan.displayDueAt != null) return LoanDate(LoanDateKind.Due, loan.displayDueAt) diff --git a/app/src/test/java/com/pinakes/app/CalendarLoanAlignmentTest.kt b/app/src/test/java/com/pinakes/app/CalendarLoanAlignmentTest.kt index cafdb322..97cf0608 100644 --- a/app/src/test/java/com/pinakes/app/CalendarLoanAlignmentTest.kt +++ b/app/src/test/java/com/pinakes/app/CalendarLoanAlignmentTest.kt @@ -60,6 +60,11 @@ class CalendarLoanAlignmentTest { assertEquals(StatusMapping.LoanDate(StatusMapping.LoanDateKind.Due, "2030-12-30"), StatusMapping.loanDate(loan)) } + @Test fun blankPickupDayFallsBackToTheReturnDay() { + val loan = LoanItem(status = "da_ritirare", dueAt = "2030-12-30", pickupDeadline = " ", isOverdue = false) + assertEquals(StatusMapping.LoanDate(StatusMapping.LoanDateKind.Due, "2030-12-30"), StatusMapping.loanDate(loan)) + } + @Test fun pickupDayIsIgnoredOnceTheLoanIsCollected() { val loan = LoanItem(status = "in_corso", dueAt = "2030-12-30", pickupDeadline = "2030-12-27", isOverdue = false) assertEquals(StatusMapping.LoanDate(StatusMapping.LoanDateKind.Due, "2030-12-30"), StatusMapping.loanDate(loan))