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. 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 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; 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", diff --git a/services/optionsService.ts b/services/optionsService.ts index 3b631167..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 - ? 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/popupUIService.ts b/services/popupUIService.ts index b44feaa5..b81b0884 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) @@ -1307,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"); @@ -1344,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( @@ -1391,246 +1389,134 @@ 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`; - 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 `${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 = `
@@ -1668,151 +1554,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); } }, diff --git a/services/storageService.ts b/services/storageService.ts index e36645db..c605d265 100644 --- a/services/storageService.ts +++ b/services/storageService.ts @@ -58,18 +58,15 @@ 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 +83,15 @@ 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) - : 0; + 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,9 +222,11 @@ async function refreshBadgeForActiveAccount(): Promise { const arcade = active.arcadeData; const basePoints = arcade?.arcadePoints?.totalPoints || arcade?.totalArcadePoints || 0; - const faciBonus = active.facilitatorProgram - ? 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); diff --git a/tests/services/bonusMilestoneClaimUi.test.ts b/tests/services/bonusMilestoneClaimUi.test.ts index 18597a15..987749b3 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,39 @@ 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"); + }); }); diff --git a/tests/services/popupUICountdown.test.ts b/tests/services/popupUICountdown.test.ts new file mode 100644 index 00000000..aff6cac5 --- /dev/null +++ b/tests/services/popupUICountdown.test.ts @@ -0,0 +1,112 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const remoteConfig = vi.hoisted(() => ({ + isInitialized: vi.fn(() => true), + initialize: vi.fn(() => Promise.resolve()), + getStringParam: vi.fn((_key: string, fallback: string) => + Promise.resolve(fallback), + ), + getBooleanParam: vi.fn(() => Promise.resolve(true)), +})); + +vi.mock("wxt/browser", () => ({ + browser: { i18n: { getMessage: (key: string) => key } }, +})); + +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((_key, fallback) => + Promise.resolve(fallback), + ); + remoteConfig.getBooleanParam.mockResolvedValue(true); + ( + PopupUIService as typeof PopupUIService & { + _countdownIntervals: Map; + } + )._countdownIntervals = new Map(); + document.body.innerHTML = ""; + }); + + afterEach(() => { + vi.clearAllTimers(); + vi.useRealTimers(); + 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((key) => + Promise.resolve( + 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); + }); +}); 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); }); });