From 156d2e5eb9c2ad76812a788b59f3594d7e405592 Mon Sep 17 00:00:00 2001 From: Will Miao Date: Sun, 4 Oct 2026 20:28:57 +0800 Subject: [PATCH] fix(ui): explain a 0 Buzz threshold in the price alerts panel The default threshold is 0 ("only tell me when a version becomes free") and the settings copy says so, but the panel did not: a real instance with 52 priced paid versions and an untouched threshold showed "Nothing is under your price threshold right now" with only a small "Alert threshold: 0 Buzz" in the corner, which reads as a broken feature. - the payload now carries pricedCount, so the empty state can say how many paid versions already have a known price - when the threshold is 0 and prices are known, the empty state says so and points at Settings - Library instead of implying there is nothing to show - the read-time threshold comparison means setting one takes effect immediately; measured on a copy of that instance: 0 Buzz -> 0 alerts, 100 -> 44, 500 -> 48, 5000 -> 51 --- docs/plans/paid-model-price-tracking.md | 12 +++++ locales/de.json | 3 +- locales/en.json | 3 +- locales/es.json | 3 +- locales/fr.json | 3 +- locales/he.json | 3 +- locales/ja.json | 3 +- locales/ko.json | 3 +- locales/ru.json | 3 +- locales/zh-CN.json | 3 +- locales/zh-TW.json | 3 +- py/routes/handlers/misc_handlers.py | 4 ++ py/services/model_update_service.py | 30 +++++++++++++ static/js/managers/UpdateService.js | 44 ++++++++++++++----- .../managers/updateServicePriceAlerts.test.js | 30 +++++++++++++ tests/routes/test_misc_routes.py | 7 ++- tests/services/test_model_update_service.py | 14 ++++++ 17 files changed, 148 insertions(+), 23 deletions(-) diff --git a/docs/plans/paid-model-price-tracking.md b/docs/plans/paid-model-price-tracking.md index 17f393f2..b8d91b7e 100644 --- a/docs/plans/paid-model-price-tracking.md +++ b/docs/plans/paid-model-price-tracking.md @@ -671,3 +671,15 @@ a price-only pass cannot silently extend the metadata TTL. Two related semantics Verified by copying a real instance's update DB into a sandbox and running a non-forced check: `bulk metadata fetches: 0` (version lists came entirely from cache) while priced versions went **1 → 20** and the panel listed 19 alerts. + +### 11.12 The empty state has to explain a 0 Buzz threshold + +The default threshold is 0 ("only tell me when a version becomes free"), and the settings copy says +so. The panel did not: a real instance with **52 priced paid versions** and an untouched threshold +showed "Nothing is under your price threshold right now" plus a small "Alert threshold: 0 Buzz" in +the corner, which reads as "the feature is broken". + +The empty state is now threshold-aware, using a new `pricedCount` in the payload: when the threshold +is 0 and prices are known, it says how many paid versions have a price and where to set a threshold. +The read-time threshold comparison means changing it takes effect immediately — no re-check needed +(measured on a copy of that instance's DB: 0 Buzz -> 0 alerts, 100 -> 44, 500 -> 48, 5000 -> 51). diff --git a/locales/de.json b/locales/de.json index c432c06d..caf22b45 100644 --- a/locales/de.json +++ b/locales/de.json @@ -2411,7 +2411,8 @@ "droppedAgo": "[TODO: Translate] dropped {when}", "openCivitai": "[TODO: Translate] Open on CivitAI", "openLocal": "[TODO: Translate] Open", - "unavailable": "[TODO: Translate] {count} paid version(s) have no readable price (mature models can only be read in a browser)" + "unavailable": "[TODO: Translate] {count} paid version(s) have no readable price (mature models can only be read in a browser)", + "emptyZeroThreshold": "[TODO: Translate] {count} paid version(s) have a known price, but your threshold is 0 Buzz so only free versions are listed. Set a threshold in Settings - Library to catch price drops." } }, "support": { diff --git a/locales/en.json b/locales/en.json index b29554f0..0d34f544 100644 --- a/locales/en.json +++ b/locales/en.json @@ -2411,7 +2411,8 @@ "droppedAgo": "dropped {when}", "openCivitai": "Open on CivitAI", "openLocal": "Open", - "unavailable": "{count} paid version(s) have no readable price (mature models can only be read in a browser)" + "unavailable": "{count} paid version(s) have no readable price (mature models can only be read in a browser)", + "emptyZeroThreshold": "{count} paid version(s) have a known price, but your threshold is 0 Buzz so only free versions are listed. Set a threshold in Settings - Library to catch price drops." } }, "support": { diff --git a/locales/es.json b/locales/es.json index f7f12ee2..b75fc19c 100644 --- a/locales/es.json +++ b/locales/es.json @@ -2411,7 +2411,8 @@ "droppedAgo": "[TODO: Translate] dropped {when}", "openCivitai": "[TODO: Translate] Open on CivitAI", "openLocal": "[TODO: Translate] Open", - "unavailable": "[TODO: Translate] {count} paid version(s) have no readable price (mature models can only be read in a browser)" + "unavailable": "[TODO: Translate] {count} paid version(s) have no readable price (mature models can only be read in a browser)", + "emptyZeroThreshold": "[TODO: Translate] {count} paid version(s) have a known price, but your threshold is 0 Buzz so only free versions are listed. Set a threshold in Settings - Library to catch price drops." } }, "support": { diff --git a/locales/fr.json b/locales/fr.json index 5f19be0a..a9699424 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -2411,7 +2411,8 @@ "droppedAgo": "[TODO: Translate] dropped {when}", "openCivitai": "[TODO: Translate] Open on CivitAI", "openLocal": "[TODO: Translate] Open", - "unavailable": "[TODO: Translate] {count} paid version(s) have no readable price (mature models can only be read in a browser)" + "unavailable": "[TODO: Translate] {count} paid version(s) have no readable price (mature models can only be read in a browser)", + "emptyZeroThreshold": "[TODO: Translate] {count} paid version(s) have a known price, but your threshold is 0 Buzz so only free versions are listed. Set a threshold in Settings - Library to catch price drops." } }, "support": { diff --git a/locales/he.json b/locales/he.json index 7001a5c6..93a6ef6e 100644 --- a/locales/he.json +++ b/locales/he.json @@ -2411,7 +2411,8 @@ "droppedAgo": "[TODO: Translate] dropped {when}", "openCivitai": "[TODO: Translate] Open on CivitAI", "openLocal": "[TODO: Translate] Open", - "unavailable": "[TODO: Translate] {count} paid version(s) have no readable price (mature models can only be read in a browser)" + "unavailable": "[TODO: Translate] {count} paid version(s) have no readable price (mature models can only be read in a browser)", + "emptyZeroThreshold": "[TODO: Translate] {count} paid version(s) have a known price, but your threshold is 0 Buzz so only free versions are listed. Set a threshold in Settings - Library to catch price drops." } }, "support": { diff --git a/locales/ja.json b/locales/ja.json index e537c576..b5e0f170 100644 --- a/locales/ja.json +++ b/locales/ja.json @@ -2411,7 +2411,8 @@ "droppedAgo": "[TODO: Translate] dropped {when}", "openCivitai": "[TODO: Translate] Open on CivitAI", "openLocal": "[TODO: Translate] Open", - "unavailable": "[TODO: Translate] {count} paid version(s) have no readable price (mature models can only be read in a browser)" + "unavailable": "[TODO: Translate] {count} paid version(s) have no readable price (mature models can only be read in a browser)", + "emptyZeroThreshold": "[TODO: Translate] {count} paid version(s) have a known price, but your threshold is 0 Buzz so only free versions are listed. Set a threshold in Settings - Library to catch price drops." } }, "support": { diff --git a/locales/ko.json b/locales/ko.json index d82f271b..6914e74f 100644 --- a/locales/ko.json +++ b/locales/ko.json @@ -2411,7 +2411,8 @@ "droppedAgo": "[TODO: Translate] dropped {when}", "openCivitai": "[TODO: Translate] Open on CivitAI", "openLocal": "[TODO: Translate] Open", - "unavailable": "[TODO: Translate] {count} paid version(s) have no readable price (mature models can only be read in a browser)" + "unavailable": "[TODO: Translate] {count} paid version(s) have no readable price (mature models can only be read in a browser)", + "emptyZeroThreshold": "[TODO: Translate] {count} paid version(s) have a known price, but your threshold is 0 Buzz so only free versions are listed. Set a threshold in Settings - Library to catch price drops." } }, "support": { diff --git a/locales/ru.json b/locales/ru.json index 986e9eba..b7272b03 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -2411,7 +2411,8 @@ "droppedAgo": "[TODO: Translate] dropped {when}", "openCivitai": "[TODO: Translate] Open on CivitAI", "openLocal": "[TODO: Translate] Open", - "unavailable": "[TODO: Translate] {count} paid version(s) have no readable price (mature models can only be read in a browser)" + "unavailable": "[TODO: Translate] {count} paid version(s) have no readable price (mature models can only be read in a browser)", + "emptyZeroThreshold": "[TODO: Translate] {count} paid version(s) have a known price, but your threshold is 0 Buzz so only free versions are listed. Set a threshold in Settings - Library to catch price drops." } }, "support": { diff --git a/locales/zh-CN.json b/locales/zh-CN.json index 9a854ad4..16a0fc46 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -2411,7 +2411,8 @@ "droppedAgo": "[TODO: Translate] dropped {when}", "openCivitai": "[TODO: Translate] Open on CivitAI", "openLocal": "[TODO: Translate] Open", - "unavailable": "[TODO: Translate] {count} paid version(s) have no readable price (mature models can only be read in a browser)" + "unavailable": "[TODO: Translate] {count} paid version(s) have no readable price (mature models can only be read in a browser)", + "emptyZeroThreshold": "[TODO: Translate] {count} paid version(s) have a known price, but your threshold is 0 Buzz so only free versions are listed. Set a threshold in Settings - Library to catch price drops." } }, "support": { diff --git a/locales/zh-TW.json b/locales/zh-TW.json index 0d3162fb..8767b248 100644 --- a/locales/zh-TW.json +++ b/locales/zh-TW.json @@ -2411,7 +2411,8 @@ "droppedAgo": "[TODO: Translate] dropped {when}", "openCivitai": "[TODO: Translate] Open on CivitAI", "openLocal": "[TODO: Translate] Open", - "unavailable": "[TODO: Translate] {count} paid version(s) have no readable price (mature models can only be read in a browser)" + "unavailable": "[TODO: Translate] {count} paid version(s) have no readable price (mature models can only be read in a browser)", + "emptyZeroThreshold": "[TODO: Translate] {count} paid version(s) have a known price, but your threshold is 0 Buzz so only free versions are listed. Set a threshold in Settings - Library to catch price drops." } }, "support": { diff --git a/py/routes/handlers/misc_handlers.py b/py/routes/handlers/misc_handlers.py index d49abbde..9fa82a62 100644 --- a/py/routes/handlers/misc_handlers.py +++ b/py/routes/handlers/misc_handlers.py @@ -4385,6 +4385,7 @@ class PriceAlertsHandler: ) newest_checked_at = update_service.newest_price_checked_at() unavailable_count = update_service.count_unavailable_prices() + priced_count = update_service.count_priced_versions() except Exception as exc: logger.error("Failed to load price alerts: %s", exc, exc_info=True) return web.json_response( @@ -4410,6 +4411,9 @@ class PriceAlertsHandler: # Gated versions whose price could not be read (mature models # are served only by the challenged civitai.red host). "unavailableCount": unavailable_count, + # Lets the empty state explain a 0 Buzz threshold instead of + # claiming there is simply nothing to show. + "pricedCount": priced_count, "alerts": alerts, } ) diff --git a/py/services/model_update_service.py b/py/services/model_update_service.py index 81811a20..2e904535 100644 --- a/py/services/model_update_service.py +++ b/py/services/model_update_service.py @@ -1362,6 +1362,36 @@ class ModelUpdateService: return None return float(row["newest"]) + def count_priced_versions(self, model_type: Optional[str] = None) -> int: + """Versions with a known price, for the panel's empty state. + + Without it, a user whose threshold is 0 sees "nothing is under your price + threshold" while dozens of paid versions already have a price. + """ + + params: List[Any] = [] + type_filter = "" + if model_type: + type_filter = "AND s.model_type = ?" + params.append(model_type) + + with self._connect() as conn: + row = conn.execute( + f""" + SELECT COUNT(*) AS priced + FROM model_update_versions v + JOIN model_update_status s ON s.model_id = v.model_id + WHERE v.should_ignore = 0 + AND s.should_ignore_model = 0 + {type_filter} + AND v.price_buzz IS NOT NULL + """, + tuple(params), + ).fetchone() + if row is None: + return 0 + return int(row["priced"]) + def count_unavailable_prices(self, model_type: Optional[str] = None) -> int: """Gated versions whose price we tried to read and could not. diff --git a/static/js/managers/UpdateService.js b/static/js/managers/UpdateService.js index d82ece99..f6b83b54 100644 --- a/static/js/managers/UpdateService.js +++ b/static/js/managers/UpdateService.js @@ -29,7 +29,8 @@ async function fetchPriceAlerts(limit = 200) { enabled: payload.enabled === true, thresholdBuzz: payload.thresholdBuzz ?? 0, newestCheckedAt: payload.newestCheckedAt ?? null, - unavailableCount: payload.unavailableCount ?? 0 + unavailableCount: payload.unavailableCount ?? 0, + pricedCount: payload.pricedCount ?? 0 }; } @@ -114,6 +115,7 @@ export class UpdateService { this.priceAlertsThreshold = 0; this.priceAlertsNewestCheckedAt = null; this.priceAlertsUnavailableCount = 0; + this.priceAlertsPricedCount = 0; this.priceAlertsLoading = false; this.priceAlertSegment = 'below_threshold'; this.unreadPriceAlertCount = 0; @@ -407,6 +409,7 @@ export class UpdateService { this.priceAlertsThreshold = payload.thresholdBuzz; this.priceAlertsNewestCheckedAt = payload.newestCheckedAt; this.priceAlertsUnavailableCount = payload.unavailableCount || 0; + this.priceAlertsPricedCount = payload.pricedCount || 0; this.refreshUnreadPriceAlertCount(); this.renderPriceAlerts(); return this.priceAlerts; @@ -512,17 +515,7 @@ export class UpdateService { list.innerHTML = ''; if (!alerts.length) { - empty.textContent = this.priceAlertSegment === 'became_free' - ? translate( - 'update.priceAlerts.emptyFree', - {}, - 'No version has become free yet.' - ) - : translate( - 'update.priceAlerts.empty', - {}, - 'Nothing is under your price threshold right now.' - ); + empty.textContent = this.buildPriceAlertsEmptyText(); empty.classList.remove('hidden'); return; } @@ -533,6 +526,33 @@ export class UpdateService { }); } + /** + * The empty state has to explain a 0 Buzz threshold: "nothing is under your + * threshold" is technically true but useless when dozens of paid versions + * already have a known price. + */ + buildPriceAlertsEmptyText() { + if (this.priceAlertSegment === 'became_free') { + return translate( + 'update.priceAlerts.emptyFree', + {}, + 'No version has become free yet.' + ); + } + if (!this.priceAlertsThreshold && this.priceAlertsPricedCount > 0) { + return translate( + 'update.priceAlerts.emptyZeroThreshold', + { count: this.priceAlertsPricedCount }, + `${this.priceAlertsPricedCount} paid version(s) have a known price, but your threshold is 0 Buzz so only free versions are listed. Set a threshold in Settings - Library to catch price drops.` + ); + } + return translate( + 'update.priceAlerts.empty', + {}, + 'Nothing is under your price threshold right now.' + ); + } + buildPriceAlertItem(alert) { const item = document.createElement('li'); item.className = 'price-alert-item'; diff --git a/tests/frontend/managers/updateServicePriceAlerts.test.js b/tests/frontend/managers/updateServicePriceAlerts.test.js index a772dd82..fee8dd36 100644 --- a/tests/frontend/managers/updateServicePriceAlerts.test.js +++ b/tests/frontend/managers/updateServicePriceAlerts.test.js @@ -218,6 +218,36 @@ describe('UpdateService price alerts panel', () => { expect(note.textContent).toContain('3 paid version(s) have no readable price'); }); + it('explains a 0 Buzz threshold instead of claiming there is nothing', async () => { + // The exact confusion a real user hit: 52 priced versions, threshold 0, + // and an empty panel saying "nothing is under your threshold". + global.fetch = vi.fn().mockResolvedValue( + createFetchResponse( + alertPayload([], { thresholdBuzz: 0, pricedCount: 52 }) + ) + ); + + await service.loadPriceAlerts({ force: true }); + + const empty = document.getElementById('priceAlertsEmpty'); + expect(empty.classList.contains('hidden')).toBe(false); + expect(empty.textContent).toContain('52 paid version(s) have a known price'); + expect(empty.textContent).toContain('threshold is 0 Buzz'); + }); + + it('keeps the plain empty message when a real threshold is set', async () => { + global.fetch = vi.fn().mockResolvedValue( + createFetchResponse( + alertPayload([], { thresholdBuzz: 500, pricedCount: 52 }) + ) + ); + + await service.loadPriceAlerts({ force: true }); + + const empty = document.getElementById('priceAlertsEmpty'); + expect(empty.textContent).toBe('Nothing is under your price threshold right now.'); + }); + it('keeps the last known list when the request fails', async () => { global.fetch = vi .fn() diff --git a/tests/routes/test_misc_routes.py b/tests/routes/test_misc_routes.py index 8719cf23..50d4178a 100644 --- a/tests/routes/test_misc_routes.py +++ b/tests/routes/test_misc_routes.py @@ -2950,10 +2950,11 @@ def _price_alerts_adapter(update_service, scanners=None): class _FakeUpdateService: - def __init__(self, alerts, *, unavailable_count=0): + def __init__(self, alerts, *, unavailable_count=0, priced_count=0): self.alerts = alerts self.calls = [] self.unavailable_count = unavailable_count + self.priced_count = priced_count async def get_price_alerts(self, model_type=None, *, threshold_buzz=None, limit=200): self.calls.append((model_type, threshold_buzz, limit)) @@ -2965,6 +2966,9 @@ class _FakeUpdateService: def count_unavailable_prices(self, model_type=None): return self.unavailable_count + def count_priced_versions(self, model_type=None): + return self.priced_count + @pytest.mark.asyncio async def test_price_alerts_handler_returns_the_global_list(): @@ -3002,6 +3006,7 @@ async def test_price_alerts_handler_returns_the_global_list(): assert payload["thresholdBuzz"] == 300 assert payload["newestCheckedAt"] == 1791039694.5 assert payload["unavailableCount"] == 0 + assert payload["pricedCount"] == 0 assert payload["alerts"][0]["civitaiUrl"] == ( "https://civitai.com/models/2981320?modelVersionId=3379626" ) diff --git a/tests/services/test_model_update_service.py b/tests/services/test_model_update_service.py index 6842be57..37e30b3f 100644 --- a/tests/services/test_model_update_service.py +++ b/tests/services/test_model_update_service.py @@ -2023,6 +2023,20 @@ async def test_failed_price_attempt_is_not_retried_within_the_ttl(tmp_path): assert service.count_unavailable_prices("lora") == 1 +@pytest.mark.asyncio +async def test_priced_count_reports_known_prices(tmp_path): + service = _price_service(tmp_path, price_tracking_enabled=True) + scanner = DummyScanner(LOCAL_RAW_DATA) + provider = PriceProvider(GATED_RESPONSE, prices=PRICE_PAYLOAD) + + assert service.count_priced_versions("lora") == 0 + + await service.refresh_for_model_type("lora", scanner, provider) + + assert service.count_priced_versions("lora") == 1 + assert service.count_priced_versions("checkpoint") == 0 + + @pytest.mark.asyncio async def test_forced_refresh_reprices_within_the_ttl(tmp_path): service = _long_ttl_service(tmp_path, price_tracking_enabled=True)