feat(update): add a Buzz price alerts panel to the notification bell

P5a of docs/plans/paid-model-price-tracking.md: one surface that answers "what
got cheaper / became free", without a permanent button (the grid filter was
dropped by owner decision, so the panel carries the actions itself).

- price_alert_since records when an alert started, so the panel can say
  "dropped 3 d ago" and count what is new since the user last looked; it is set
  on the first sight of an already-cheap version, preserved while the alert
  stands, and cleared when the price rises back above the threshold
- get_price_alerts() compares the threshold at read time (editing it takes
  effect immediately, no refresh needed) and returns both kinds in one list;
  model_type=None covers every type, which the shared update DB makes a single
  query. "became free" needs no price data, so it is reported even while price
  tracking is off
- GET /api/lm/price-alerts, registered once in MiscRoutes rather than per model
  type, decorating rows best-effort with the local model name and file path from
  the scanner indexes (a cold cache just omits them)
- a third tab in the notification bell: segments for under-threshold and
  became-free, the three states (tracking off / nothing matching / stale), and
  per-row actions (CivitAI always, Open when the model is local)
- two non-permanent entry points share one helper: the controls-bar updates
  dropdown and the global context menu, whose label carries the unread count
- unread state stays client-side (localStorage watermark); the count is fetched
  once on init and only when price tracking is enabled
- the per-type frontend client method is removed as dead code; the per-type
  backend route stays for the companion extension
