From a8a4d57cd8ea9ea002aa008027d6f00ef6e62a5f Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Fri, 30 Oct 2026 14:02:57 +0700 Subject: [PATCH 01/22] fix: prevent Arcade bonus score double counting --- components/BonusMilestoneControl.tsx | 48 +--------------------------- 1 file changed, 1 insertion(+), 47 deletions(-) diff --git a/components/BonusMilestoneControl.tsx b/components/BonusMilestoneControl.tsx index 8e97cb1d..3b3f26a0 100644 --- a/components/BonusMilestoneControl.tsx +++ b/components/BonusMilestoneControl.tsx @@ -54,46 +54,7 @@ function renderedManualBonus(state: BonusMilestoneControlState): number { return state.appliedPoints > 0 ? state.appliedPoints : CLAIM_BONUS_POINTS; } -/** Parse a rendered point total into a safe numeric value. */ -function parseDisplayedTotal(value: string | null | undefined): number { - if (!value) return 0; - const normalized = value.replace(/\s/gu, "").replace(/,/gu, ""); - const parsed = Number(normalized); - return Number.isFinite(parsed) ? parsed : 0; -} - -/** Add only the self-reported bonus to the main total, without double counting it. */ -function syncDisplayedTotal(state: BonusMilestoneControlState): void { - const total = document.getElementById("arcade-points"); - if (!total) return; - - if (total.dataset.bonusMilestoneProfile !== state.profileUrl) { - delete total.dataset.bonusMilestoneApplied; - delete total.dataset.bonusMilestoneRenderedTotal; - total.dataset.bonusMilestoneProfile = state.profileUrl; - } - - const currentTotal = parseDisplayedTotal(total.textContent); - const previousApplied = - Number(total.dataset.bonusMilestoneApplied || "0") || 0; - const previousRendered = Number( - total.dataset.bonusMilestoneRenderedTotal || "NaN", - ); - const stillContainsPreviousBonus = - Number.isFinite(previousRendered) && currentTotal === previousRendered; - const baseTotal = stillContainsPreviousBonus - ? Math.max(0, currentTotal - previousApplied) - : currentTotal; - const nextApplied = state.bonusIncludedInTotal - ? 0 - : renderedManualBonus(state); - const nextTotal = baseTotal + nextApplied; - - total.textContent = formatPoints(nextTotal); - total.dataset.bonusMilestoneApplied = String(nextApplied); - total.dataset.bonusMilestoneRenderedTotal = String(nextTotal); -} - +/** The signed Arcade refresh owns the total; the claim chip never edits score DOM. */ /** Render the self-report disclaimer and the official task link before confirmation. */ function BonusMilestoneInfo({ completed }: BonusMilestoneInfoProps) { return ( @@ -147,13 +108,6 @@ function BonusMilestoneControl() { [reload], ); - useEffect( - function keepMainTotalInSync() { - syncDisplayedTotal(state); - }, - [state], - ); - const manualBonus = renderedManualBonus(state); const canConfirm = state.participating && state.enabled && state.points > 0; From 67e6a408f1a300652b10e0f9f428a4f8b2cb2c67 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Fri, 30 Oct 2026 14:03:00 +0700 Subject: [PATCH 02/22] fix: prevent Arcade bonus score double counting --- services/storageService.ts | 27 +++++++++------------------ 1 file changed, 9 insertions(+), 18 deletions(-) diff --git a/services/storageService.ts b/services/storageService.ts index e36645db..c1b7ffcd 100644 --- a/services/storageService.ts +++ b/services/storageService.ts @@ -58,18 +58,13 @@ async function saveArcadeData(data: ArcadeData): Promise { updatedData.totalArcadePoints || 0; const facilitatorBonus = - includeFaci && updatedData.faciCounts + includeFaci && updatedData.facilitator?.bonusIncludedInTotal !== true && updatedData.faciCounts ? calculateFacilitatorBonus(updatedData.faciCounts) : 0; const finalTotalPoints = basePoints + facilitatorBonus; - // Persist the computed final total into both shapes the app uses - updatedData.arcadePoints = { - ...(updatedData.arcadePoints || {}), - totalPoints: finalTotalPoints, - }; - updatedData.totalArcadePoints = finalTotalPoints; - + // Preserve the API snapshot. The derived toolbar score must not replace + // arcadePoints.totalPoints: popup rendering would add the bonus again. await AccountService.updateAccountArcadeData(activeAccount.id, updatedData); // Update extension badge to show total points for the active account try { @@ -86,18 +81,14 @@ async function saveArcadeData(data: ArcadeData): Promise { // For legacy storage we don't have account flags. Do a best-effort: include facilitator if faciCounts exists. const legacyBasePoints = updatedData.arcadePoints?.totalPoints || updatedData.totalArcadePoints || 0; - const legacyFaciBonus = updatedData.faciCounts - ? calculateFacilitatorBonus(updatedData.faciCounts) + const legacyFaciBonus = + updatedData.facilitator?.bonusIncludedInTotal !== true && updatedData.faciCounts + ? calculateFacilitatorBonus(updatedData.faciCounts) : 0; const legacyFinalTotal = legacyBasePoints + legacyFaciBonus; - // Persist final total into fields as well - updatedData.arcadePoints = { - ...(updatedData.arcadePoints || {}), - totalPoints: legacyFinalTotal, - }; - updatedData.totalArcadePoints = legacyFinalTotal; - + // Keep the legacy snapshot as received so reopening the popup does not + // compound previously applied Facilitator or Bonus Milestone points. await storage.setItem(STORAGE_KEYS.arcadeData, updatedData); // Also update extension badge for legacy storage try { @@ -228,7 +219,7 @@ async function refreshBadgeForActiveAccount(): Promise { const arcade = active.arcadeData; const basePoints = arcade?.arcadePoints?.totalPoints || arcade?.totalArcadePoints || 0; - const faciBonus = active.facilitatorProgram + const faciBonus = active.facilitatorProgram && arcade?.facilitator?.bonusIncludedInTotal !== true ? calculateFacilitatorBonus(arcade?.faciCounts) : 0; const finalPoints = (Number(basePoints) || 0) + (Number(faciBonus) || 0); From c5a007a6f4fa7379b1641ef410d093c406072a7d Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Fri, 30 Oct 2026 14:03:04 +0700 Subject: [PATCH 03/22] fix: prevent Arcade bonus score double counting --- services/popupUIService.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/services/popupUIService.ts b/services/popupUIService.ts index b44feaa5..317a4502 100644 --- a/services/popupUIService.ts +++ b/services/popupUIService.ts @@ -744,7 +744,9 @@ const PopupUIService = { // 1. Account has facilitatorProgram enabled (includeFacilitator = true) AND // 2. Firebase config allows it globally (facilitatorGloballyEnabled = true) const shouldCalculateBonus = - includeFacilitator && facilitatorGloballyEnabled; + includeFacilitator && + facilitatorGloballyEnabled && + data.facilitator?.bonusIncludedInTotal !== true; const facilitatorBonus = shouldCalculateBonus && faciCounts ? calculateFacilitatorBonus(faciCounts) From 73752e5d5ed789be52fbb4b223128989ee6a49b6 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Fri, 30 Oct 2026 14:03:07 +0700 Subject: [PATCH 04/22] fix: prevent Arcade bonus score double counting --- services/optionsService.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/optionsService.ts b/services/optionsService.ts index 3b631167..b539fa94 100644 --- a/services/optionsService.ts +++ b/services/optionsService.ts @@ -131,7 +131,7 @@ const OptionsService = { const nickname = account.nickname; const arcadePoints = account.arcadeData?.arcadePoints?.totalPoints || 0; // If facilitator program is enabled, compute facilitator bonus and add to total - const facilitatorBonus = account.facilitatorProgram + const facilitatorBonus = account.facilitatorProgram && account.arcadeData?.facilitator?.bonusIncludedInTotal !== true ? calculateFacilitatorBonus(account.arcadeData?.faciCounts) : 0; const finalPoints = (arcadePoints || 0) + (facilitatorBonus || 0); From 818cd68f7259af3bac956f620289ce7481516088 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Fri, 30 Oct 2026 14:03:10 +0700 Subject: [PATCH 05/22] fix: prevent Arcade bonus score double counting --- tests/services/bonusMilestoneClaimUi.test.ts | 33 ++++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/tests/services/bonusMilestoneClaimUi.test.ts b/tests/services/bonusMilestoneClaimUi.test.ts index 18597a15..d4a41eec 100644 --- a/tests/services/bonusMilestoneClaimUi.test.ts +++ b/tests/services/bonusMilestoneClaimUi.test.ts @@ -158,10 +158,11 @@ describe("Bonus Milestone claim UI", () => { expect( host?.querySelector('[role="tooltip"]')?.textContent, ).toBe(browserMessages.bonusMilestoneAppliedTooltip); - expect(document.getElementById("arcade-points")?.textContent).toBe("128"); + // The chip cannot mutate the API-owned score independently of avatar/tier. + expect(document.getElementById("arcade-points")?.textContent).toBe("118"); // Return a stale appliedPoints value on purpose: completed=false must be - // authoritative, so the UI removes +10 immediately instead of keeping 128. + // authoritative, without ever modifying the API-owned total. serviceMocks.setActiveBonusMilestoneCompleted.mockResolvedValue({ completed: false, enabled: true, @@ -190,4 +191,32 @@ describe("Bonus Milestone claim UI", () => { expect(claimButton?.textContent).toBe("Claim +10"); expect(document.getElementById("arcade-points")?.textContent).toBe("118"); }); + + it("does not turn an API-confirmed 130 point snapshot into 140", async () => { + const profileUrl = "https://www.skills.google/public_profiles/11111111-1111-4111-8111-111111111111"; + serviceMocks.getBonusMilestoneControlState.mockResolvedValue({ + completed: true, + enabled: true, + participating: true, + points: 10, + appliedPoints: 10, + milestoneBonusPoints: 0, + bonusIncludedInTotal: true, + profileUrl, + }); + serviceMocks.watchBonusMilestoneControlState.mockImplementation(() => () => undefined); + document.body.innerHTML = \` + 130 + 130 +
+ \`; + await act(async () => { + mountBonusMilestoneControl(); + await Promise.resolve(); + await Promise.resolve(); + }); + expect(document.getElementById("arcade-points")?.textContent).toBe("130"); + expect(document.getElementById("avatar-score-badge")?.textContent).toBe("130"); + expect(document.getElementById("arcade-facilitator-points")?.textContent).toContain("✓ +10"); + }); }); From 4d7f770a3d71ece2bd827630e6e827f209165e75 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Fri, 30 Oct 2026 14:03:13 +0700 Subject: [PATCH 06/22] fix: prevent Arcade bonus score double counting --- .../services/storageService.extended.test.ts | 34 +++++++++++++++++-- 1 file changed, 31 insertions(+), 3 deletions(-) diff --git a/tests/services/storageService.extended.test.ts b/tests/services/storageService.extended.test.ts index 5862170c..6b057dad 100644 --- a/tests/services/storageService.extended.test.ts +++ b/tests/services/storageService.extended.test.ts @@ -75,6 +75,36 @@ describe("StorageService.saveArcadeData - active account path", () => { ); }); + it("preserves the API's 130 points instead of persisting a doubled +10", async () => { + vi.mocked(AccountService.getActiveAccount).mockResolvedValue({ + id: "acc1", + name: "Test", + profileUrl: "https://www.skills.google/public_profiles/abc", + createdAt: new Date().toISOString(), + lastUsed: new Date().toISOString(), + facilitatorProgram: true, + }); + vi.mocked(AccountService.getSettings).mockResolvedValue({ + enableSearchFeature: true, + showBadge: false, + }); + + await StorageService.saveArcadeData({ + arcadePoints: { totalPoints: 130 }, + totalArcadePoints: 130, + faciCounts: { bonusMilestonePoints: 10 }, + facilitator: { bonusIncludedInTotal: true, bonusMilestonePoints: 10 }, + }); + + expect(AccountService.updateAccountArcadeData).toHaveBeenCalledWith( + "acc1", + expect.objectContaining({ + totalArcadePoints: 130, + arcadePoints: expect.objectContaining({ totalPoints: 130 }), + }), + ); + }); + it("saves to legacy storage with facilitator bonus when no active account", async () => { vi.mocked(AccountService.getActiveAccount).mockResolvedValue(null); vi.mocked(AccountService.getSettings).mockResolvedValue({ @@ -88,9 +118,7 @@ describe("StorageService.saveArcadeData - active account path", () => { }); const stored = await fakeBrowser.storage.local.get("arcadeData"); - expect((stored.arcadeData as any).totalArcadePoints).toBeGreaterThanOrEqual( - 50, - ); + expect((stored.arcadeData as any).totalArcadePoints).toBe(50); }); }); From 424a5a8e5e3bfe7ebd6665d03c1f26e90f9a6104 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Fri, 30 Oct 2026 14:03:18 +0700 Subject: [PATCH 07/22] fix: prevent Arcade bonus score double counting --- ARCHITECTURE.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 4b4a9d67..361613a9 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -134,6 +134,8 @@ Current behavior: - Requires explicit user confirmation. - Keeps Bonus Milestone separate from the normal Facilitator milestone bonus. - Avoids double counting when the API says the bonus is already included. +- The claim chip is status-only: it triggers a signed API refresh and never adjusts the headline DOM total itself. +- API point snapshots remain unmodified in extension storage; derived totals are only for presentation and toolbar badges. - Persists confirmation using a profile/period-scoped storage key. - Supports undo. - Ignores stale confirmation for periods where the bonus is unavailable. From 0fbff7cfca72687b0a1220c2527e3b7dcc9a3036 Mon Sep 17 00:00:00 2001 From: "deepsource-autofix[bot]" <62050782+deepsource-autofix[bot]@users.noreply.github.com> Date: Fri, 30 Oct 2026 07:04:10 +0000 Subject: [PATCH 08/22] style: format code with Prettier This commit fixes the style issues introduced in 424a5a8 according to the output from Prettier. Details: https://github.com/ePlus-DEV/google-cloud-skills-boost-helper/pull/258 --- services/optionsService.ts | 8 +++++--- services/storageService.ts | 17 +++++++++++------ 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/services/optionsService.ts b/services/optionsService.ts index b539fa94..457bea99 100644 --- a/services/optionsService.ts +++ b/services/optionsService.ts @@ -131,9 +131,11 @@ const OptionsService = { const nickname = account.nickname; const arcadePoints = account.arcadeData?.arcadePoints?.totalPoints || 0; // If facilitator program is enabled, compute facilitator bonus and add to total - const facilitatorBonus = account.facilitatorProgram && account.arcadeData?.facilitator?.bonusIncludedInTotal !== true - ? calculateFacilitatorBonus(account.arcadeData?.faciCounts) - : 0; + const facilitatorBonus = + account.facilitatorProgram && + account.arcadeData?.facilitator?.bonusIncludedInTotal !== true + ? calculateFacilitatorBonus(account.arcadeData?.faciCounts) + : 0; const finalPoints = (arcadePoints || 0) + (facilitatorBonus || 0); const activeLabel = browser.i18n.getMessage("statusActive"); const facilitatorLabel = browser.i18n.getMessage("labelFacilitatorProgram"); diff --git a/services/storageService.ts b/services/storageService.ts index c1b7ffcd..c605d265 100644 --- a/services/storageService.ts +++ b/services/storageService.ts @@ -58,7 +58,9 @@ async function saveArcadeData(data: ArcadeData): Promise { updatedData.totalArcadePoints || 0; const facilitatorBonus = - includeFaci && updatedData.facilitator?.bonusIncludedInTotal !== true && updatedData.faciCounts + includeFaci && + updatedData.facilitator?.bonusIncludedInTotal !== true && + updatedData.faciCounts ? calculateFacilitatorBonus(updatedData.faciCounts) : 0; const finalTotalPoints = basePoints + facilitatorBonus; @@ -82,9 +84,10 @@ async function saveArcadeData(data: ArcadeData): Promise { const legacyBasePoints = updatedData.arcadePoints?.totalPoints || updatedData.totalArcadePoints || 0; const legacyFaciBonus = - updatedData.facilitator?.bonusIncludedInTotal !== true && updatedData.faciCounts + updatedData.facilitator?.bonusIncludedInTotal !== true && + updatedData.faciCounts ? calculateFacilitatorBonus(updatedData.faciCounts) - : 0; + : 0; const legacyFinalTotal = legacyBasePoints + legacyFaciBonus; // Keep the legacy snapshot as received so reopening the popup does not @@ -219,9 +222,11 @@ async function refreshBadgeForActiveAccount(): Promise { const arcade = active.arcadeData; const basePoints = arcade?.arcadePoints?.totalPoints || arcade?.totalArcadePoints || 0; - const faciBonus = active.facilitatorProgram && arcade?.facilitator?.bonusIncludedInTotal !== true - ? calculateFacilitatorBonus(arcade?.faciCounts) - : 0; + const faciBonus = + active.facilitatorProgram && + arcade?.facilitator?.bonusIncludedInTotal !== true + ? calculateFacilitatorBonus(arcade?.faciCounts) + : 0; const finalPoints = (Number(basePoints) || 0) + (Number(faciBonus) || 0); await updateExtensionBadge(finalPoints); From 7e9d0415324fff29593e91a711e2595882d3e122 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Fri, 30 Oct 2026 14:05:50 +0700 Subject: [PATCH 09/22] fix: repair backtick delimiters in bonus score regression test --- tests/services/bonusMilestoneClaimUi.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/services/bonusMilestoneClaimUi.test.ts b/tests/services/bonusMilestoneClaimUi.test.ts index d4a41eec..15c9af85 100644 --- a/tests/services/bonusMilestoneClaimUi.test.ts +++ b/tests/services/bonusMilestoneClaimUi.test.ts @@ -205,11 +205,11 @@ describe("Bonus Milestone claim UI", () => { profileUrl, }); serviceMocks.watchBonusMilestoneControlState.mockImplementation(() => () => undefined); - document.body.innerHTML = \` + document.body.innerHTML = ` 130 130
- \`; + `; await act(async () => { mountBonusMilestoneControl(); await Promise.resolve(); From cb913b137bd8f40ed0012a1c667d38102efc99e6 Mon Sep 17 00:00:00 2001 From: "deepsource-autofix[bot]" <62050782+deepsource-autofix[bot]@users.noreply.github.com> Date: Fri, 30 Oct 2026 07:06:09 +0000 Subject: [PATCH 10/22] style: format code with Prettier This commit fixes the style issues introduced in 7e9d041 according to the output from Prettier. Details: https://github.com/ePlus-DEV/google-cloud-skills-boost-helper/pull/258 --- tests/services/bonusMilestoneClaimUi.test.ts | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/tests/services/bonusMilestoneClaimUi.test.ts b/tests/services/bonusMilestoneClaimUi.test.ts index 15c9af85..987749b3 100644 --- a/tests/services/bonusMilestoneClaimUi.test.ts +++ b/tests/services/bonusMilestoneClaimUi.test.ts @@ -193,7 +193,8 @@ describe("Bonus Milestone claim UI", () => { }); it("does not turn an API-confirmed 130 point snapshot into 140", async () => { - const profileUrl = "https://www.skills.google/public_profiles/11111111-1111-4111-8111-111111111111"; + const profileUrl = + "https://www.skills.google/public_profiles/11111111-1111-4111-8111-111111111111"; serviceMocks.getBonusMilestoneControlState.mockResolvedValue({ completed: true, enabled: true, @@ -204,7 +205,9 @@ describe("Bonus Milestone claim UI", () => { bonusIncludedInTotal: true, profileUrl, }); - serviceMocks.watchBonusMilestoneControlState.mockImplementation(() => () => undefined); + serviceMocks.watchBonusMilestoneControlState.mockImplementation( + () => () => undefined, + ); document.body.innerHTML = ` 130 130 @@ -216,7 +219,11 @@ describe("Bonus Milestone claim UI", () => { await Promise.resolve(); }); expect(document.getElementById("arcade-points")?.textContent).toBe("130"); - expect(document.getElementById("avatar-score-badge")?.textContent).toBe("130"); - expect(document.getElementById("arcade-facilitator-points")?.textContent).toContain("✓ +10"); + expect(document.getElementById("avatar-score-badge")?.textContent).toBe( + "130", + ); + expect( + document.getElementById("arcade-facilitator-points")?.textContent, + ).toContain("✓ +10"); }); }); From 2893b352b0f0e3e39f30e28ba2a5cbe5f8fb2990 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Fri, 30 Oct 2026 14:17:26 +0700 Subject: [PATCH 11/22] refactor: address DeepSource countdown complexity and empty catches --- services/popupUIService.ts | 490 ++++++++++++------------------------- 1 file changed, 155 insertions(+), 335 deletions(-) diff --git a/services/popupUIService.ts b/services/popupUIService.ts index 317a4502..31525715 100644 --- a/services/popupUIService.ts +++ b/services/popupUIService.ts @@ -1309,9 +1309,7 @@ Details: "text-gray-400", "font-bold", ]; - try { - progressElement.classList.remove(...removable); - } catch (_) {} + progressElement.classList.remove(...removable); if (progressPercent === 100) { progressElement.classList.add("text-green-400", "font-bold"); @@ -1346,9 +1344,7 @@ Details: "text-orange-400", "text-gray-400", ]; - try { - statusIcon.classList.remove(...iconRemovable, ...colorRemovable); - } catch (_) {} + statusIcon.classList.remove(...iconRemovable, ...colorRemovable); // Ensure base classes remain (do not wipe other unrelated classes) statusIcon.classList.add( @@ -1393,246 +1389,119 @@ Details: * Start facilitator program countdown timer with Firebase Remote Config */ async startFacilitatorCountdown(): Promise { - // Support multiple countdown instances. Each instance should have - // elements with class `.countdown-instance` and data attributes: - // - data-countdown-key (optional): remote config key for deadline - // If not provided, default `countdown_deadline_facilitator` and `countdown_enabled_facilitator` are used. - const firebaseService = (await import("./firebaseService")).default; + // Keep intervals on this service to prevent duplicate timers after refresh. + if (!(this as any)._countdownIntervals) { + (this as any)._countdownIntervals = new Map(); + } + const intervals: Map = (this as any)._countdownIntervals; + let createdArcadeInstance = false; + const now = new Date(); const currentYear = now.getFullYear(); - // Default to end of current year if env var is missing/empty const defaultFacilitatorDeadline = import.meta.env.WXT_COUNTDOWN_DEADLINE_FACILITATOR || - `${currentYear}-12-31T23:59:59+05:30`; + String(currentYear) + "-12-31T23:59:59+05:30"; - const getArcadeDefaultDeadline = () => { + /** Return the Arcade deadline configured for this calendar year. */ + const getArcadeDefaultDeadline = (): string => { const envDeadline = import.meta.env.WXT_COUNTDOWN_DEADLINE_ARCADE; if (envDeadline) return envDeadline; - const now = new Date(); - const year = now.getFullYear(); - return `${year}-12-31T23:59:59+00:00`; + return String(new Date().getFullYear()) + "-12-31T23:59:59+00:00"; }; + /** Pick the fallback deadline from the countdown's program and remote key. */ const getProgramDefaultDeadline = ( program: string | undefined, key: string, ): string => { - const normalized = program?.toLowerCase(); - if (normalized === "arcade" || key.toLowerCase().includes("arcade")) { - return getArcadeDefaultDeadline(); - } - return defaultFacilitatorDeadline; + const isArcade = program?.toLowerCase() === "arcade" || + key.toLowerCase().includes("arcade"); + return isArcade ? getArcadeDefaultDeadline() : defaultFacilitatorDeadline; }; - const formatCountdownDeadlineLabel = ( - date: Date, - source?: string, - ): string => { + /** Format the local deadline and timezone without losing half-hour offsets. */ + const formatCountdownDeadlineLabel = (date: Date): string => { try { - // Format: "10:20 01/10/2027 (GMT+7)" - respects user's locale const locale = navigator.language || "en-US"; - - const timeStr = date.toLocaleTimeString(locale, { + const time = date.toLocaleTimeString(locale, { hour: "2-digit", minute: "2-digit", hour12: false, }); - - const dateStr = date.toLocaleDateString(locale, { + const dateText = date.toLocaleDateString(locale, { day: "2-digit", month: "2-digit", year: "numeric", }); - - // Get timezone offset in GMT format (keep fractional offsets, e.g. +5:30) const offset = -date.getTimezoneOffset(); - const offsetHours = Math.floor(Math.abs(offset) / 60); - const offsetMinutes = Math.abs(offset) % 60; - const offsetSign = offset >= 0 ? "+" : "-"; - const timezone = `GMT${offsetSign}${offsetHours}${ - offsetMinutes > 0 ? `:${String(offsetMinutes).padStart(2, "0")}` : "" - }`; - - return `${timeStr} ${dateStr} (${timezone})`; - } catch (e) { - console.debug("formatCountdownDeadlineLabel fallback triggered", e); + const hours = Math.floor(Math.abs(offset) / 60); + const minutes = Math.abs(offset) % 60; + const sign = offset >= 0 ? "+" : "-"; + const timezone = "GMT" + sign + hours + + (minutes > 0 ? ":" + String(minutes).padStart(2, "0") : ""); + return time + " " + dateText + " (" + timezone + ")"; + } catch (error) { + console.debug("Countdown date formatting fallback:", error); return date.toISOString(); } }; - const renderEndedState = (program: string | undefined): string => { - const normalized = program?.toLowerCase(); - const isArcade = normalized === "arcade"; - const accentClass = isArcade ? "text-sky-400" : "text-red-400"; - const detailClass = isArcade ? "text-sky-300/70" : "text-red-300/70"; - const iconClass = isArcade ? "fa-gamepad" : "fa-clock-o"; - const messageKey = - normalized === "arcade" - ? "arcadeDeadlinePassed" - : "facilitatorDeadlinePassed"; - const getMessage = (key: string): string => - browser.i18n.getMessage( - key as Parameters[0], - ); - - return ` -
- -
${getMessage("programEnded")}
-
${getMessage(messageKey)}
-
- `; - }; - - // Initialize Firebase if not already done - try { - if (!firebaseService.isInitialized()) { - await firebaseService.initialize(); - } - } catch (e) { - // initialization failure is non-fatal; we'll fall back per-instance - console.error( - "Error initializing Firebase for facilitator countdown:", - e, + /** Resolve a localized countdown string. */ + const getMessage = (key: string): string => + browser.i18n.getMessage( + key as Parameters[0], ); - } - // Clear any existing intervals for countdown instances - // We keep a map on this object to preserve intervals across calls - if (!(this as any)._countdownIntervals) - (this as any)._countdownIntervals = new Map(); - const intervals: Map = (this as any)._countdownIntervals; - - // Find all countdown instances - const instances = document.querySelectorAll( - ".countdown-instance", - ); - - // Helper to clear interval by id - const clearIntervalById = (id: string) => { - const iv = intervals.get(id); - if (iv) { - try { - clearInterval(iv); - } catch {} - intervals.delete(id); - } + /** Render a localized completion message for an expired countdown. */ + const renderEndedState = (program: string | undefined): string => { + const isArcade = program?.toLowerCase() === "arcade"; + const accent = isArcade ? "text-sky-400" : "text-red-400"; + const detail = isArcade ? "text-sky-300/70" : "text-red-300/70"; + const icon = isArcade ? "fa-gamepad" : "fa-clock-o"; + const messageKey = isArcade + ? "arcadeDeadlinePassed" + : "facilitatorDeadlinePassed"; + return [ + '
', + '', + '
', getMessage("programEnded"), '
', + '
', getMessage(messageKey), '
', + '
', + ].join(""); }; - // Iterate instances sequentially and set up timers. Processing sequentially - // lets us await remote config reads and decide whether to schedule an - // interval (we avoid scheduling intervals for deadlines already passed). - let createdArcadeInstance = false; - const nodes = Array.from(instances.values()); - for (let idx = 0; idx < nodes.length; idx++) { - const el = nodes[idx]; - const id = el.id || `countdown-${idx}`; - const program = el.dataset.program; - - // Clear previous interval if exists - clearIntervalById(id); - - // Extract data attribute for remote key - const rcKey = el.dataset.countdownKey || "countdown_deadline_facilitator"; - const rcToggleKey = - el.dataset.countdownToggleKey || "countdown_enabled_facilitator"; - - // Determine deadline and enabled state - let deadlineDate: Date; - let enabled = true; - const fallbackDeadline = getProgramDefaultDeadline(program, rcKey); - - let configSource: string | undefined; - try { - const [deadlineStr, enabledValue] = await Promise.all([ - firebaseService.getStringParam(rcKey, fallbackDeadline), - firebaseService.getBooleanParam(rcToggleKey, true), - ]); - enabled = enabledValue; - - // Get configuration source for display - const params = firebaseService.getAllParams(); - configSource = params[rcKey]?.source; - - if (!enabled) { - // Hide this specific instance - el.classList.add("hidden"); - continue; - } else { - el.classList.remove("hidden"); - } - - deadlineDate = new Date(deadlineStr); - if (isNaN(deadlineDate.getTime())) { - deadlineDate = new Date(fallbackDeadline); - } - } catch (e) { - // Fallback - deadlineDate = new Date(fallbackDeadline); - configSource = "fallback"; + /** Fill all four countdown digits, including the expired zero state. */ + const renderCountdownDigits = (el: HTMLElement, diff: number): void => { + const duration = Math.max(0, diff); + const values = [ + [".countdown-days", Math.floor(duration / 86400000)], + [".countdown-hours", Math.floor((duration % 86400000) / 3600000)], + [".countdown-minutes", Math.floor((duration % 3600000) / 60000)], + [".countdown-seconds", Math.floor((duration % 60000) / 1000)], + ] as const; + for (const [selector, value] of values) { + const node = el.querySelector(selector); + if (node) node.textContent = String(value).padStart(2, "0"); } + }; - const deadlineLabelEl = el.querySelector( - ".countdown-deadline-label", + /** Ensure Arcade countdown exists when Facilitator has expired. */ + const ensureArcadeInstance = (): HTMLElement => { + const existing = document.querySelector( + '.countdown-instance[data-program="arcade"]', ); - if (deadlineLabelEl && !isNaN(deadlineDate.getTime())) { - deadlineLabelEl.innerHTML = formatCountdownDeadlineLabel( - deadlineDate, - configSource, - ); - } - - // If the deadline is already passed, render the ended state once and - // (for facilitator) ensure the arcade instance exists. We do NOT - // schedule a repeating interval for already-expired instances to avoid - // re-creating intervals that immediately expire and re-enter this - // function. - const now = new Date(); - const initialDiff = deadlineDate.getTime() - now.getTime(); - if (initialDiff <= 0) { - // Show zeroed counters if present - const daysEl = el.querySelector(".countdown-days"); - const hoursEl = el.querySelector(".countdown-hours"); - const minutesEl = el.querySelector(".countdown-minutes"); - const secondsEl = el.querySelector(".countdown-seconds"); - if (daysEl) daysEl.textContent = "00"; - if (hoursEl) hoursEl.textContent = "00"; - if (minutesEl) minutesEl.textContent = "00"; - if (secondsEl) secondsEl.textContent = "00"; - - // replace instance content with ended message - el.innerHTML = renderEndedState(program); - - try { - if ((program ?? "").toLowerCase() === "facilitator") { - let arcadeEl = document.querySelector( - '.countdown-instance[data-program="arcade"]', - ); - - if (!arcadeEl) { - // Create a minimal arcade instance under the milestones or countdown container - const container = - document.querySelector("#milestones-section") || - document.querySelector("#countdown-container") || - document.body; - - const wrapper = document.createElement("div"); - wrapper.className = - "countdown-card countdown-instance bg-gradient-to-r from-sky-500/20 to-indigo-500/20 backdrop-blur-md rounded-lg p-3 border border-sky-400/30"; - wrapper.setAttribute("data-program", "arcade"); - // ensure arcade uses an explicit remote key if you want - wrapper.setAttribute( - "data-countdown-key", - "countdown_deadline_arcade", - ); - wrapper.setAttribute( - "data-countdown-toggle-key", - "countdown_enabled_arcade", - ); - // Use default child elements expected by the updater - wrapper.innerHTML = ` + if (existing) return existing; + const container = document.querySelector("#milestones-section") || + document.querySelector("#countdown-container") || document.body; + const wrapper = document.createElement("div"); + wrapper.className = + "countdown-card countdown-instance bg-gradient-to-r from-sky-500/20 to-indigo-500/20 backdrop-blur-md rounded-lg p-3 border border-sky-400/30"; + wrapper.setAttribute("data-program", "arcade"); + wrapper.setAttribute("data-countdown-key", "countdown_deadline_arcade"); + wrapper.setAttribute("data-countdown-toggle-key", "countdown_enabled_arcade"); + wrapper.innerHTML = `
@@ -1670,153 +1539,104 @@ Details: Keep the momentum going and climb the Arcade leaderboard!
`; - container.appendChild(wrapper); - arcadeEl = wrapper; - createdArcadeInstance = true; - } - - // Ensure countdown UI is visible and arcade instance is shown - try { - this.showCountdownDisplay(); - arcadeEl.classList.remove("hidden"); - } catch (_) {} - } - } catch (_) {} - - // Do not schedule an interval for already-expired instances - continue; + container.appendChild(wrapper); + createdArcadeInstance = true; + return wrapper; + }; + + /** Stop an expired countdown and expose Arcade countdown when needed. */ + const markExpired = (el: HTMLElement, program: string | undefined): void => { + renderCountdownDigits(el, 0); + el.innerHTML = renderEndedState(program); + if (program?.toLowerCase() !== "facilitator") return; + const arcade = ensureArcadeInstance(); + this.showCountdownDisplay(); + arcade.classList.remove("hidden"); + }; + + /** Clear a previously registered countdown interval. */ + const clearIntervalById = (id: string): void => { + const timerId = intervals.get(id); + if (timerId === undefined) return; + clearInterval(timerId); + intervals.delete(id); + }; + + try { + if (!firebaseService.isInitialized()) { + await firebaseService.initialize(); + } + } catch (error) { + // Remote Config initialization is optional; each instance falls back. + console.debug("Countdown Firebase initialization fallback:", error); + } + + const instances = Array.from( + document.querySelectorAll(".countdown-instance"), + ); + for (const [idx, el] of instances.entries()) { + const id = el.id || "countdown-" + idx; + const program = el.dataset.program; + clearIntervalById(id); + + const rcKey = el.dataset.countdownKey || "countdown_deadline_facilitator"; + const rcToggleKey = + el.dataset.countdownToggleKey || "countdown_enabled_facilitator"; + const fallbackDeadline = getProgramDefaultDeadline(program, rcKey); + let deadlineDate = new Date(fallbackDeadline); + + try { + const [deadlineStr, enabled] = await Promise.all([ + firebaseService.getStringParam(rcKey, fallbackDeadline), + firebaseService.getBooleanParam(rcToggleKey, true), + ]); + if (!enabled) { + el.classList.add("hidden"); + continue; + } + el.classList.remove("hidden"); + const configured = new Date(deadlineStr); + if (!Number.isNaN(configured.getTime())) deadlineDate = configured; + } catch (error) { + console.debug("Using fallback countdown configuration:", error); } - // Query inner elements for time parts (allow per-instance selectors) - const daysEl = el.querySelector(".countdown-days"); - const hoursEl = el.querySelector(".countdown-hours"); - const minutesEl = el.querySelector(".countdown-minutes"); - const secondsEl = el.querySelector(".countdown-seconds"); + const label = el.querySelector(".countdown-deadline-label"); + if (label && !Number.isNaN(deadlineDate.getTime())) { + label.textContent = formatCountdownDeadlineLabel(deadlineDate); + } - const updateFn = () => { - const now = new Date(); - const diff = deadlineDate.getTime() - now.getTime(); + if (deadlineDate.getTime() <= Date.now()) { + markExpired(el, program); + continue; + } + /** Update this instance independently and terminate it on expiry. */ + const updateFn = (): void => { + const diff = deadlineDate.getTime() - Date.now(); if (diff <= 0) { - if (daysEl) daysEl.textContent = "00"; - if (hoursEl) hoursEl.textContent = "00"; - if (minutesEl) minutesEl.textContent = "00"; - if (secondsEl) secondsEl.textContent = "00"; - - // replace instance content with ended message - el.innerHTML = renderEndedState(program); - - // If this was the facilitator instance, ensure the arcade countdown - // is running until end of December. Create one if missing. - try { - if ((program ?? "").toLowerCase() === "facilitator") { - let arcadeEl = document.querySelector( - '.countdown-instance[data-program="arcade"]', - ); - - if (!arcadeEl) { - // Create a minimal arcade instance under the milestones or countdown container - const container = - document.querySelector("#milestones-section") || - document.querySelector("#countdown-container") || - document.body; - - const wrapper = document.createElement("div"); - wrapper.className = - "countdown-card countdown-instance bg-gradient-to-r from-sky-500/20 to-indigo-500/20 backdrop-blur-md rounded-lg p-3 border border-sky-400/30"; - wrapper.setAttribute("data-program", "arcade"); - // ensure arcade uses an explicit remote key if you want - wrapper.setAttribute( - "data-countdown-key", - "countdown_deadline_arcade", - ); - wrapper.setAttribute( - "data-countdown-toggle-key", - "countdown_enabled_arcade", - ); - // Use default child elements expected by the updater - wrapper.innerHTML = ` -
-
- - - Arcade Season Countdown - -
-
- Loading... -
-
-
-
-
00
-
Days
-
-
:
-
-
00
-
Hours
-
-
:
-
-
00
-
Minutes
-
-
:
-
-
00
-
Seconds
-
-
-
- - Keep the momentum going and climb the Arcade leaderboard! -
- `; - container.appendChild(wrapper); - arcadeEl = wrapper; - createdArcadeInstance = true; - } - - // Ensure countdown UI is visible and arcade instance is shown - try { - this.showCountdownDisplay(); - arcadeEl.classList.remove("hidden"); - } catch (_) {} - } - } catch (_) {} - + markExpired(el, program); clearIntervalById(id); return; } - - const days = Math.floor(diff / (1000 * 60 * 60 * 24)); - const hours = Math.floor( - (diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60), - ); - const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60)); - const seconds = Math.floor((diff % (1000 * 60)) / 1000); - - if (daysEl) daysEl.textContent = String(days).padStart(2, "0"); - if (hoursEl) hoursEl.textContent = String(hours).padStart(2, "0"); - if (minutesEl) minutesEl.textContent = String(minutes).padStart(2, "0"); - if (secondsEl) secondsEl.textContent = String(seconds).padStart(2, "0"); + renderCountdownDigits(el, diff); }; - // Run immediately and every second updateFn(); + if (deadlineDate.getTime() <= Date.now()) continue; const intervalId = setInterval(updateFn, 1000) as unknown as number; intervals.set(id, intervalId); } - // If we created an arcade instance during processing of expired facilitator, - // kick off a new pass to start countdowns for any non-expired instances - // (run on next tick to avoid immediate recursion). + // A dynamically created Arcade card was not part of the initial node list. if (createdArcadeInstance) { setTimeout(() => { - void this.startFacilitatorCountdown(); + this.startFacilitatorCountdown().catch((error) => { + console.error("Could not initialize Arcade countdown:", error); + }); }, 0); } + } }, }; From ceacc13a55e6ce71646585c7a76bf9ae707603dc Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Fri, 30 Oct 2026 14:17:42 +0700 Subject: [PATCH 12/22] fix: close refactored countdown service method correctly --- services/popupUIService.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/services/popupUIService.ts b/services/popupUIService.ts index 31525715..f91b22b8 100644 --- a/services/popupUIService.ts +++ b/services/popupUIService.ts @@ -1636,7 +1636,6 @@ Details: }); }, 0); } - } }, }; From b4d3aa94e282c936eb4a2394a39019b3a2307474 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Fri, 30 Oct 2026 14:19:59 +0700 Subject: [PATCH 13/22] test: cover Remote Config countdown lifecycle and single Arcade fallback --- tests/services/popupUICountdown.test.ts | 93 +++++++++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 tests/services/popupUICountdown.test.ts diff --git a/tests/services/popupUICountdown.test.ts b/tests/services/popupUICountdown.test.ts new file mode 100644 index 00000000..21130b9e --- /dev/null +++ b/tests/services/popupUICountdown.test.ts @@ -0,0 +1,93 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const remoteConfig = vi.hoisted(() => ({ + isInitialized: vi.fn(() => true), + initialize: vi.fn(async () => undefined), + getStringParam: vi.fn(async (_key: string, fallback: string) => fallback), + getBooleanParam: vi.fn(async () => true), +})); + +vi.mock("../../services/firebaseService", () => ({ + default: remoteConfig, +})); + +import PopupUIService from "../../services/popupUIService"; + +describe("PopupUIService countdown", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-10-30T00:00:00.000Z")); + vi.clearAllMocks(); + remoteConfig.isInitialized.mockReturnValue(true); + remoteConfig.getStringParam.mockImplementation(async (_key, fallback) => fallback); + remoteConfig.getBooleanParam.mockResolvedValue(true); + vi.stubGlobal("browser", { + i18n: { getMessage: (key: string) => key }, + }); + ( + PopupUIService as typeof PopupUIService & { + _countdownIntervals: Map; + } + )._countdownIntervals = new Map(); + document.body.innerHTML = ""; + }); + + afterEach(() => { + vi.clearAllTimers(); + vi.useRealTimers(); + vi.unstubAllGlobals(); + document.body.innerHTML = ""; + }); + + it("hides only the disabled countdown without creating a timer", async () => { + document.body.innerHTML = '
'; + remoteConfig.getBooleanParam.mockResolvedValue(false); + + await PopupUIService.startFacilitatorCountdown(); + + expect(document.getElementById("disabled")?.classList.contains("hidden")).toBe(true); + expect(vi.getTimerCount()).toBe(0); + }); + + it("renders an active timer and updates its counters", async () => { + document.body.innerHTML = [ + '
', + '', + '', + "
", + ].join(""); + remoteConfig.getStringParam.mockResolvedValue("2026-10-30T00:00:03.000Z"); + + await PopupUIService.startFacilitatorCountdown(); + + const counter = document.querySelector(".countdown-seconds"); + expect(counter?.textContent).toBe("03"); + await vi.advanceTimersByTimeAsync(1000); + expect(counter?.textContent).toBe("02"); + await vi.advanceTimersByTimeAsync(2000); + expect(document.querySelector("#active")?.textContent).toContain("arcadeDeadlinePassed"); + expect(vi.getTimerCount()).toBe(0); + }); + + it("creates only one Arcade card after Facilitator expires", async () => { + document.body.innerHTML = [ + '
', + '
', + '
', + '', + "
", + ].join(""); + remoteConfig.getStringParam.mockImplementation(async (key) => + key.includes("arcade") ? "2026-12-31T00:00:00.000Z" : "2026-10-01T00:00:00.000Z", + ); + + await PopupUIService.startFacilitatorCountdown(); + await vi.advanceTimersByTimeAsync(0); + + expect(document.querySelector("#facilitator")?.textContent).toContain("facilitatorDeadlinePassed"); + expect(document.querySelectorAll('.countdown-instance[data-program="arcade"]')).toHaveLength(1); + + await PopupUIService.startFacilitatorCountdown(); + expect(document.querySelectorAll('.countdown-instance[data-program="arcade"]')).toHaveLength(1); + }); +}); From ab61573d63310429718b760a568229c1efe847d7 Mon Sep 17 00:00:00 2001 From: "deepsource-autofix[bot]" <62050782+deepsource-autofix[bot]@users.noreply.github.com> Date: Fri, 30 Oct 2026 07:20:18 +0000 Subject: [PATCH 14/22] style: format code with Prettier This commit fixes the style issues introduced in ceacc13 according to the output from Prettier. Details: https://github.com/ePlus-DEV/google-cloud-skills-boost-helper/pull/258 --- services/popupUIService.ts | 44 ++++++++++++++++++++++++++++---------- 1 file changed, 33 insertions(+), 11 deletions(-) diff --git a/services/popupUIService.ts b/services/popupUIService.ts index f91b22b8..ffc0ee22 100644 --- a/services/popupUIService.ts +++ b/services/popupUIService.ts @@ -1416,7 +1416,8 @@ Details: program: string | undefined, key: string, ): string => { - const isArcade = program?.toLowerCase() === "arcade" || + const isArcade = + program?.toLowerCase() === "arcade" || key.toLowerCase().includes("arcade"); return isArcade ? getArcadeDefaultDeadline() : defaultFacilitatorDeadline; }; @@ -1439,7 +1440,10 @@ Details: const hours = Math.floor(Math.abs(offset) / 60); const minutes = Math.abs(offset) % 60; const sign = offset >= 0 ? "+" : "-"; - const timezone = "GMT" + sign + hours + + const timezone = + "GMT" + + sign + + hours + (minutes > 0 ? ":" + String(minutes).padStart(2, "0") : ""); return time + " " + dateText + " (" + timezone + ")"; } catch (error) { @@ -1464,11 +1468,21 @@ Details: ? "arcadeDeadlinePassed" : "facilitatorDeadlinePassed"; return [ - '
', - '', - '
', getMessage("programEnded"), '
', - '
', getMessage(messageKey), '
', - '
', + '
', + '', + '
', + getMessage("programEnded"), + "
", + '
', + getMessage(messageKey), + "
", + "
", ].join(""); }; @@ -1493,14 +1507,19 @@ Details: '.countdown-instance[data-program="arcade"]', ); if (existing) return existing; - const container = document.querySelector("#milestones-section") || - document.querySelector("#countdown-container") || document.body; + const container = + document.querySelector("#milestones-section") || + document.querySelector("#countdown-container") || + document.body; const wrapper = document.createElement("div"); wrapper.className = "countdown-card countdown-instance bg-gradient-to-r from-sky-500/20 to-indigo-500/20 backdrop-blur-md rounded-lg p-3 border border-sky-400/30"; wrapper.setAttribute("data-program", "arcade"); wrapper.setAttribute("data-countdown-key", "countdown_deadline_arcade"); - wrapper.setAttribute("data-countdown-toggle-key", "countdown_enabled_arcade"); + wrapper.setAttribute( + "data-countdown-toggle-key", + "countdown_enabled_arcade", + ); wrapper.innerHTML = `
@@ -1545,7 +1564,10 @@ Details: }; /** Stop an expired countdown and expose Arcade countdown when needed. */ - const markExpired = (el: HTMLElement, program: string | undefined): void => { + const markExpired = ( + el: HTMLElement, + program: string | undefined, + ): void => { renderCountdownDigits(el, 0); el.innerHTML = renderEndedState(program); if (program?.toLowerCase() !== "facilitator") return; From cce8b0a85de7b9a3e3cf9a7ccbef40c0d2df8fcf Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Fri, 30 Oct 2026 14:21:57 +0700 Subject: [PATCH 15/22] test: mock WXT browser i18n for countdown completion scenarios --- tests/services/popupUICountdown.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/services/popupUICountdown.test.ts b/tests/services/popupUICountdown.test.ts index 21130b9e..8bc5e5a7 100644 --- a/tests/services/popupUICountdown.test.ts +++ b/tests/services/popupUICountdown.test.ts @@ -7,6 +7,10 @@ const remoteConfig = vi.hoisted(() => ({ getBooleanParam: vi.fn(async () => true), })); +vi.mock("wxt/browser", () => ({ + browser: { i18n: { getMessage: (key: string) => key } }, +})); + vi.mock("../../services/firebaseService", () => ({ default: remoteConfig, })); @@ -21,9 +25,6 @@ describe("PopupUIService countdown", () => { remoteConfig.isInitialized.mockReturnValue(true); remoteConfig.getStringParam.mockImplementation(async (_key, fallback) => fallback); remoteConfig.getBooleanParam.mockResolvedValue(true); - vi.stubGlobal("browser", { - i18n: { getMessage: (key: string) => key }, - }); ( PopupUIService as typeof PopupUIService & { _countdownIntervals: Map; @@ -35,7 +36,6 @@ describe("PopupUIService countdown", () => { afterEach(() => { vi.clearAllTimers(); vi.useRealTimers(); - vi.unstubAllGlobals(); document.body.innerHTML = ""; }); From b6826467bae6c52a6a46fc7dbd32927d6f8e4240 Mon Sep 17 00:00:00 2001 From: "deepsource-autofix[bot]" <62050782+deepsource-autofix[bot]@users.noreply.github.com> Date: Fri, 30 Oct 2026 07:24:05 +0000 Subject: [PATCH 16/22] style: format code with Prettier This commit fixes the style issues introduced in cce8b0a according to the output from Prettier. Details: https://github.com/ePlus-DEV/google-cloud-skills-boost-helper/pull/258 --- tests/services/popupUICountdown.test.ts | 31 ++++++++++++++++++------- 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/tests/services/popupUICountdown.test.ts b/tests/services/popupUICountdown.test.ts index 8bc5e5a7..6ab8be80 100644 --- a/tests/services/popupUICountdown.test.ts +++ b/tests/services/popupUICountdown.test.ts @@ -23,7 +23,9 @@ describe("PopupUIService countdown", () => { vi.setSystemTime(new Date("2026-10-30T00:00:00.000Z")); vi.clearAllMocks(); remoteConfig.isInitialized.mockReturnValue(true); - remoteConfig.getStringParam.mockImplementation(async (_key, fallback) => fallback); + remoteConfig.getStringParam.mockImplementation( + async (_key, fallback) => fallback, + ); remoteConfig.getBooleanParam.mockResolvedValue(true); ( PopupUIService as typeof PopupUIService & { @@ -40,12 +42,15 @@ describe("PopupUIService countdown", () => { }); it("hides only the disabled countdown without creating a timer", async () => { - document.body.innerHTML = '
'; + document.body.innerHTML = + '
'; remoteConfig.getBooleanParam.mockResolvedValue(false); await PopupUIService.startFacilitatorCountdown(); - expect(document.getElementById("disabled")?.classList.contains("hidden")).toBe(true); + expect( + document.getElementById("disabled")?.classList.contains("hidden"), + ).toBe(true); expect(vi.getTimerCount()).toBe(0); }); @@ -65,7 +70,9 @@ describe("PopupUIService countdown", () => { await vi.advanceTimersByTimeAsync(1000); expect(counter?.textContent).toBe("02"); await vi.advanceTimersByTimeAsync(2000); - expect(document.querySelector("#active")?.textContent).toContain("arcadeDeadlinePassed"); + expect(document.querySelector("#active")?.textContent).toContain( + "arcadeDeadlinePassed", + ); expect(vi.getTimerCount()).toBe(0); }); @@ -78,16 +85,24 @@ describe("PopupUIService countdown", () => { "
", ].join(""); remoteConfig.getStringParam.mockImplementation(async (key) => - key.includes("arcade") ? "2026-12-31T00:00:00.000Z" : "2026-10-01T00:00:00.000Z", + key.includes("arcade") + ? "2026-12-31T00:00:00.000Z" + : "2026-10-01T00:00:00.000Z", ); await PopupUIService.startFacilitatorCountdown(); await vi.advanceTimersByTimeAsync(0); - expect(document.querySelector("#facilitator")?.textContent).toContain("facilitatorDeadlinePassed"); - expect(document.querySelectorAll('.countdown-instance[data-program="arcade"]')).toHaveLength(1); + expect(document.querySelector("#facilitator")?.textContent).toContain( + "facilitatorDeadlinePassed", + ); + expect( + document.querySelectorAll('.countdown-instance[data-program="arcade"]'), + ).toHaveLength(1); await PopupUIService.startFacilitatorCountdown(); - expect(document.querySelectorAll('.countdown-instance[data-program="arcade"]')).toHaveLength(1); + expect( + document.querySelectorAll('.countdown-instance[data-program="arcade"]'), + ).toHaveLength(1); }); }); From 6d37632c4ccf1bce2df80ce05edd327108d9d5d3 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Fri, 30 Oct 2026 15:11:41 +0700 Subject: [PATCH 17/22] style: replace countdown string concatenation with template literals --- services/popupUIService.ts | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/services/popupUIService.ts b/services/popupUIService.ts index ffc0ee22..b81b0884 100644 --- a/services/popupUIService.ts +++ b/services/popupUIService.ts @@ -1402,13 +1402,13 @@ Details: const currentYear = now.getFullYear(); const defaultFacilitatorDeadline = import.meta.env.WXT_COUNTDOWN_DEADLINE_FACILITATOR || - String(currentYear) + "-12-31T23:59:59+05:30"; + `${currentYear}-12-31T23:59:59+05:30`; /** Return the Arcade deadline configured for this calendar year. */ const getArcadeDefaultDeadline = (): string => { const envDeadline = import.meta.env.WXT_COUNTDOWN_DEADLINE_ARCADE; if (envDeadline) return envDeadline; - return String(new Date().getFullYear()) + "-12-31T23:59:59+00:00"; + return `${new Date().getFullYear()}-12-31T23:59:59+00:00`; }; /** Pick the fallback deadline from the countdown's program and remote key. */ @@ -1440,12 +1440,8 @@ Details: const hours = Math.floor(Math.abs(offset) / 60); const minutes = Math.abs(offset) % 60; const sign = offset >= 0 ? "+" : "-"; - const timezone = - "GMT" + - sign + - hours + - (minutes > 0 ? ":" + String(minutes).padStart(2, "0") : ""); - return time + " " + dateText + " (" + timezone + ")"; + const timezone = `GMT${sign}${hours}${minutes > 0 ? `:${String(minutes).padStart(2, "0")}` : ""}`; + return `${time} ${dateText} (${timezone})`; } catch (error) { console.debug("Countdown date formatting fallback:", error); return date.toISOString(); @@ -1597,7 +1593,7 @@ Details: document.querySelectorAll(".countdown-instance"), ); for (const [idx, el] of instances.entries()) { - const id = el.id || "countdown-" + idx; + const id = el.id || `countdown-${idx}`; const program = el.dataset.program; clearIntervalById(id); From 941fd16388bd0b9af7918c6a95bfe0b4275fe672 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Fri, 30 Oct 2026 15:11:54 +0700 Subject: [PATCH 18/22] test: return explicit Promises from countdown mocks without unnecessary async --- tests/services/popupUICountdown.test.ts | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/tests/services/popupUICountdown.test.ts b/tests/services/popupUICountdown.test.ts index 6ab8be80..e0bd868f 100644 --- a/tests/services/popupUICountdown.test.ts +++ b/tests/services/popupUICountdown.test.ts @@ -2,9 +2,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const remoteConfig = vi.hoisted(() => ({ isInitialized: vi.fn(() => true), - initialize: vi.fn(async () => undefined), - getStringParam: vi.fn(async (_key: string, fallback: string) => fallback), - getBooleanParam: vi.fn(async () => true), + initialize: vi.fn(() => Promise.resolve(undefined)), + getStringParam: vi.fn((_key: string, fallback: string) => Promise.resolve(fallback)), + getBooleanParam: vi.fn(() => Promise.resolve(true)), })); vi.mock("wxt/browser", () => ({ @@ -24,7 +24,7 @@ describe("PopupUIService countdown", () => { vi.clearAllMocks(); remoteConfig.isInitialized.mockReturnValue(true); remoteConfig.getStringParam.mockImplementation( - async (_key, fallback) => fallback, + (_key, fallback) => Promise.resolve(fallback), ); remoteConfig.getBooleanParam.mockResolvedValue(true); ( @@ -84,10 +84,12 @@ describe("PopupUIService countdown", () => { '', "
", ].join(""); - remoteConfig.getStringParam.mockImplementation(async (key) => - key.includes("arcade") - ? "2026-12-31T00:00:00.000Z" - : "2026-10-01T00:00:00.000Z", + remoteConfig.getStringParam.mockImplementation((key) => + Promise.resolve( + key.includes("arcade") + ? "2026-12-31T00:00:00.000Z" + : "2026-10-01T00:00:00.000Z", + ), ); await PopupUIService.startFacilitatorCountdown(); From d9cc4785f4e659697a48fa0d703098b90abd2f22 Mon Sep 17 00:00:00 2001 From: "deepsource-autofix[bot]" <62050782+deepsource-autofix[bot]@users.noreply.github.com> Date: Fri, 30 Oct 2026 08:14:28 +0000 Subject: [PATCH 19/22] style: format code with Prettier This commit fixes the style issues introduced in 941fd16 according to the output from Prettier. Details: https://github.com/ePlus-DEV/google-cloud-skills-boost-helper/pull/258 --- tests/services/popupUICountdown.test.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/services/popupUICountdown.test.ts b/tests/services/popupUICountdown.test.ts index e0bd868f..6bee5850 100644 --- a/tests/services/popupUICountdown.test.ts +++ b/tests/services/popupUICountdown.test.ts @@ -3,7 +3,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const remoteConfig = vi.hoisted(() => ({ isInitialized: vi.fn(() => true), initialize: vi.fn(() => Promise.resolve(undefined)), - getStringParam: vi.fn((_key: string, fallback: string) => Promise.resolve(fallback)), + getStringParam: vi.fn((_key: string, fallback: string) => + Promise.resolve(fallback), + ), getBooleanParam: vi.fn(() => Promise.resolve(true)), })); @@ -23,8 +25,8 @@ describe("PopupUIService countdown", () => { vi.setSystemTime(new Date("2026-10-30T00:00:00.000Z")); vi.clearAllMocks(); remoteConfig.isInitialized.mockReturnValue(true); - remoteConfig.getStringParam.mockImplementation( - (_key, fallback) => Promise.resolve(fallback), + remoteConfig.getStringParam.mockImplementation((_key, fallback) => + Promise.resolve(fallback), ); remoteConfig.getBooleanParam.mockResolvedValue(true); ( From 414b4bb7bc53de5cc33ccc52e2788466a8089ceb Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Fri, 30 Oct 2026 15:20:07 +0700 Subject: [PATCH 20/22] fix: remove redundant undefined argument from countdown test mock --- tests/services/popupUICountdown.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/services/popupUICountdown.test.ts b/tests/services/popupUICountdown.test.ts index 6bee5850..aff6cac5 100644 --- a/tests/services/popupUICountdown.test.ts +++ b/tests/services/popupUICountdown.test.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const remoteConfig = vi.hoisted(() => ({ isInitialized: vi.fn(() => true), - initialize: vi.fn(() => Promise.resolve(undefined)), + initialize: vi.fn(() => Promise.resolve()), getStringParam: vi.fn((_key: string, fallback: string) => Promise.resolve(fallback), ), From 2367b2a6968ed17861d5851c7391514954cb72ec Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Fri, 30 Oct 2026 15:31:06 +0700 Subject: [PATCH 21/22] chore(release): prepare extension v1.3.5 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 2be38e19..a8338e4e 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "google-cloud-skills-boost-helper", "description": "__MSG_extDescription__", "private": true, - "version": "1.3.4", + "version": "1.3.5", "author": "dev@eplus.dev", "license": "MIT", "homepage_url": "https://eplus.dev", From 70a70af92a5cc1bfbd3895bf3fb4df7da2878a79 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Fri, 30 Oct 2026 15:31:22 +0700 Subject: [PATCH 22/22] docs(changelog): document v1.3.5 Arcade bonus hotfix --- CHANGELOG.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 53d1af98..c1ed5fd4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,24 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.3.5] - 2026-10-30 + +### :bug: Bug Fixes + +- Prevent double counting of the self-reported `+10` Bonus Milestone in the Arcade popup headline. +- Preserve raw Arcade API point totals in active-account and legacy storage instead of persisting locally adjusted totals. +- Respect the API's `facilitator.bonusIncludedInTotal` flag when deriving Facilitator bonus for the popup, account cards, and extension badge. + +### :white_check_mark: Tests + +- Add regression coverage for the API-confirmed 130-point score remaining unchanged after Bonus Milestone confirmation. +- Cover Facilitator/Arcade countdown Remote Config behavior, active timers, and single-card fallback after Facilitator expiry. + +### :wrench: Maintenance + +- Refactor countdown UI to reduce repeated code, remove empty exception handlers, and resolve DeepSource JavaScript findings. +- Bump the extension version to 1.3.5 in preparation for the `dev → main` release. The release tag is created only after approval and merge. + ## [1.3.4] - 2026-10-06 ### :lock: Security