From 4c647ad9c853111792a1362a29f50906b8febafe Mon Sep 17 00:00:00 2001 From: Will Miao Date: Fri, 31 Jul 2026 21:18:58 +0800 Subject: [PATCH] fix(update): throttle nightly update badge to once per day --- static/js/managers/UpdateService.js | 36 +++++- tests/frontend/managers/updateService.test.js | 103 ++++++++++++++++++ 2 files changed, 136 insertions(+), 3 deletions(-) diff --git a/static/js/managers/UpdateService.js b/static/js/managers/UpdateService.js index 3f5fdd0b..1ddcdade 100644 --- a/static/js/managers/UpdateService.js +++ b/static/js/managers/UpdateService.js @@ -27,6 +27,8 @@ export class UpdateService { this.isUpdating = false; this.channelMode = null; this.hasGit = false; + this.nightlyNotifyDate = getStorageItem('nightly_notify_date', ''); + this.nightlyBadgeShown = false; this.progressKeepVisible = false; this.currentVersionInfo = null; this.versionMismatch = false; @@ -552,6 +554,11 @@ export class UpdateService { this.updateAvailable = data.update_available; + // Nightly channel: surface the update badge at most once per calendar day. + if (this.updateAvailable && this.channelMode === 'nightly' && this.nightlyNotifyDate !== this._getTodayKey()) { + this._markNightlyNotified(); + } + this.lastCheckTime = now; setStorageItem('last_update_check', now.toString()); @@ -601,6 +608,28 @@ export class UpdateService { return false; } + + _getTodayKey() { + const now = new Date(); + const month = String(now.getMonth() + 1).padStart(2, '0'); + const day = String(now.getDate()).padStart(2, '0'); + return `${now.getFullYear()}-${month}-${day}`; + } + + _isNightlyBadgeAllowed() { + if (this.channelMode !== 'nightly') { + return true; + } + // Keep the badge visible for the rest of the session once shown, but do + // not show it again on later sessions within the same calendar day. + return this.nightlyNotifyDate !== this._getTodayKey() || this.nightlyBadgeShown; + } + + _markNightlyNotified() { + this.nightlyNotifyDate = this._getTodayKey(); + this.nightlyBadgeShown = true; + setStorageItem('nightly_notify_date', this.nightlyNotifyDate); + } updateBadgeVisibility() { const updateToggle = document.querySelector('.update-toggle'); @@ -609,9 +638,12 @@ export class UpdateService { ? bannerService.getUnreadBannerCount() : 0; + // Force updating badges visibility based on current state + const shouldShowUpdate = this.updateNotificationsEnabled && this.updateAvailable && this._isNightlyBadgeAllowed(); + if (updateToggle) { let tooltipKey = 'header.actions.notifications'; - if (this.updateNotificationsEnabled && this.updateAvailable) { + if (shouldShowUpdate) { tooltipKey = 'update.updateAvailable'; } else if (unreadBanners > 0) { tooltipKey = 'update.tabs.messages'; @@ -619,8 +651,6 @@ export class UpdateService { updateToggle.title = translate(tooltipKey); } - // Force updating badges visibility based on current state - const shouldShowUpdate = this.updateNotificationsEnabled && this.updateAvailable; const shouldShow = shouldShowUpdate || unreadBanners > 0; if (updateBadge) { diff --git a/tests/frontend/managers/updateService.test.js b/tests/frontend/managers/updateService.test.js index 00092457..f39d5c52 100644 --- a/tests/frontend/managers/updateService.test.js +++ b/tests/frontend/managers/updateService.test.js @@ -61,3 +61,106 @@ describe('UpdateService passive checks', () => { expect(fetchMock).toHaveBeenCalledWith('/api/lm/check-updates?nightly=false'); }); }); + +describe('UpdateService nightly notification throttling', () => { + let fetchMock; + let updateToggle; + let updateBadge; + + function stubUpdateBadgeDom() { + updateToggle = document.createElement('div'); + updateToggle.className = 'update-toggle'; + updateBadge = document.createElement('span'); + updateBadge.className = 'update-badge'; + updateToggle.appendChild(updateBadge); + document.body.appendChild(updateToggle); + + vi.spyOn(document, 'querySelector').mockImplementation((selector) => { + if (selector === '.update-toggle') return updateToggle; + if (selector === '.update-toggle .update-badge') return updateBadge; + return null; + }); + } + + function makeUpdateResponse(channel) { + return { + success: true, + current_version: 'v1.0.0', + latest_version: channel === 'nightly' ? 'main-abc1234' : 'v1.1.0', + update_available: true, + git_info: { short_hash: 'abc123' }, + has_git: true, + nightly: channel === 'nightly', + changelog: ['test: change'], + releases: [], + behind_by: 3, + commit_date: '2026-07-31', + }; + } + + beforeEach(() => { + fetchMock = vi.fn().mockResolvedValue(createFetchResponse(makeUpdateResponse('release'))); + global.fetch = fetchMock; + stubUpdateBadgeDom(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + delete global.fetch; + }); + + it('shows the nightly badge once and keeps it visible for the session', async () => { + stubSettingsUpdateChannel('nightly'); + fetchMock.mockResolvedValue(createFetchResponse(makeUpdateResponse('nightly'))); + + const service = new UpdateService(); + service.updateNotificationsEnabled = true; + + await service.checkForUpdates({ force: true }); + + expect(service.updateAvailable).toBe(true); + expect(service.nightlyBadgeShown).toBe(true); + expect(service.nightlyNotifyDate).toBe(service._getTodayKey()); + expect(updateBadge.classList.contains('visible')).toBe(true); + + // A repeated check within the same session keeps the badge visible. + await service.checkForUpdates({ force: true }); + expect(updateBadge.classList.contains('visible')).toBe(true); + }); + + it('suppresses the nightly badge on a later session in the same day', async () => { + stubSettingsUpdateChannel('nightly'); + fetchMock.mockResolvedValue(createFetchResponse(makeUpdateResponse('nightly'))); + + const firstService = new UpdateService(); + firstService.updateNotificationsEnabled = true; + await firstService.checkForUpdates({ force: true }); + expect(updateBadge.classList.contains('visible')).toBe(true); + + // Simulate a fresh page session on the same calendar day. + const secondService = new UpdateService(); + secondService.updateNotificationsEnabled = true; + await secondService.checkForUpdates({ force: true }); + + expect(secondService.updateAvailable).toBe(true); + expect(secondService.nightlyBadgeShown).toBe(false); + expect(updateBadge.classList.contains('visible')).toBe(false); + }); + + it('is not affected by the daily limit on the release channel', async () => { + stubSettingsUpdateChannel('release'); + fetchMock.mockResolvedValue(createFetchResponse(makeUpdateResponse('release'))); + + const firstService = new UpdateService(); + firstService.updateNotificationsEnabled = true; + await firstService.checkForUpdates({ force: true }); + expect(updateBadge.classList.contains('visible')).toBe(true); + + const secondService = new UpdateService(); + secondService.updateNotificationsEnabled = true; + await secondService.checkForUpdates({ force: true }); + + expect(secondService.updateAvailable).toBe(true); + expect(updateBadge.classList.contains('visible')).toBe(true); + }); +});