This commit is contained in:
Will Miao
2026-10-04 15:08:48 +08:00
parent ad2402724b
commit 7ed19c185c
27 changed files with 2142 additions and 86 deletions
+411
View File
@@ -9,6 +9,77 @@ import {
import { state } from '../state/index.js';
import { bannerService } from './BannerService.js';
import { translate } from '../utils/i18nHelpers.js';
import { showModelModal } from '../components/shared/ModelModal.js';
/** Global endpoint: the alerts panel spans every model type. */
const PRICE_ALERTS_ENDPOINT = '/api/lm/price-alerts';
/** localStorage watermark of the newest alert the user has already seen. */
const PRICE_ALERTS_VIEWED_KEY = 'lm_price_alerts_viewed_at';
async function fetchPriceAlerts(limit = 200) {
const response = await fetch(
`${PRICE_ALERTS_ENDPOINT}?limit=${encodeURIComponent(limit)}`
);
const payload = await response.json().catch(() => ({}));
if (!response.ok || payload?.success !== true) {
throw new Error(payload?.error || response.statusText || 'Failed to load price alerts');
}
return {
alerts: Array.isArray(payload.alerts) ? payload.alerts : [],
enabled: payload.enabled === true,
thresholdBuzz: payload.thresholdBuzz ?? 0,
newestCheckedAt: payload.newestCheckedAt ?? null
};
}
/** POSIX seconds of the moment an alert started, for unread/sorting comparisons. */
function priceAlertTimestamp(alert) {
if (typeof alert?.priceAlertSince === 'number') {
return alert.priceAlertSince;
}
if (alert?.gateLapsedAt) {
const parsed = Date.parse(alert.gateLapsedAt);
if (!Number.isNaN(parsed)) {
return parsed / 1000;
}
}
return 0;
}
function formatRelativeTime(timestampSeconds) {
if (!timestampSeconds) return '';
const elapsedMs = Date.now() - timestampSeconds * 1000;
const minutes = Math.floor(elapsedMs / 60000);
if (minutes < 1) return translate('update.priceAlerts.justNow', {}, 'just now');
if (minutes < 60) {
return translate('update.priceAlerts.minutesAgo', { count: minutes }, `${minutes} min ago`);
}
const hours = Math.floor(minutes / 60);
if (hours < 24) {
return translate('update.priceAlerts.hoursAgo', { count: hours }, `${hours} h ago`);
}
const days = Math.floor(hours / 24);
return translate('update.priceAlerts.daysAgo', { count: days }, `${days} d ago`);
}
function formatShortDate(value) {
if (!value) return '';
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return '';
return parsed.toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric'
});
}
/**
* Open the notification bell on the Buzz price alerts tab.
* Shared by the controls dropdown item and the global context menu item.
*/
export function openPriceAlertsPanel() {
updateService.openPriceAlertsTab();
}
export class UpdateService {
constructor() {
@@ -35,6 +106,16 @@ export class UpdateService {
this.activeNotificationTab = 'updates';
this.handleBannerHistoryUpdated = this.handleBannerHistoryUpdated.bind(this);
this.handleNotificationTabKeydown = this.handleNotificationTabKeydown.bind(this);
// Buzz price alerts (panel data, loaded on demand; the count is cached so
// the bell badge and the context-menu label can share it).
this.priceAlerts = null;
this.priceAlertsEnabled = false;
this.priceAlertsThreshold = 0;
this.priceAlertsNewestCheckedAt = null;
this.priceAlertsLoading = false;
this.priceAlertSegment = 'below_threshold';
this.unreadPriceAlertCount = 0;
this.handlePriceAlertSegmentClick = this.handlePriceAlertSegmentClick.bind(this);
}
initialize() {
@@ -250,7 +331,321 @@ export class UpdateService {
});
this.renderRecentBanners();
this.wirePriceAlertsPanel();
this.switchNotificationTab(this.activeNotificationTab);
// One request, and only for users who turned price tracking on. It feeds
// the tab badge and the global-context-menu count.
if (state?.global?.settings?.price_tracking_enabled) {
this.loadPriceAlerts().catch((error) => {
console.warn('Failed to preload price alerts:', error);
});
}
}
wirePriceAlertsPanel() {
const modal = document.getElementById('updateModal');
if (!modal) return;
this.priceAlertSegments = Array.from(
modal.querySelectorAll('[data-price-alert-segment]')
);
this.priceAlertSegments.forEach((segment) => {
segment.addEventListener('click', this.handlePriceAlertSegmentClick);
});
const settingsBtn = document.getElementById('priceAlertsSettingsBtn');
if (settingsBtn) {
settingsBtn.addEventListener('click', () => {
this.toggleUpdateModal();
if (window.settingsManager) {
window.settingsManager.toggleSettings();
}
});
}
}
handlePriceAlertSegmentClick(event) {
const segment = event?.currentTarget?.getAttribute('data-price-alert-segment');
if (!segment) return;
this.priceAlertSegment = segment;
this.renderPriceAlerts();
}
/**
* Open the bell on the price alerts tab.
*
* ``toggleUpdateModal()`` *closes* an open bell, so an entry point that called
* it unconditionally would dismiss the modal instead of switching to the tab.
*/
openPriceAlertsTab() {
const modal = modalManager.getModal('updateModal');
if (!modal || !modal.isOpen) {
this.toggleUpdateModal();
}
this.switchNotificationTab('priceAlerts', { markRead: true });
}
/**
* Load the global alerts payload (all model types in one query).
* @param {{force?: boolean}} [options]
*/
async loadPriceAlerts({ force = false } = {}) {
if (this.priceAlertsLoading) return this.priceAlerts;
if (!force && this.priceAlerts) {
this.renderPriceAlerts();
return this.priceAlerts;
}
this.priceAlertsLoading = true;
try {
const payload = await fetchPriceAlerts();
this.priceAlerts = payload.alerts;
this.priceAlertsEnabled = payload.enabled;
this.priceAlertsThreshold = payload.thresholdBuzz;
this.priceAlertsNewestCheckedAt = payload.newestCheckedAt;
this.refreshUnreadPriceAlertCount();
this.renderPriceAlerts();
return this.priceAlerts;
} catch (error) {
console.error('Failed to load price alerts:', error);
// Keep whatever we already had: a stale list beats an empty panel.
this.renderPriceAlerts({ error: true });
return this.priceAlerts;
} finally {
this.priceAlertsLoading = false;
}
}
/** Alerts newer than the last time the user opened the panel. */
refreshUnreadPriceAlertCount() {
const alerts = Array.isArray(this.priceAlerts) ? this.priceAlerts : [];
const viewedAt = Number(getStorageItem(PRICE_ALERTS_VIEWED_KEY, 0)) || 0;
this.unreadPriceAlertCount = alerts.filter(
(alert) => priceAlertTimestamp(alert) > viewedAt
).length;
this.updateTabBadges();
return this.unreadPriceAlertCount;
}
getUnreadPriceAlertCount() {
return this.unreadPriceAlertCount || 0;
}
markPriceAlertsViewed() {
const alerts = Array.isArray(this.priceAlerts) ? this.priceAlerts : [];
const newest = alerts.reduce(
(latest, alert) => Math.max(latest, priceAlertTimestamp(alert)),
0
);
if (newest > 0) {
setStorageItem(PRICE_ALERTS_VIEWED_KEY, newest);
}
this.unreadPriceAlertCount = 0;
this.updateTabBadges();
}
renderPriceAlerts({ error = false } = {}) {
const list = document.getElementById('priceAlertsList');
const empty = document.getElementById('priceAlertsEmpty');
const disabled = document.getElementById('priceAlertsDisabled');
const stale = document.getElementById('priceAlertsStale');
const thresholdLabel = document.getElementById('priceAlertsThreshold');
if (!list || !empty) return;
if (disabled) {
disabled.classList.toggle('hidden', this.priceAlertsEnabled !== false);
}
if (thresholdLabel) {
thresholdLabel.textContent = this.priceAlertsEnabled
? translate(
'update.priceAlerts.thresholdLabel',
{ buzz: this.priceAlertsThreshold },
`Alert threshold: ${this.priceAlertsThreshold} Buzz`
)
: '';
}
if (stale) {
const checkedAt = this.priceAlertsNewestCheckedAt;
const staleText = error
? translate(
'update.priceAlerts.loadFailed',
{},
'Could not refresh the alerts; showing the last known list.'
)
: checkedAt
? translate(
'update.priceAlerts.lastChecked',
{ when: formatRelativeTime(checkedAt) },
`Prices last checked ${formatRelativeTime(checkedAt)}`
)
: '';
stale.textContent = staleText;
stale.classList.toggle('hidden', !staleText);
}
if (Array.isArray(this.priceAlertSegments)) {
this.priceAlertSegments.forEach((segment) => {
const isActive =
segment.getAttribute('data-price-alert-segment') ===
this.priceAlertSegment;
segment.classList.toggle('active', isActive);
segment.setAttribute('aria-selected', isActive ? 'true' : 'false');
});
}
const alerts = (Array.isArray(this.priceAlerts) ? this.priceAlerts : []).filter(
(alert) => (alert.kind || 'below_threshold') === this.priceAlertSegment
);
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.classList.remove('hidden');
return;
}
empty.classList.add('hidden');
alerts.forEach((alert) => {
list.appendChild(this.buildPriceAlertItem(alert));
});
}
buildPriceAlertItem(alert) {
const item = document.createElement('li');
item.className = 'price-alert-item';
const head = document.createElement('div');
head.className = 'price-alert-head';
const name = document.createElement('span');
name.className = 'price-alert-model';
const modelLabel = alert.modelName || alert.versionName || `#${alert.modelId}`;
name.textContent = alert.versionName
? `${modelLabel} · ${alert.versionName}`
: modelLabel;
head.appendChild(name);
if (alert.kind === 'below_threshold') {
const price = document.createElement('span');
price.className = 'price-alert-price is-alert';
price.textContent = `${Number(alert.priceBuzz).toLocaleString()} Buzz`;
if (
typeof alert.listPriceBuzz === 'number' &&
alert.listPriceBuzz > alert.priceBuzz
) {
const was = document.createElement('span');
was.className = 'price-alert-was';
was.textContent = `${alert.listPriceBuzz.toLocaleString()} Buzz`;
price.appendChild(was);
}
head.appendChild(price);
} else {
const free = document.createElement('span');
free.className = 'price-alert-price is-alert';
free.textContent = translate('update.priceAlerts.freeNow', {}, 'Free now');
head.appendChild(free);
}
item.appendChild(head);
const meta = document.createElement('div');
meta.className = 'price-alert-meta';
const metaParts = [alert.modelType];
if (alert.acceptsBlueBuzz) {
metaParts.push(translate('update.priceAlerts.blueBuzz', {}, 'Blue Buzz OK'));
}
if (alert.earlyAccessEndsAt) {
metaParts.push(
translate(
'update.priceAlerts.earlyAccessUntil',
{ date: formatShortDate(alert.earlyAccessEndsAt) },
`Early access until ${formatShortDate(alert.earlyAccessEndsAt)}`
)
);
}
metaParts.push(
alert.isInLibrary
? translate('update.priceAlerts.inLibrary', {}, 'In library')
: translate('update.priceAlerts.notInLibrary', {}, 'Not in library')
);
const since = priceAlertTimestamp(alert);
if (since > 0) {
metaParts.push(
translate(
'update.priceAlerts.droppedAgo',
{ when: formatRelativeTime(since) },
`dropped ${formatRelativeTime(since)}`
)
);
}
meta.textContent = metaParts.filter(Boolean).join(' · ');
item.appendChild(meta);
const actions = document.createElement('div');
actions.className = 'price-alert-actions';
if (alert.civitaiUrl) {
actions.appendChild(
this.buildPriceAlertAction(
'civitai',
translate('update.priceAlerts.openCivitai', {}, 'Open on CivitAI'),
'fa-external-link-alt',
() => window.open(alert.civitaiUrl, '_blank', 'noopener')
)
);
}
// Only possible for a model that is actually in the library: the modal
// reads the local metadata by file path.
if (alert.filePath) {
actions.appendChild(
this.buildPriceAlertAction(
'open',
translate('update.priceAlerts.openLocal', {}, 'Open'),
'fa-folder-open',
() => {
showModelModal(
{
model_name: alert.modelName || alert.versionName,
file_path: alert.filePath,
civitai: {},
},
alert.modelType
);
}
)
);
}
item.appendChild(actions);
return item;
}
buildPriceAlertAction(name, label, icon, onClick) {
const button = document.createElement('button');
button.type = 'button';
button.className = 'price-alert-action';
button.dataset.priceAlertAction = name;
const iconElement = document.createElement('i');
iconElement.className = `fas ${icon}`;
button.appendChild(iconElement);
const text = document.createElement('span');
text.textContent = label;
button.appendChild(text);
button.addEventListener('click', onClick);
return button;
}
switchNotificationTab(tabName, { markRead = false } = {}) {
@@ -283,6 +678,14 @@ export class UpdateService {
}
}
if (tabName === 'priceAlerts') {
this.loadPriceAlerts().then(() => {
if (markRead) {
this.markPriceAlertsViewed();
}
});
}
this.updateTabBadges();
}
@@ -343,10 +746,12 @@ export class UpdateService {
updateTabBadges() {
const updatesBadge = document.getElementById('updatesTabBadge');
const bannerBadge = document.getElementById('bannerTabBadge');
const priceAlertsBadge = document.getElementById('priceAlertsTabBadge');
const hasUpdate = this.updateNotificationsEnabled && this.updateAvailable;
const unreadBanners = typeof bannerService.getUnreadBannerCount === 'function'
? bannerService.getUnreadBannerCount()
: 0;
const unreadAlerts = this.getUnreadPriceAlertCount();
if (updatesBadge) {
updatesBadge.classList.toggle('visible', hasUpdate);
@@ -354,6 +759,12 @@ export class UpdateService {
updatesBadge.textContent = '';
}
if (priceAlertsBadge) {
priceAlertsBadge.textContent = unreadAlerts > 9 ? '9+' : unreadAlerts ? String(unreadAlerts) : '';
priceAlertsBadge.classList.toggle('visible', unreadAlerts > 0);
priceAlertsBadge.classList.remove('is-dot');
}
if (bannerBadge) {
if (unreadBanners > 0) {
bannerBadge.textContent = unreadBanners > 9 ? '9+' : unreadBanners.toString();