From 0b4b828c71fe46fefc708b9c9864f55ac9a6e002 Mon Sep 17 00:00:00 2001 From: Conroy Ricketts Date: Thu, 1 Oct 2026 15:56:08 -0400 Subject: [PATCH 1/7] CCCT-2888 Optional Daily Limits Backward Compat [AI] Parsed null opportunity and payment unit daily limits as "no daily limit" instead of failing. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../connect/models/ConnectJobRecord.java | 7 ++- .../models/ConnectPaymentUnitRecord.java | 9 ++- app/src/org/commcare/utils/JsonExtensions.kt | 6 ++ .../parser/ConnectOpportunitiesParserTest.kt | 56 +++++++++++++++++++ .../org/commcare/utils/JsonExtensionsTest.kt | 39 +++++++++++++ 5 files changed, 115 insertions(+), 2 deletions(-) create mode 100644 app/unit-tests/src/org/commcare/utils/JsonExtensionsTest.kt diff --git a/app/src/org/commcare/android/database/connect/models/ConnectJobRecord.java b/app/src/org/commcare/android/database/connect/models/ConnectJobRecord.java index 8e968e084a..e26c252ade 100644 --- a/app/src/org/commcare/android/database/connect/models/ConnectJobRecord.java +++ b/app/src/org/commcare/android/database/connect/models/ConnectJobRecord.java @@ -91,6 +91,7 @@ public class ConnectJobRecord extends Persisted implements Serializable { public static final String META_JOB_UUID = "opportunity_id"; + public static final int NO_DAILY_LIMIT = -1; @Persisting(1) @MetaField(META_JOB_ID) @@ -205,7 +206,7 @@ public static ConnectJobRecord fromJson(JSONObject json) throws JSONException { job.projectEndDate = JsonExtensions.requireDate(json, META_END_DATE); job.projectStartDate = JsonExtensions.requireDate(json, META_START_DATE); job.maxVisits = json.getInt(META_MAX_VISITS_PER_USER); - job.maxDailyVisits = json.getInt(META_MAX_DAILY_VISITS); + job.maxDailyVisits = JsonExtensions.requireIntOrDefaultIfNull(json, META_MAX_DAILY_VISITS, NO_DAILY_LIMIT); job.budgetPerVisit = json.getInt(META_BUDGET_PER_VISIT); String budgetPerUserKey = "budget_per_user"; job.totalBudget = json.getInt(budgetPerUserKey); @@ -347,6 +348,10 @@ public int getMaxDailyVisits() { return maxDailyVisits; } + public boolean hasDailyLimit() { + return maxDailyVisits != NO_DAILY_LIMIT; + } + public Date getProjectStartDate() { return projectStartDate; } diff --git a/app/src/org/commcare/android/database/connect/models/ConnectPaymentUnitRecord.java b/app/src/org/commcare/android/database/connect/models/ConnectPaymentUnitRecord.java index d2ca6df301..0d15a1bfad 100644 --- a/app/src/org/commcare/android/database/connect/models/ConnectPaymentUnitRecord.java +++ b/app/src/org/commcare/android/database/connect/models/ConnectPaymentUnitRecord.java @@ -4,6 +4,7 @@ import org.commcare.models.framework.Persisting; import org.commcare.modern.database.Table; import org.commcare.modern.models.MetaField; +import org.commcare.utils.JsonExtensions; import org.javarosa.core.services.Logger; import org.json.JSONException; import org.json.JSONObject; @@ -28,6 +29,8 @@ public class ConnectPaymentUnitRecord extends Persisted implements Serializable public static final String META_JOB_UUID = ConnectJobRecord.META_JOB_UUID; public static final String META_PAYMENT_UNIT_UUID = "payment_unit_id"; + public static final int NO_DAILY_LIMIT = -1; + @Persisting(1) @MetaField(META_JOB_ID) private int jobId; @@ -76,7 +79,7 @@ public static ConnectPaymentUnitRecord fromJson(JSONObject json, ConnectJobRecor paymentUnit.name = json.getString(META_NAME); paymentUnit.maxTotal = json.getInt(META_TOTAL); - paymentUnit.maxDaily = json.getInt(META_DAILY); + paymentUnit.maxDaily = JsonExtensions.requireIntOrDefaultIfNull(json, META_DAILY, NO_DAILY_LIMIT); paymentUnit.amount = json.getInt(META_AMOUNT); return paymentUnit; @@ -123,6 +126,10 @@ public int getMaxDaily() { return maxDaily; } + public boolean hasDailyLimit() { + return maxDaily != NO_DAILY_LIMIT; + } + public int getAmount() { return amount; } diff --git a/app/src/org/commcare/utils/JsonExtensions.kt b/app/src/org/commcare/utils/JsonExtensions.kt index cb6a658e33..51149547d3 100644 --- a/app/src/org/commcare/utils/JsonExtensions.kt +++ b/app/src/org/commcare/utils/JsonExtensions.kt @@ -20,6 +20,12 @@ fun JSONObject.optStringSafe( /** Returns the value at [key] if it is present and not blank, otherwise null. */ fun JSONObject.optNonBlankStringSafe(key: String): String? = optStringSafe(key, null)?.takeIf { it.isNotBlank() } +/** Returns the int at [key], or [fallback] if it is null. Throws if [key] is missing. */ +fun JSONObject.requireIntOrDefaultIfNull( + key: String, + fallback: Int, +): Int = if (get(key) == JSONObject.NULL) fallback else getInt(key) + fun JSONObject.requireDate(key: String): Date = DateUtils.parseDate(getString(key)) ?: throw JSONException("Unparseable date for $key: ${getString(key)}") diff --git a/app/unit-tests/src/org/commcare/connect/network/connect/parser/ConnectOpportunitiesParserTest.kt b/app/unit-tests/src/org/commcare/connect/network/connect/parser/ConnectOpportunitiesParserTest.kt index 8de5e47cf4..8e1ebf2d85 100644 --- a/app/unit-tests/src/org/commcare/connect/network/connect/parser/ConnectOpportunitiesParserTest.kt +++ b/app/unit-tests/src/org/commcare/connect/network/connect/parser/ConnectOpportunitiesParserTest.kt @@ -19,7 +19,9 @@ import org.json.JSONException import org.json.JSONObject import org.junit.After import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import org.junit.runner.RunWith @@ -87,6 +89,22 @@ class ConnectOpportunitiesParserTest { put("learn_modules", JSONArray()) } + private fun paymentUnitJson(maxDaily: Any): JSONObject = + JSONObject().apply { + put("id", 7) + put("payment_unit_id", "unit-uuid-7") + put("name", "Registration") + put("max_total", 20) + put("max_daily", maxDaily) + put("amount", 100) + } + + private fun parseSingleJob(job: JSONObject): ConnectJobRecord { + every { ConnectJobUtils.storeJobs(any(), any(), any()) } returns 1 + every { ConnectReleaseTogglesWorker.scheduleOneTimeFetch(any()) } just Runs + return parser.parse(200, jsonArrayOf(job), null).single() + } + private fun claimedJobJson( id: Int, claim: JSONObject, @@ -237,6 +255,44 @@ class ConnectOpportunitiesParserTest { verify(exactly = 1) { ConnectJobUtils.storeJobs(any(), match { it.isEmpty() }, true) } } + @Test + fun `parse keeps the daily limits when the job and its payment unit have them`() { + val job = + parseSingleJob( + validJobJson(1).apply { put("payment_units", JSONArray().put(paymentUnitJson(5))) }, + ) + + assertTrue(job.hasDailyLimit()) + assertEquals(10, job.maxDailyVisits) + assertTrue(job.paymentUnits.single().hasDailyLimit()) + assertEquals(5, job.paymentUnits.single().maxDaily) + } + + @Test + fun `parse treats a null job daily limit as no daily limit`() { + val job = parseSingleJob(validJobJson(1).apply { put("daily_max_visits_per_user", JSONObject.NULL) }) + + assertFalse(job.hasDailyLimit()) + } + + @Test + fun `parse treats a null payment unit daily limit as no daily limit`() { + val job = + parseSingleJob( + validJobJson(1).apply { + put("payment_units", JSONArray().put(paymentUnitJson(JSONObject.NULL))) + }, + ) + + assertFalse(job.paymentUnits.single().hasDailyLimit()) + assertEquals(20, job.paymentUnits.single().maxTotal) + } + + @Test + fun `parse stores valid job and skips job missing its daily limit`() { + assertBadJobIsSkipped(validJobJson(10).apply { remove("daily_max_visits_per_user") }) + } + @Test fun `parse stores valid jobs when one entry has a null end_date`() { assertBadJobIsSkipped(validJobJson(10).apply { put("end_date", JSONObject.NULL) }) diff --git a/app/unit-tests/src/org/commcare/utils/JsonExtensionsTest.kt b/app/unit-tests/src/org/commcare/utils/JsonExtensionsTest.kt new file mode 100644 index 0000000000..fdf3f81567 --- /dev/null +++ b/app/unit-tests/src/org/commcare/utils/JsonExtensionsTest.kt @@ -0,0 +1,39 @@ +package org.commcare.utils + +import org.json.JSONException +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Test + +class JsonExtensionsTest { + @Test + fun `requireIntOrDefaultIfNull returns the int when present`() { + val json = JSONObject().put("limit", 5) + + assertEquals(5, json.requireIntOrDefaultIfNull("limit", -1)) + } + + @Test + fun `requireIntOrDefaultIfNull returns the null value when the value is null`() { + val json = JSONObject().put("limit", JSONObject.NULL) + + assertEquals(-1, json.requireIntOrDefaultIfNull("limit", -1)) + } + + @Test + fun `requireIntOrDefaultIfNull throws when the key is missing`() { + assertThrows(JSONException::class.java) { + JSONObject().requireIntOrDefaultIfNull("limit", -1) + } + } + + @Test + fun `requireIntOrDefaultIfNull throws when the value is not a number`() { + val json = JSONObject().put("limit", "abc") + + assertThrows(JSONException::class.java) { + json.requireIntOrDefaultIfNull("limit", -1) + } + } +} From de5e6cb3e365b46249f73b067b02ef19673e05d1 Mon Sep 17 00:00:00 2001 From: Conroy Ricketts Date: Thu, 1 Oct 2026 15:56:08 -0400 Subject: [PATCH 2/7] CCCT-2888 Optional Daily Limits Backward Compat [AI] Stopped missing daily limits from triggering daily-limit warnings or blocking further work. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../connect/models/ConnectJobRecord.java | 13 +++++--- .../models/ConnectPaymentUnitRecord.java | 4 +++ .../models/ConnectJobRecordCardMessageTest.kt | 31 ++++++++++++++++- .../models/ConnectJobRecordWorkBlockedTest.kt | 33 ++++++++++++++++++- 4 files changed, 74 insertions(+), 7 deletions(-) diff --git a/app/src/org/commcare/android/database/connect/models/ConnectJobRecord.java b/app/src/org/commcare/android/database/connect/models/ConnectJobRecord.java index e26c252ade..ee8f7508fa 100644 --- a/app/src/org/commcare/android/database/connect/models/ConnectJobRecord.java +++ b/app/src/org/commcare/android/database/connect/models/ConnectJobRecord.java @@ -352,6 +352,10 @@ public boolean hasDailyLimit() { return maxDailyVisits != NO_DAILY_LIMIT; } + private boolean isDailyLimitReached() { + return hasDailyLimit() && numberOfDeliveriesToday() >= maxDailyVisits; + } + public Date getProjectStartDate() { return projectStartDate; } @@ -783,7 +787,7 @@ public String getCardMessageText(Context context) { // The job-level caps are checked ahead of the per-unit warnings: once the whole // opportunity is spent, which individual unit ran out first no longer matters. return context.getString(R.string.connect_progress_warning_max_reached_single); - } else if (numberOfDeliveriesToday() >= getMaxDailyVisits()) { + } else if (isDailyLimitReached()) { return context.getString(R.string.connect_progress_warning_daily_max_reached_single); } else if (!getPaymentUnits().isEmpty()) { return getMultiVisitWarnings(context); @@ -807,7 +811,7 @@ private String getMultiVisitWarnings(Context context) { totalMaxes.add(unit.getName()); } else { int todayCount = today.containsKey(key) ? today.get(key) : 0; - if (todayCount >= unit.getMaxDaily()) { + if (unit.isDailyLimitReached(todayCount)) { dailyMaxes.add(unit.getName()); } } @@ -846,8 +850,7 @@ public boolean isFurtherWorkBlocked() { } // The job-level caps bind whatever the payment units allow, so they are checked first. - if (getDeliveries().size() >= getMaxVisits() - || numberOfDeliveriesToday() >= getMaxDailyVisits()) { + if (getDeliveries().size() >= getMaxVisits() || isDailyLimitReached()) { return true; } @@ -871,7 +874,7 @@ public Set getPaymentUnitsAtLimit() { String key = unit.getUnitUUID(); int totalCount = total.containsKey(key) ? total.get(key) : 0; int todayCount = today.containsKey(key) ? today.get(key) : 0; - if (totalCount >= unit.getMaxTotal() || todayCount >= unit.getMaxDaily()) { + if (totalCount >= unit.getMaxTotal() || unit.isDailyLimitReached(todayCount)) { atLimit.add(key); } } diff --git a/app/src/org/commcare/android/database/connect/models/ConnectPaymentUnitRecord.java b/app/src/org/commcare/android/database/connect/models/ConnectPaymentUnitRecord.java index 0d15a1bfad..47dec8600d 100644 --- a/app/src/org/commcare/android/database/connect/models/ConnectPaymentUnitRecord.java +++ b/app/src/org/commcare/android/database/connect/models/ConnectPaymentUnitRecord.java @@ -130,6 +130,10 @@ public boolean hasDailyLimit() { return maxDaily != NO_DAILY_LIMIT; } + public boolean isDailyLimitReached(int visitsToday) { + return hasDailyLimit() && visitsToday >= maxDaily; + } + public int getAmount() { return amount; } diff --git a/app/unit-tests/src/org/commcare/android/database/connect/models/ConnectJobRecordCardMessageTest.kt b/app/unit-tests/src/org/commcare/android/database/connect/models/ConnectJobRecordCardMessageTest.kt index 532117638a..f599b3c7a3 100644 --- a/app/unit-tests/src/org/commcare/android/database/connect/models/ConnectJobRecordCardMessageTest.kt +++ b/app/unit-tests/src/org/commcare/android/database/connect/models/ConnectJobRecordCardMessageTest.kt @@ -71,7 +71,7 @@ class ConnectJobRecordCardMessageTest { private fun paymentUnit( id: Int, maxTotal: Int, - maxDaily: Int, + maxDaily: Int?, ): ConnectPaymentUnitRecord = ConnectPaymentUnitRecord.fromJson( JSONObject( @@ -155,6 +155,35 @@ class ConnectJobRecordCardMessageTest { ) } + @Test + fun `a job without a daily limit never reports the daily maximum`() { + val job = multiPaymentJob(maxDailyVisits = ConnectJobRecord.NO_DAILY_LIMIT) + job.deliveries = List(5) { delivery(it, 1, Date()) } + + assertNull(job.getCardMessageText(context)) + } + + @Test + fun `a payment unit without a daily limit is never named in the daily maximum warning`() { + val job = multiPaymentJob() + job.paymentUnits = + listOf( + paymentUnit(id = 1, maxTotal = 50, maxDaily = null), + paymentUnit(id = 2, maxTotal = 50, maxDaily = 1), + ) + job.deliveries = + listOf( + delivery(1, 1, Date()), + delivery(2, 1, Date()), + delivery(3, 2, Date()), + ) + + assertEquals( + context.getString(R.string.connect_progress_warning_daily_max_reached_multi, "Unit 2"), + job.getCardMessageText(context), + ) + } + @Test fun `a multi-payment job with room everywhere reports nothing`() { val job = multiPaymentJob() diff --git a/app/unit-tests/src/org/commcare/android/database/connect/models/ConnectJobRecordWorkBlockedTest.kt b/app/unit-tests/src/org/commcare/android/database/connect/models/ConnectJobRecordWorkBlockedTest.kt index edec9ba3e3..7295c580b3 100644 --- a/app/unit-tests/src/org/commcare/android/database/connect/models/ConnectJobRecordWorkBlockedTest.kt +++ b/app/unit-tests/src/org/commcare/android/database/connect/models/ConnectJobRecordWorkBlockedTest.kt @@ -38,7 +38,7 @@ class ConnectJobRecordWorkBlockedTest { private fun paymentUnit( id: Int, maxTotal: Int, - maxDaily: Int, + maxDaily: Int?, ): ConnectPaymentUnitRecord = ConnectPaymentUnitRecord.fromJson( JSONObject( @@ -300,6 +300,37 @@ class ConnectJobRecordWorkBlockedTest { assertTrue(job.isFurtherWorkBlocked) } + @Test + fun `a job without a daily limit is not blocked by today's visits`() { + val job = job(maxVisits = 10, maxDailyVisits = ConnectJobRecord.NO_DAILY_LIMIT) + job.deliveries = List(5) { delivery(it, 1, Date()) } + + assertFalse(job.isFurtherWorkBlocked) + } + + @Test + fun `a payment unit without a daily limit is not at its limit from today's visits`() { + val job = job(maxVisits = 100, maxDailyVisits = ConnectJobRecord.NO_DAILY_LIMIT) + job.paymentUnits = listOf(paymentUnit(id = 1, maxTotal = 50, maxDaily = null)) + job.deliveries = List(5) { delivery(it, 1, Date()) } + + assertTrue(job.paymentUnitsAtLimit.isEmpty()) + assertFalse(job.isFurtherWorkBlocked) + } + + @Test + fun `a payment unit without a daily limit is still blocked by its total limit`() { + val job = job(maxVisits = 100, maxDailyVisits = ConnectJobRecord.NO_DAILY_LIMIT) + job.paymentUnits = listOf(paymentUnit(id = 1, maxTotal = 2, maxDaily = null)) + job.deliveries = + listOf( + delivery(1, 1, daysFromNow(-1)), + delivery(2, 1, daysFromNow(-1)), + ) + + assertTrue(job.isFurtherWorkBlocked) + } + /** Guards the empty case, where an unguarded `atLimit.size == units.size` compares 0 to 0. */ @Test fun `a job with no payment units is not blocked while it has visits left`() { From 5d931e2d522bfa10e81d26e8072559b2eb750deb Mon Sep 17 00:00:00 2001 From: Conroy Ricketts Date: Thu, 1 Oct 2026 15:56:08 -0400 Subject: [PATCH 3/7] CCCT-2888 Optional Daily Limits Backward Compat [AI] Showed a plain daily visit count and hid the per-day limit text wherever an opportunity has no daily limit. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../item_progress_job_summary_visit.xml | 5 ++++- .../StandardHomeActivityUIController.java | 4 +++- .../ConnectProgressJobSummaryAdapter.java | 9 ++++++++ .../ConnectDeliveryPaymentSummaryInfo.java | 6 ++++++ .../ConnectDeliveryDashboardFragment.kt | 21 +++++++++++-------- .../connect/ConnectJobIntroFragment.kt | 2 +- .../views/connect/ConnectLearnCompleteView.kt | 2 +- .../views/connect/ConnectProgressCard.kt | 11 +++++++++- .../ConnectDeliveryDashboardFragmentTest.kt | 14 ++++++++++++- .../connect/ConnectJobIntroFragmentTest.kt | 9 ++++++++ .../connect/ConnectLearnCompleteViewTest.kt | 9 ++++++++ .../views/connect/ConnectProgressCardTest.kt | 18 ++++++++++++++++ 12 files changed, 95 insertions(+), 15 deletions(-) diff --git a/app/res/layout/item_progress_job_summary_visit.xml b/app/res/layout/item_progress_job_summary_visit.xml index 6739ef0517..9f6c591c81 100644 --- a/app/res/layout/item_progress_job_summary_visit.xml +++ b/app/res/layout/item_progress_job_summary_visit.xml @@ -24,8 +24,10 @@ tools:text="2/2" android:textColor="@color/connect_dark_blue_color" android:textSize="14sp" + android:layout_marginEnd="20dp" + app:layout_goneMarginEnd="0dp" app:layout_constraintBottom_toBottomOf="@+id/tv_primary_visit_title" - app:layout_constraintEnd_toStartOf="@+id/guideline3" + app:layout_constraintEnd_toStartOf="@+id/lp_primary_visit_progress" app:layout_constraintTop_toTopOf="@+id/tv_primary_visit_title" /> diff --git a/app/src/org/commcare/activities/StandardHomeActivityUIController.java b/app/src/org/commcare/activities/StandardHomeActivityUIController.java index 4e537b7312..5c8aadd7e8 100644 --- a/app/src/org/commcare/activities/StandardHomeActivityUIController.java +++ b/app/src/org/commcare/activities/StandardHomeActivityUIController.java @@ -234,7 +234,9 @@ public void updateConnectJobProgress() { list.add(new ConnectDeliveryPaymentSummaryInfo( activity.getString(R.string.connect_job_tile_daily_visits), job.numberOfDeliveriesToday(), - job.getMaxDailyVisits() + job.hasDailyLimit() + ? job.getMaxDailyVisits() + : ConnectDeliveryPaymentSummaryInfo.NO_DAILY_LIMIT )); connectProgressJobSummaryAdapter.setDeliverySummaries(list); diff --git a/app/src/org/commcare/adapters/ConnectProgressJobSummaryAdapter.java b/app/src/org/commcare/adapters/ConnectProgressJobSummaryAdapter.java index 234af95402..a45cc9c228 100644 --- a/app/src/org/commcare/adapters/ConnectProgressJobSummaryAdapter.java +++ b/app/src/org/commcare/adapters/ConnectProgressJobSummaryAdapter.java @@ -36,6 +36,15 @@ public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { public void onBindViewHolder(@NonNull ViewHolder holder, int position) { ConnectDeliveryPaymentSummaryInfo summary = deliverySummaries.get(position); holder.tvPrimaryVisitTitle.setText(summary.getPaymentUnitName()); + if (!summary.hasDailyLimit()) { + holder.lpPrimaryVisitProgress.setVisibility(View.GONE); + holder.tvPrimaryVisitCount.setText( + String.format(Locale.getDefault(), "%d", summary.getPaymentUnitAmount()) + ); + return; + } + + holder.lpPrimaryVisitProgress.setVisibility(View.VISIBLE); holder.tvPrimaryVisitCount.setText(String.format(Locale.getDefault(), "%d/%d", summary.getPaymentUnitAmount(), summary.getPaymentUnitMaxDaily())); diff --git a/app/src/org/commcare/android/database/connect/models/ConnectDeliveryPaymentSummaryInfo.java b/app/src/org/commcare/android/database/connect/models/ConnectDeliveryPaymentSummaryInfo.java index 9f36b30630..fff7c5d331 100644 --- a/app/src/org/commcare/android/database/connect/models/ConnectDeliveryPaymentSummaryInfo.java +++ b/app/src/org/commcare/android/database/connect/models/ConnectDeliveryPaymentSummaryInfo.java @@ -1,6 +1,8 @@ package org.commcare.android.database.connect.models; public class ConnectDeliveryPaymentSummaryInfo { + public static final int NO_DAILY_LIMIT = -1; + private String paymentUnitName; private int paymentUnitAmount; private int paymentUnitMaxDaily; @@ -35,4 +37,8 @@ public int getPaymentUnitMaxDaily() { public void setPaymentUnitMaxDaily(int paymentUnitMaxDaily) { this.paymentUnitMaxDaily = paymentUnitMaxDaily; } + + public boolean hasDailyLimit() { + return paymentUnitMaxDaily != NO_DAILY_LIMIT; + } } diff --git a/app/src/org/commcare/fragments/connect/ConnectDeliveryDashboardFragment.kt b/app/src/org/commcare/fragments/connect/ConnectDeliveryDashboardFragment.kt index 81fe47766b..4fc99aa98f 100644 --- a/app/src/org/commcare/fragments/connect/ConnectDeliveryDashboardFragment.kt +++ b/app/src/org/commcare/fragments/connect/ConnectDeliveryDashboardFragment.kt @@ -62,8 +62,8 @@ class ConnectDeliveryDashboardFragment : } private fun bindVisitProgress(contentEnabled: Boolean) { - val doneToday = job.numberOfDeliveriesToday() - val remainingToday = (job.maxDailyVisits - doneToday).coerceAtLeast(0) + val visitsDoneToday = job.numberOfDeliveriesToday() + val maxDailyVisits = job.maxDailyVisits.takeIf { job.hasDailyLimit() } val cardMessage: String? = job.getCardMessageText(requireContext()) binding.deliveryProgressCard.bind( ConnectProgressCard.State( @@ -85,14 +85,17 @@ class ConnectDeliveryDashboardFragment : linearProgress = ConnectProgressCard.State.LinearProgress( label = getString(R.string.connect_delivery_daily_visits), - current = doneToday, - max = job.maxDailyVisits, + current = visitsDoneToday, + max = maxDailyVisits, caption = - resources.getQuantityString( - R.plurals.connect_delivery_visits_remaining_today, - remainingToday, - remainingToday, - ), + maxDailyVisits?.let { max -> + val remainingToday = (max - visitsDoneToday).coerceAtLeast(0) + resources.getQuantityString( + R.plurals.connect_delivery_visits_remaining_today, + remainingToday, + remainingToday, + ) + }, ), ), ) diff --git a/app/src/org/commcare/fragments/connect/ConnectJobIntroFragment.kt b/app/src/org/commcare/fragments/connect/ConnectJobIntroFragment.kt index eb5dea018d..e3e99ae76b 100644 --- a/app/src/org/commcare/fragments/connect/ConnectJobIntroFragment.kt +++ b/app/src/org/commcare/fragments/connect/ConnectJobIntroFragment.kt @@ -158,7 +158,7 @@ class ConnectJobIntroFragment : ConnectJobFragment 0) { diff --git a/app/unit-tests/src/org/commcare/fragments/connect/ConnectDeliveryDashboardFragmentTest.kt b/app/unit-tests/src/org/commcare/fragments/connect/ConnectDeliveryDashboardFragmentTest.kt index 6f5b72fbb4..701ba7f51c 100644 --- a/app/unit-tests/src/org/commcare/fragments/connect/ConnectDeliveryDashboardFragmentTest.kt +++ b/app/unit-tests/src/org/commcare/fragments/connect/ConnectDeliveryDashboardFragmentTest.kt @@ -184,6 +184,17 @@ class ConnectDeliveryDashboardFragmentTest { ) } + @Test + fun `a job without a daily limit shows only today's visit count`() { + job = seedDeliveryJob(maxDailyVisits = ConnectJobRecord.NO_DAILY_LIMIT) + val view = launch(progressResponse(deliveries = deliveriesToday(unit = 1, count = 3))).requireView() + + assertEquals("3", view.findViewById(R.id.progress_card_bar_count).text.toString()) + assertEquals(View.GONE, view.findViewById(R.id.progress_card_linear_bar).visibility) + assertEquals(View.GONE, view.findViewById(R.id.progress_card_bar_caption).visibility) + assertEquals(View.GONE, view.findViewById(R.id.progress_card_info_message).visibility) + } + /** * The sync card is the in-page equivalent of the action bar's sync, so the click has to make the * same delivery-progress call and land its result on the figures. @@ -533,10 +544,11 @@ class ConnectDeliveryDashboardFragmentTest { * Writes the opportunity through the real storage layer so the repository's cache read and the * dashboard both see the same record production would. */ - private fun seedDeliveryJob(): ConnectJobRecord { + private fun seedDeliveryJob(maxDailyVisits: Int = ConnectLearnJobTestData.MAX_DAILY_VISITS): ConnectJobRecord { val seeded = ConnectLearnJobTestData.job().apply { status = ConnectJobRecord.STATUS_DELIVERING + setMaxDailyVisits(maxDailyVisits) } ConnectJobUtils.storeJobs(appContext, listOf(seeded), true) return ConnectJobUtils.getCompositeJob(ConnectLearnJobTestData.JOB_UUID)!! diff --git a/app/unit-tests/src/org/commcare/fragments/connect/ConnectJobIntroFragmentTest.kt b/app/unit-tests/src/org/commcare/fragments/connect/ConnectJobIntroFragmentTest.kt index f8a7a4c542..4af3f20afc 100644 --- a/app/unit-tests/src/org/commcare/fragments/connect/ConnectJobIntroFragmentTest.kt +++ b/app/unit-tests/src/org/commcare/fragments/connect/ConnectJobIntroFragmentTest.kt @@ -136,6 +136,15 @@ class ConnectJobIntroFragmentTest : BaseConnectJobIntroTest() { ) } + @Test + fun `max visits card drops the per-day subtitle when the job has no daily limit`() { + job.setMaxDailyVisits(ConnectJobRecord.NO_DAILY_LIMIT) + val fragment = launch() + + assertEquals("100", cardValue(fragment, R.id.card_max_visits)) + assertEquals("", cardSubtitle(fragment, R.id.card_max_visits)) + } + @Test fun `days card shows the days remaining`() { val fragment = launch() diff --git a/app/unit-tests/src/org/commcare/views/connect/ConnectLearnCompleteViewTest.kt b/app/unit-tests/src/org/commcare/views/connect/ConnectLearnCompleteViewTest.kt index c9baafab02..cbbd6025fc 100644 --- a/app/unit-tests/src/org/commcare/views/connect/ConnectLearnCompleteViewTest.kt +++ b/app/unit-tests/src/org/commcare/views/connect/ConnectLearnCompleteViewTest.kt @@ -136,6 +136,15 @@ class ConnectLearnCompleteViewTest { ) } + @Test + fun `the visits card drops the per-day subtitle when the job has no daily limit`() { + val job = ConnectLearnJobTestData.job().apply { setMaxDailyVisits(ConnectJobRecord.NO_DAILY_LIMIT) } + val view = bind(job) + + assertEquals(job.maxPossibleVisits.toString(), view.card(R.id.card_total_visits).valueText) + assertTrue(view.card(R.id.card_total_visits).subtitleText.isNullOrEmpty()) + } + @Test fun `cta offers the delivery download when the delivery app is missing`() { val view = bind() diff --git a/app/unit-tests/src/org/commcare/views/connect/ConnectProgressCardTest.kt b/app/unit-tests/src/org/commcare/views/connect/ConnectProgressCardTest.kt index 37b23cff6a..4a15982b03 100644 --- a/app/unit-tests/src/org/commcare/views/connect/ConnectProgressCardTest.kt +++ b/app/unit-tests/src/org/commcare/views/connect/ConnectProgressCardTest.kt @@ -104,6 +104,24 @@ class ConnectProgressCardTest { assertEquals(View.GONE, count.visibility) } + @Test + fun `a null max shows the bare count and hides the bar until a max returns`() { + val card = newCard() + val count = card.findViewById(R.id.progress_card_bar_count) + val bar = card.findViewById(R.id.progress_card_linear_bar) + + card.bind(State(linearProgress = State.LinearProgress(current = 3, max = null))) + + assertEquals(View.VISIBLE, count.visibility) + assertEquals("3", count.text.toString()) + assertEquals(View.GONE, bar.visibility) + + card.bind(State(linearProgress = State.LinearProgress(current = 3, max = 5))) + + assertEquals("3 of 5", count.text.toString()) + assertEquals(View.VISIBLE, bar.visibility) + } + @Test fun `linear current is clamped to max in the count label`() { val card = newCard() From 08481b4ec14fe773ec47cb8b079a44f131eed7cc Mon Sep 17 00:00:00 2001 From: Conroy Ricketts Date: Thu, 1 Oct 2026 15:56:08 -0400 Subject: [PATCH 4/7] CCCT-2888 Optional Daily Limits Backward Compat [AI] Bumped the Connect API version to 2.0 so the server knows this app handles null daily limits. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../commcare/connect/network/connect/ConnectNetworkClient.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/org/commcare/connect/network/connect/ConnectNetworkClient.kt b/app/src/org/commcare/connect/network/connect/ConnectNetworkClient.kt index 9338044082..4951f4442e 100644 --- a/app/src/org/commcare/connect/network/connect/ConnectNetworkClient.kt +++ b/app/src/org/commcare/connect/network/connect/ConnectNetworkClient.kt @@ -29,7 +29,7 @@ class ConnectNetworkClient ) { companion object { private const val BASE_URL = "https://${BuildConfig.CCC_HOST}" - private const val API_VERSION_CONNECT = "1.0" + private const val API_VERSION_CONNECT = "2.0" @Volatile private var instance: ConnectNetworkClient? = null From 9ab22cb4a041f44e8a6a3e8e0b8a7d8402e4534b Mon Sep 17 00:00:00 2001 From: Conroy Ricketts Date: Thu, 1 Oct 2026 16:03:42 -0400 Subject: [PATCH 5/7] Add release notes for CCCT-2888 Co-Authored-By: Claude Opus 5.5 (1M context) --- RELEASES.md | 1 + 1 file changed, 1 insertion(+) diff --git a/RELEASES.md b/RELEASES.md index 319f9fa18d..d2421581aa 100644 --- a/RELEASES.md +++ b/RELEASES.md @@ -10,6 +10,7 @@ This file is meant as an easy way for us to collate notes and change logs across - The CommCare logo now appears only on the first screen of PersonalID sign-up and account recovery, rather than on every screen. - Messaging and Work History now show the sidebar, and opening another sidebar section replaces the current one instead of stacking on top of it. +- Opportunities whose visits have no daily limit now show today's visit count without a daily progress bar or "daily limit reached" warning. #### Important Bug Fixes From 83a7b33919950e3cfb7cd7fba70c87990d522e53 Mon Sep 17 00:00:00 2001 From: Conroy Ricketts Date: Thu, 1 Oct 2026 16:03:43 -0400 Subject: [PATCH 6/7] Add QA notes for CCCT-2888 Co-Authored-By: Claude Opus 5.5 (1M context) --- RELEASES.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/RELEASES.md b/RELEASES.md index d2421581aa..03faedc7d5 100644 --- a/RELEASES.md +++ b/RELEASES.md @@ -26,6 +26,9 @@ This file is meant as an easy way for us to collate notes and change logs across - Requesting a new OTP code after that should report the remaining wait in minutes or hours, and Resend should stay hidden until it passes. - A single wrong OTP code still shows the usual incorrect OTP code error, and the option to proceed without email during sign-up is still reachable. - In the forgot-backup-code recovery flow, running out of attempts keeps the user on the verification screen instead of ending the flow. +- On an opportunity with no daily visit limit, confirm the delivery dashboard and the job tile on the app home screen show only today's visit count with no progress bar, and no daily-limit warning appears. + - Confirm the opportunity intro and learning-complete screens omit the "Up to N per day" text for such opportunities. + - On an opportunity that still has daily limits, confirm the daily progress bar and daily-limit warnings work as before. ## CommCare 2.64.1 From a575908c9c05dc0af9957dc24313f5b616bd24b6 Mon Sep 17 00:00:00 2001 From: Conroy Ricketts Date: Fri, 2 Oct 2026 09:29:29 -0400 Subject: [PATCH 7/7] CCCT-2888 Optional Daily Limits Backward Compat [AI] Made the daily limit JSON helper fall back on a missing key as well as null, and renamed it to optIntSafe. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../connect/models/ConnectJobRecord.java | 2 +- .../models/ConnectPaymentUnitRecord.java | 2 +- app/src/org/commcare/utils/JsonExtensions.kt | 6 +++--- .../parser/ConnectOpportunitiesParserTest.kt | 6 ++++-- .../org/commcare/utils/JsonExtensionsTest.kt | 18 ++++++++---------- 5 files changed, 17 insertions(+), 17 deletions(-) diff --git a/app/src/org/commcare/android/database/connect/models/ConnectJobRecord.java b/app/src/org/commcare/android/database/connect/models/ConnectJobRecord.java index ee8f7508fa..ec13a4eebe 100644 --- a/app/src/org/commcare/android/database/connect/models/ConnectJobRecord.java +++ b/app/src/org/commcare/android/database/connect/models/ConnectJobRecord.java @@ -206,7 +206,7 @@ public static ConnectJobRecord fromJson(JSONObject json) throws JSONException { job.projectEndDate = JsonExtensions.requireDate(json, META_END_DATE); job.projectStartDate = JsonExtensions.requireDate(json, META_START_DATE); job.maxVisits = json.getInt(META_MAX_VISITS_PER_USER); - job.maxDailyVisits = JsonExtensions.requireIntOrDefaultIfNull(json, META_MAX_DAILY_VISITS, NO_DAILY_LIMIT); + job.maxDailyVisits = JsonExtensions.optIntSafe(json, META_MAX_DAILY_VISITS, NO_DAILY_LIMIT); job.budgetPerVisit = json.getInt(META_BUDGET_PER_VISIT); String budgetPerUserKey = "budget_per_user"; job.totalBudget = json.getInt(budgetPerUserKey); diff --git a/app/src/org/commcare/android/database/connect/models/ConnectPaymentUnitRecord.java b/app/src/org/commcare/android/database/connect/models/ConnectPaymentUnitRecord.java index 47dec8600d..d3fad7e54b 100644 --- a/app/src/org/commcare/android/database/connect/models/ConnectPaymentUnitRecord.java +++ b/app/src/org/commcare/android/database/connect/models/ConnectPaymentUnitRecord.java @@ -79,7 +79,7 @@ public static ConnectPaymentUnitRecord fromJson(JSONObject json, ConnectJobRecor paymentUnit.name = json.getString(META_NAME); paymentUnit.maxTotal = json.getInt(META_TOTAL); - paymentUnit.maxDaily = JsonExtensions.requireIntOrDefaultIfNull(json, META_DAILY, NO_DAILY_LIMIT); + paymentUnit.maxDaily = JsonExtensions.optIntSafe(json, META_DAILY, NO_DAILY_LIMIT); paymentUnit.amount = json.getInt(META_AMOUNT); return paymentUnit; diff --git a/app/src/org/commcare/utils/JsonExtensions.kt b/app/src/org/commcare/utils/JsonExtensions.kt index 51149547d3..2ad7315681 100644 --- a/app/src/org/commcare/utils/JsonExtensions.kt +++ b/app/src/org/commcare/utils/JsonExtensions.kt @@ -20,11 +20,11 @@ fun JSONObject.optStringSafe( /** Returns the value at [key] if it is present and not blank, otherwise null. */ fun JSONObject.optNonBlankStringSafe(key: String): String? = optStringSafe(key, null)?.takeIf { it.isNotBlank() } -/** Returns the int at [key], or [fallback] if it is null. Throws if [key] is missing. */ -fun JSONObject.requireIntOrDefaultIfNull( +/** Returns the int at [key], or [fallback] if it is missing or null. */ +fun JSONObject.optIntSafe( key: String, fallback: Int, -): Int = if (get(key) == JSONObject.NULL) fallback else getInt(key) +): Int = if (hasNonNull(key)) getInt(key) else fallback fun JSONObject.requireDate(key: String): Date = DateUtils.parseDate(getString(key)) diff --git a/app/unit-tests/src/org/commcare/connect/network/connect/parser/ConnectOpportunitiesParserTest.kt b/app/unit-tests/src/org/commcare/connect/network/connect/parser/ConnectOpportunitiesParserTest.kt index 8e1ebf2d85..5affbd5210 100644 --- a/app/unit-tests/src/org/commcare/connect/network/connect/parser/ConnectOpportunitiesParserTest.kt +++ b/app/unit-tests/src/org/commcare/connect/network/connect/parser/ConnectOpportunitiesParserTest.kt @@ -289,8 +289,10 @@ class ConnectOpportunitiesParserTest { } @Test - fun `parse stores valid job and skips job missing its daily limit`() { - assertBadJobIsSkipped(validJobJson(10).apply { remove("daily_max_visits_per_user") }) + fun `parse treats a missing job daily limit as no daily limit`() { + val job = parseSingleJob(validJobJson(1).apply { remove("daily_max_visits_per_user") }) + + assertFalse(job.hasDailyLimit()) } @Test diff --git a/app/unit-tests/src/org/commcare/utils/JsonExtensionsTest.kt b/app/unit-tests/src/org/commcare/utils/JsonExtensionsTest.kt index fdf3f81567..a176b7c11b 100644 --- a/app/unit-tests/src/org/commcare/utils/JsonExtensionsTest.kt +++ b/app/unit-tests/src/org/commcare/utils/JsonExtensionsTest.kt @@ -8,32 +8,30 @@ import org.junit.Test class JsonExtensionsTest { @Test - fun `requireIntOrDefaultIfNull returns the int when present`() { + fun `optIntSafe returns the int when present`() { val json = JSONObject().put("limit", 5) - assertEquals(5, json.requireIntOrDefaultIfNull("limit", -1)) + assertEquals(5, json.optIntSafe("limit", -1)) } @Test - fun `requireIntOrDefaultIfNull returns the null value when the value is null`() { + fun `optIntSafe returns the fallback when the value is null`() { val json = JSONObject().put("limit", JSONObject.NULL) - assertEquals(-1, json.requireIntOrDefaultIfNull("limit", -1)) + assertEquals(-1, json.optIntSafe("limit", -1)) } @Test - fun `requireIntOrDefaultIfNull throws when the key is missing`() { - assertThrows(JSONException::class.java) { - JSONObject().requireIntOrDefaultIfNull("limit", -1) - } + fun `optIntSafe returns the fallback when the key is missing`() { + assertEquals(-1, JSONObject().optIntSafe("limit", -1)) } @Test - fun `requireIntOrDefaultIfNull throws when the value is not a number`() { + fun `optIntSafe throws when the value is not a number`() { val json = JSONObject().put("limit", "abc") assertThrows(JSONException::class.java) { - json.requireIntOrDefaultIfNull("limit", -1) + json.optIntSafe("limit", -1) } } }