From 5b1a35430a56bc81535aad0665ee92ac4a4a582a Mon Sep 17 00:00:00 2001 From: Paulo Porto Date: Mon, 7 Sep 2026 16:11:00 -0300 Subject: [PATCH] Implementation of the customer consumption display --- dist/chromium/background.js | 71 ++ dist/chromium/content.js | 1308 ++++++++++++++++++++++++- dist/firefox/background.js | 71 ++ dist/firefox/content.js | 1308 ++++++++++++++++++++++++- package.json | 3 +- src/background.js | 71 ++ src/content.js | 1308 ++++++++++++++++++++++++- tests/consumptionTrend.test.cjs | 174 ++++ tests/fixtures/consumption-trend.html | 103 ++ 9 files changed, 4323 insertions(+), 94 deletions(-) create mode 100644 tests/consumptionTrend.test.cjs create mode 100644 tests/fixtures/consumption-trend.html diff --git a/dist/chromium/background.js b/dist/chromium/background.js index d8f0fa1..4200856 100644 --- a/dist/chromium/background.js +++ b/dist/chromium/background.js @@ -20,6 +20,13 @@ const CUSTOMER_TOKEN_SERVICE_URL = "https://spa.oracle.com/oalcrm/web/api/g2m-consumer-application/consumerTokenService"; const ACCOUNT_SUMMARY_URL = "https://spa.oracle.com/oalcrm/web/api/provider-proxy/g2m-account/service/accountsummary"; const ACCOUNT_PLANS_URL = "https://spa.oracle.com/oalcrm/web/api/provider-proxy/g2m-consumption/service/v1/account/accountPlans"; + const CONSUMPTION_TREND_URL = "https://spa.oracle.com/oalcrm/web/api/provider-proxy/g2m-consumption/service/v1/plan/trend"; + const CONSUMPTION_TREND_REQUESTS = { + quarterly: "requestConsumptionServiceQuarterly", + monthly: "requestConsumptionServiceMonthly", + weekly: "requestConsumptionServiceWeekly", + daily: "requestConsumptionServiceDaily" + }; const ACCOUNT_PLANS_PAYLOAD = { hierarchy: "single", products: "all", @@ -149,6 +156,20 @@ return true; } + if (message.type === "opportunitiesExtension.requestConsumptionTrend") { + requestConsumptionTrend(message) + .then(sendResponse) + .catch((error) => { + sendResponse({ + ok: false, + error: error.message || "Unable to load consumption trend.", + debugRequests: Array.isArray(error.debugRequests) ? error.debugRequests : [] + }); + }); + + return true; + } + return false; }); @@ -282,6 +303,56 @@ } } + async function requestConsumptionTrend(message) { + const subPlanNum = typeof message.subPlanNum === "string" ? message.subPlanNum.trim() : ""; + const period = typeof message.period === "string" ? message.period.toLowerCase() : ""; + const requestName = CONSUMPTION_TREND_REQUESTS[period]; + const authorization = normalizeMessageAuthorization(message.authorization, "g2m-authorization"); + const debugRequests = []; + + if (!subPlanNum) { + throw new Error("Sub plan number is required for the consumption trend."); + } + + if (!requestName) { + throw new Error(`Unsupported consumption trend period: ${period || "(missing)"}.`); + } + + if (!authorization.value) { + throw new Error("OCICONS-TS authorization is unavailable for the consumption trend."); + } + + const url = `${CONSUMPTION_TREND_URL}/${encodeURIComponent(subPlanNum)}?quarter=All&timePeriod=${period}`; + + try { + const response = await fetchJson(url, { + method: "GET", + credentials: "include", + cache: "no-store", + headers: { + Accept: "application/json", + "G2m-Authorization": authorization.value + } + }, requestName, debugRequests); + const items = Array.isArray(response) + ? response + : Array.isArray(response && response.items) + ? response.items + : []; + + return { + ok: true, + subPlanNum, + period, + items, + debugRequests + }; + } catch (error) { + error.debugRequests = debugRequests; + throw error; + } + } + function normalizeMessageAuthorization(authorization, fallbackHeaderName) { const headerName = authorization && typeof authorization.name === "string" ? authorization.name.trim() diff --git a/dist/chromium/content.js b/dist/chromium/content.js index ade6ea6..51bc626 100644 --- a/dist/chromium/content.js +++ b/dist/chromium/content.js @@ -45,6 +45,19 @@ authenticated: "authenticated", unauthenticated: "unauthenticated" }; + const CONSUMPTION_TREND_PERIODS = [ + { value: "quarterly", label: "Quarterly", windowSize: 8 }, + { value: "monthly", label: "Monthly", windowSize: 13 }, + { value: "weekly", label: "Weekly", windowSize: 13 }, + { value: "daily", label: "Daily", windowSize: 30 } + ]; + const CONSUMPTION_TREND_SERIES = [ + { value: "funded", label: "Funded Allocation" }, + { value: "overage", label: "Overage" }, + { value: "average", label: "Average Consumption" } + ]; + const DEFAULT_CONSUMPTION_TREND_PERIOD = "weekly"; + const SVG_NAMESPACE = "http://www.w3.org/2000/svg"; const STAGE_OPTIONS = ["SQL", "PIPELINE", "UPSIDE", "FORECAST", "WON"]; const STATUS_OPTIONS = ["OPEN", "CLOSED", "LOST", "WON"]; const OPPORTUNITY_TYPE_OPTIONS = [ @@ -102,6 +115,9 @@ }; let productsLastCachedAt = 0; let accountPlansRequestState = new Map(); + let consumptionTrendCache = new Map(); + let consumptionTrendWindowOffsets = new Map(); + let consumptionTrendSeriesFilters = new Map(); let hasSavedCustomerFilter = false; let hasSavedOwnerFilter = false; let customerFilterInitialized = false; @@ -777,6 +793,9 @@ }; productsLastCachedAt = 0; accountPlansRequestState = new Map(); + consumptionTrendCache = new Map(); + consumptionTrendWindowOffsets = new Map(); + consumptionTrendSeriesFilters = new Map(); renderProductsProgress(); setOpportunitiesTableState({ items: [], @@ -1033,10 +1052,10 @@ if (timestamp) { timestamp.hidden = !productsLastCachedAt; timestamp.textContent = productsLastCachedAt - ? `Last cache: ${formatProductsCacheTimestamp(productsLastCachedAt)}` + ? `Last update: ${formatProductsCacheTimestamp(productsLastCachedAt)}` : ""; timestamp.title = productsLastCachedAt - ? `Latest product cache: ${formatProductsCacheTimestamp(productsLastCachedAt)}` + ? `Latest product update: ${formatProductsCacheTimestamp(productsLastCachedAt)}` : ""; } } @@ -1088,6 +1107,9 @@ }; productsLastCachedAt = 0; accountPlansRequestState = new Map(); + consumptionTrendCache = new Map(); + consumptionTrendWindowOffsets = new Map(); + consumptionTrendSeriesFilters = new Map(); stageDashboardAmounts = new Map([["TOTAL", 0], ...STAGE_OPTIONS.map((stage) => [stage, 0])]); opportunitiesTableState = { items: [], @@ -1261,7 +1283,16 @@ } else if (column.key === "status") { cell.append(createStatusBadge(column.display(item))); } else if (column.key === "actions") { - cell.append(createCloudConsumptionButton(item)); + const actionsContent = document.createElement("span"); + actionsContent.className = "opportunities-extension-actions-content"; + const productsResult = opptyProductsByNumber.get(String(item.OptyNumber)); + + if (productsResult) { + actionsContent.append(createProductsCountButton(item.OptyNumber, productsResult)); + } + + actionsContent.append(createCloudConsumptionButton(item)); + cell.append(actionsContent); } else if (column.key === "name" || column.key === "optyNumber") { const opportunityLink = createDetailLink( column.display(item), @@ -1272,13 +1303,6 @@ const nameCellContent = document.createElement("span"); nameCellContent.className = "opportunities-extension-name-content"; nameCellContent.append(opportunityLink); - - const productsResult = opptyProductsByNumber.get(String(item.OptyNumber)); - - if (productsResult) { - nameCellContent.append(createProductsCountButton(item.OptyNumber, productsResult)); - } - cell.append(nameCellContent); } else if (column.key === "optyNumber" && item.OptyNumber) { const opportunityCellContent = document.createElement("span"); @@ -1966,7 +1990,7 @@ button.type = "button"; button.className = "opportunities-extension-action-button"; button.setAttribute("aria-label", `View cloud consumption for ${optyNumber || "opportunity"}`); - button.title = isLoading ? "Loading cloud consumption" : "View cloud consumption"; + button.title = isLoading ? "Loading customer consumption" : "View customer consumption"; button.disabled = Boolean(isLoading); button.classList.toggle("opportunities-extension-action-button-loading", Boolean(isLoading)); button.append(createCloudConsumptionIcon()); @@ -2137,7 +2161,7 @@ panel.setAttribute("role", "tabpanel"); panel.setAttribute("aria-labelledby", tabId); panel.hidden = index !== 0; - panel.append(createAccountPlanDetails(plan)); + panel.append(createAccountPlanDetails(plan), createConsumptionTrendSection(plan)); tab.addEventListener("click", () => { tabList.querySelectorAll("[role='tab']").forEach((candidate) => { @@ -2148,6 +2172,12 @@ panels.querySelectorAll("[role='tabpanel']").forEach((candidate) => { candidate.hidden = candidate !== panel; }); + const trendSection = panel.querySelector(".opportunities-extension-consumption-trend"); + loadConsumptionTrendForPanel( + panel, + plan, + trendSection ? trendSection.dataset.period : DEFAULT_CONSUMPTION_TREND_PERIOD + ); }); tabList.append(tab); panels.append(panel); @@ -2160,6 +2190,14 @@ modal.append(dialog); overlay.append(modal); modal.querySelector("[role='tab'], .opportunities-extension-icon-button")?.focus(); + + if (state === "ready" && Array.isArray(plans) && plans.length > 0) { + const firstPanel = modal.querySelector("[role='tabpanel']"); + + if (firstPanel) { + loadConsumptionTrendForPanel(firstPanel, plans[0], DEFAULT_CONSUMPTION_TREND_PERIOD); + } + } } function createAccountPlansSkeleton() { @@ -2232,6 +2270,680 @@ return details; } + function createConsumptionTrendSection(plan) { + const section = document.createElement("section"); + section.className = "opportunities-extension-consumption-trend"; + section.dataset.subPlanNum = String(plan.subPlanNum || ""); + section.dataset.period = DEFAULT_CONSUMPTION_TREND_PERIOD; + + const header = document.createElement("header"); + header.className = "opportunities-extension-consumption-trend-header"; + + const periodControls = document.createElement("div"); + periodControls.className = "opportunities-extension-consumption-periods"; + periodControls.setAttribute("role", "group"); + periodControls.setAttribute("aria-label", "Consumption period"); + + CONSUMPTION_TREND_PERIODS.forEach((periodOption) => { + const button = document.createElement("button"); + button.type = "button"; + button.dataset.period = periodOption.value; + button.setAttribute("aria-pressed", String(periodOption.value === DEFAULT_CONSUMPTION_TREND_PERIOD)); + button.textContent = periodOption.label; + button.addEventListener("click", () => { + section.dataset.period = periodOption.value; + consumptionTrendWindowOffsets.set( + createConsumptionTrendKey(section.dataset.subPlanNum, periodOption.value), + 0 + ); + loadConsumptionTrendForPanel(section.closest("[role='tabpanel']"), plan, periodOption.value); + }); + periodControls.append(button); + }); + + const chart = document.createElement("div"); + chart.className = "opportunities-extension-consumption-chart"; + chart.setAttribute("aria-live", "polite"); + header.append(periodControls); + section.append(header, chart); + return section; + } + + function loadConsumptionTrendForPanel(panel, plan, period) { + if (!panel) { + return; + } + + const section = panel.querySelector(".opportunities-extension-consumption-trend"); + + if (!section) { + return; + } + + const subPlanNum = String(plan.subPlanNum || "").trim(); + const selectedPeriod = CONSUMPTION_TREND_PERIODS.some((item) => item.value === period) + ? period + : DEFAULT_CONSUMPTION_TREND_PERIOD; + const key = createConsumptionTrendKey(subPlanNum, selectedPeriod); + const chart = section.querySelector(".opportunities-extension-consumption-chart"); + section.dataset.period = selectedPeriod; + chart.dataset.requestKey = key; + updateConsumptionPeriodButtons(section, selectedPeriod); + + if (!subPlanNum) { + renderConsumptionTrendError(chart, "Sub plan number is unavailable."); + return; + } + + const cached = consumptionTrendCache.get(key); + + renderConsumptionTrendSkeleton(chart); + + if (cached && cached.status === "loading") { + cached.promise + .then((items) => renderConsumptionTrendIfCurrent(section, chart, items, selectedPeriod, subPlanNum, key)) + .catch((error) => renderConsumptionTrendErrorIfCurrent(chart, error.message, key)); + return; + } + + const request = requestConsumerTokenServiceOnce() + .then((authorizations) => sendRuntimeMessage({ + type: "opportunitiesExtension.requestConsumptionTrend", + subPlanNum, + period: selectedPeriod, + authorization: authorizations && authorizations.accountPlans + })) + .then((response) => { + appendBackgroundRequestLog(response && response.debugRequests); + + if (!response || !response.ok) { + throw new Error(response && response.error ? response.error : "Unable to load consumption trend."); + } + + return Array.isArray(response.items) ? response.items : []; + }) + .finally(() => { + if (consumptionTrendCache.get(key)?.promise === request) { + consumptionTrendCache.delete(key); + } + }); + + consumptionTrendCache.set(key, { status: "loading", promise: request }); + request + .then((items) => renderConsumptionTrendIfCurrent(section, chart, items, selectedPeriod, subPlanNum, key)) + .catch((error) => renderConsumptionTrendErrorIfCurrent(chart, error.message, key)); + } + + function createConsumptionTrendKey(subPlanNum, period) { + return `${subPlanNum}:${period}`; + } + + function updateConsumptionPeriodButtons(section, period) { + section.querySelectorAll(".opportunities-extension-consumption-periods button").forEach((button) => { + button.setAttribute("aria-pressed", String(button.dataset.period === period)); + }); + } + + function renderConsumptionTrendIfCurrent(section, chart, items, period, subPlanNum, key) { + if (chart.dataset.requestKey === key) { + renderConsumptionTrendChart(section, chart, items, period, subPlanNum); + } + } + + function renderConsumptionTrendErrorIfCurrent(chart, errorMessage, key) { + if (chart.dataset.requestKey === key) { + renderConsumptionTrendError(chart, errorMessage || "Unable to load consumption trend."); + } + } + + function renderConsumptionTrendSkeleton(chart) { + chart.textContent = ""; + chart.setAttribute("aria-busy", "true"); + const skeleton = document.createElement("div"); + skeleton.className = "opportunities-extension-consumption-chart-skeleton"; + const announcement = document.createElement("span"); + announcement.className = "opportunities-extension-visually-hidden"; + announcement.textContent = "Loading consumption trend"; + const plot = document.createElement("div"); + plot.className = "opportunities-extension-consumption-chart-skeleton-plot"; + + for (let index = 0; index < 13; index += 1) { + const bar = document.createElement("span"); + bar.style.height = `${28 + ((index * 17) % 58)}%`; + plot.append(bar); + } + + skeleton.append(announcement, plot); + chart.append(skeleton); + } + + function renderConsumptionTrendError(chart, errorMessage) { + chart.textContent = ""; + chart.setAttribute("aria-busy", "false"); + const error = document.createElement("p"); + error.className = "opportunities-extension-consumption-chart-error"; + error.setAttribute("role", "alert"); + error.textContent = errorMessage; + chart.append(error); + } + + function renderConsumptionTrendChart(section, chart, items, period, subPlanNum) { + chart.textContent = ""; + chart.setAttribute("aria-busy", "false"); + const points = normalizeConsumptionTrendItems(items); + + if (points.length === 0) { + const empty = document.createElement("p"); + empty.className = "opportunities-extension-consumption-chart-empty"; + empty.textContent = "No consumption data found for this period."; + chart.append(empty); + return; + } + + const option = CONSUMPTION_TREND_PERIODS.find((item) => item.value === period); + const windowSize = option ? option.windowSize : points.length; + const offsetKey = createConsumptionTrendKey(subPlanNum, period); + const maxOffset = Math.max(0, Math.ceil(points.length / windowSize) - 1); + const offset = Math.min(consumptionTrendWindowOffsets.get(offsetKey) || 0, maxOffset); + const end = Math.max(0, points.length - (offset * windowSize)); + const start = Math.max(0, end - windowSize); + const visiblePoints = points.slice(start, end); + consumptionTrendWindowOffsets.set(offsetKey, offset); + + const navigation = document.createElement("div"); + navigation.className = "opportunities-extension-consumption-navigation"; + const olderButton = createConsumptionNavigationButton("older", start === 0); + const newerButton = createConsumptionNavigationButton("newer", end === points.length); + const range = document.createElement("strong"); + range.textContent = formatConsumptionRange(visiblePoints, period); + olderButton.addEventListener("click", () => { + consumptionTrendWindowOffsets.set(offsetKey, Math.min(maxOffset, offset + 1)); + renderConsumptionTrendChart(section, chart, items, period, subPlanNum); + }); + newerButton.addEventListener("click", () => { + consumptionTrendWindowOffsets.set(offsetKey, Math.max(0, offset - 1)); + renderConsumptionTrendChart(section, chart, items, period, subPlanNum); + }); + navigation.append(olderButton, range, newerButton); + + const figure = document.createElement("figure"); + figure.className = "opportunities-extension-consumption-figure"; + const visual = document.createElement("div"); + visual.className = "opportunities-extension-consumption-visual"; + const tooltip = document.createElement("div"); + tooltip.className = "opportunities-extension-consumption-tooltip"; + tooltip.hidden = true; + tooltip.setAttribute("role", "tooltip"); + const activeSeries = getConsumptionTrendSeriesFilter(subPlanNum); + const svg = createConsumptionSvg( + visiblePoints, + period, + end === points.length, + activeSeries, + tooltip + ); + const legend = createConsumptionLegend( + section, + chart, + items, + period, + subPlanNum, + activeSeries + ); + visual.append(svg, legend); + figure.append(visual, tooltip); + chart.append(navigation, figure); + } + + function getConsumptionTrendSeriesFilter(subPlanNum) { + if (!consumptionTrendSeriesFilters.has(subPlanNum)) { + consumptionTrendSeriesFilters.set( + subPlanNum, + new Set(CONSUMPTION_TREND_SERIES.map((series) => series.value)) + ); + } + + return consumptionTrendSeriesFilters.get(subPlanNum); + } + + function createConsumptionLegend(section, chart, items, period, subPlanNum, activeSeries) { + const legend = document.createElement("div"); + legend.className = "opportunities-extension-consumption-legend"; + legend.setAttribute("role", "group"); + legend.setAttribute("aria-label", "Chart series filters"); + + CONSUMPTION_TREND_SERIES.forEach((series) => { + const button = document.createElement("button"); + const swatch = document.createElement("span"); + const label = document.createElement("span"); + button.type = "button"; + button.dataset.series = series.value; + button.setAttribute("aria-pressed", String(activeSeries.has(series.value))); + button.setAttribute("aria-label", `${activeSeries.has(series.value) ? "Hide" : "Show"} ${series.label}`); + swatch.className = `opportunities-extension-consumption-legend-swatch opportunities-extension-consumption-legend-${series.value}`; + swatch.setAttribute("aria-hidden", "true"); + label.textContent = series.label; + button.append(swatch, label); + button.addEventListener("click", () => { + if (activeSeries.has(series.value)) { + activeSeries.delete(series.value); + } else { + activeSeries.add(series.value); + } + + renderConsumptionTrendChart(section, chart, items, period, subPlanNum); + chart.querySelector(`[data-series="${series.value}"]`)?.focus(); + }); + legend.append(button); + }); + + return legend; + } + + function createConsumptionNavigationButton(direction, disabled) { + const button = document.createElement("button"); + button.type = "button"; + button.className = "opportunities-extension-consumption-navigation-button"; + button.disabled = disabled; + button.setAttribute("aria-label", direction === "older" ? "Show older consumption data" : "Show newer consumption data"); + button.title = direction === "older" ? "Older" : "Newer"; + const icon = createSvgElement("svg", { + viewBox: "0 0 24 24", + "aria-hidden": "true", + focusable: "false" + }); + icon.append(createSvgElement("path", { + d: direction === "older" ? "m14.5 5-7 7 7 7" : "m9.5 5 7 7-7 7", + fill: "none", + stroke: "currentColor", + "stroke-linecap": "round", + "stroke-linejoin": "round", + "stroke-width": "2" + })); + button.append(icon); + return button; + } + + function normalizeConsumptionTrendItems(items) { + const points = new Map(); + + (Array.isArray(items) ? items : []).forEach((item) => { + const dateKey = String(item.fyDate || (Array.isArray(item.fiscalTime) ? item.fiscalTime.join("|") : "")); + + if (!dateKey) { + return; + } + + if (!points.has(dateKey)) { + points.set(dateKey, { + dateKey, + date: parseConsumptionDate(item.fyDate), + fiscalTime: Array.isArray(item.fiscalTime) ? item.fiscalTime.slice() : [], + fiscalQtr: item.fiscalQtr || "", + funded: 0, + overage: 0, + arr: 0, + currencyCode: item.currencyCode || "USD" + }); + } + + const point = points.get(dateKey); + const amount = toFiniteNumber(item.usedAmount); + const category = String(item.usageCategory || "Funded Allocation").toUpperCase(); + + if (category === "OVERAGE") { + point.overage += amount; + } else { + point.funded += amount; + } + + point.arr = Math.max(point.arr, toFiniteNumber(item.arrCd)); + }); + + return Array.from(points.values()) + .map((point) => ({ ...point, total: point.funded + point.overage })) + .sort((left, right) => left.dateKey.localeCompare(right.dateKey)); + } + + function createConsumptionSvg(points, period, hasPartialLatestPoint, activeSeries, tooltip) { + const width = 1000; + const height = 320; + const plot = { left: 62, top: 24, right: 980, bottom: 258 }; + const plotWidth = plot.right - plot.left; + const plotHeight = plot.bottom - plot.top; + const average = calculateAverageConsumption(points, hasPartialLatestPoint, activeSeries); + const maximum = createNiceConsumptionMaximum(Math.max(average, ...points.map((point) => point.total))); + const svg = createSvgElement("svg", { + viewBox: `0 0 ${width} ${height}`, + role: "img", + "aria-label": `${period} cloud consumption chart` + }); + svg.classList.add("opportunities-extension-consumption-svg"); + + for (let tick = 0; tick <= 5; tick += 1) { + const value = (maximum / 5) * tick; + const y = plot.bottom - ((value / maximum) * plotHeight); + svg.append( + createSvgElement("line", { x1: plot.left, y1: y, x2: plot.right, y2: y, class: "consumption-grid-line" }), + createSvgText(plot.left - 10, y + 4, formatCompactConsumptionNumber(value), "consumption-axis-label", "end") + ); + } + + const step = plotWidth / Math.max(points.length, 1); + const barWidth = Math.min(44, Math.max(10, step * 0.58)); + + points.forEach((point, index) => { + const x = plot.left + (step * index) + ((step - barWidth) / 2); + const fundedAmount = activeSeries.has("funded") ? point.funded : 0; + const overageAmount = activeSeries.has("overage") ? point.overage : 0; + const visibleTotal = fundedAmount + overageAmount; + const fundedHeight = (fundedAmount / maximum) * plotHeight; + const overageHeight = (overageAmount / maximum) * plotHeight; + const group = createSvgElement("g", { + class: "consumption-bar-group", + tabindex: "0", + role: "img", + "aria-label": createConsumptionPointAriaLabel(point, period) + }); + + ["pointerenter", "pointermove", "focus"].forEach((eventName) => { + group.addEventListener(eventName, (event) => { + showConsumptionTooltip(tooltip, point, period, event); + }); + }); + ["pointerleave", "blur"].forEach((eventName) => { + group.addEventListener(eventName, () => { + tooltip.hidden = true; + }); + }); + + if (fundedHeight > 0) { + const fundedBar = createSvgElement("rect", { + x, + y: plot.bottom - fundedHeight, + width: barWidth, + height: fundedHeight, + class: "consumption-bar-funded" + }); + appendConsumptionBarAnimation( + fundedBar, + plot.bottom, + plot.bottom - fundedHeight, + fundedHeight, + index * 34 + ); + group.append(fundedBar); + } + + if (overageHeight > 0) { + const overageTop = plot.bottom - fundedHeight - overageHeight; + const overageBar = createSvgElement("rect", { + x, + y: overageTop, + width: barWidth, + height: overageHeight, + class: "consumption-bar-overage" + }); + appendConsumptionBarAnimation( + overageBar, + plot.bottom - fundedHeight, + overageTop, + overageHeight, + (index * 34) + 90 + ); + group.append(overageBar); + } + + const top = plot.bottom - fundedHeight - overageHeight; + + if (visibleTotal > 0) { + const valueLabel = createSvgText( + x + (barWidth / 2), + Math.max(plot.top + 11, top - 7), + formatCompactConsumptionNumber(visibleTotal), + "consumption-value-label consumption-animated-label", + "middle" + ); + valueLabel.style.animationDelay = `${(index * 34) + 360}ms`; + group.append(valueLabel); + } + + const label = createSvgText( + x + (barWidth / 2), + plot.bottom + 18, + formatConsumptionPointLabel(point, period), + "consumption-x-label", + period === "monthly" || period === "quarterly" ? "middle" : "end" + ); + + if (period === "weekly" || period === "daily") { + label.setAttribute("transform", `rotate(-65 ${x + (barWidth / 2)} ${plot.bottom + 18})`); + } + + group.append(label); + svg.append(group); + }); + + if (activeSeries.has("average")) { + const averageY = plot.bottom - ((average / maximum) * plotHeight); + const averageLine = createSvgElement("line", { + x1: plot.left, + y1: averageY, + x2: plot.right, + y2: averageY, + class: "consumption-average-line consumption-animated-average" + }); + svg.append(averageLine); + } + + return svg; + } + + function getConsumptionPointTotal(point, activeSeries) { + const selectedSeries = activeSeries && typeof activeSeries.has === "function" + ? activeSeries + : new Set(["funded", "overage"]); + return (selectedSeries.has("funded") ? point.funded : 0) + + (selectedSeries.has("overage") ? point.overage : 0); + } + + function calculateAverageConsumption(points, hasPartialLatestPoint, activeSeries) { + const completePoints = hasPartialLatestPoint && points.length > 1 ? points.slice(0, -1) : points; + return completePoints.reduce( + (sum, point) => sum + getConsumptionPointTotal(point, activeSeries), + 0 + ) / Math.max(completePoints.length, 1); + } + + function appendConsumptionBarAnimation(element, fromY, toY, height, delayMs) { + if (prefersReducedMotion()) { + return; + } + + element.append( + createSvgElement("animate", { + attributeName: "y", + from: fromY, + to: toY, + dur: "0.52s", + begin: `${delayMs}ms`, + fill: "freeze", + calcMode: "spline", + keyTimes: "0;1", + keySplines: "0.2 0.8 0.2 1" + }), + createSvgElement("animate", { + attributeName: "height", + from: "0", + to: height, + dur: "0.52s", + begin: `${delayMs}ms`, + fill: "freeze", + calcMode: "spline", + keyTimes: "0;1", + keySplines: "0.2 0.8 0.2 1" + }) + ); + } + + function prefersReducedMotion() { + return typeof window.matchMedia === "function" + && window.matchMedia("(prefers-reduced-motion: reduce)").matches; + } + + function createConsumptionPointAriaLabel(point, period) { + return `${formatConsumptionPointLabel(point, period)}. ` + + `Funded Allocation: ${formatConsumptionCurrency(point.funded, point.currencyCode)}. ` + + `Overage: ${formatConsumptionCurrency(point.overage, point.currencyCode)}. ` + + `Total: ${formatConsumptionCurrency(point.total, point.currencyCode)}.`; + } + + function showConsumptionTooltip(tooltip, point, period, event) { + const figure = tooltip.parentElement; + + if (!figure) { + return; + } + + tooltip.textContent = ""; + const title = document.createElement("strong"); + title.textContent = formatConsumptionPointLabel(point, period); + const details = document.createElement("span"); + details.textContent = `Funded Allocation: ${formatConsumptionCurrency(point.funded, point.currencyCode)}`; + const overage = document.createElement("span"); + overage.textContent = `Overage: ${formatConsumptionCurrency(point.overage, point.currencyCode)}`; + const total = document.createElement("span"); + total.textContent = `Total: ${formatConsumptionCurrency(point.total, point.currencyCode)}`; + tooltip.append(title, details, overage, total); + tooltip.hidden = false; + + const figureBounds = figure.getBoundingClientRect(); + const targetBounds = event.currentTarget.getBoundingClientRect(); + const pointerX = Number.isFinite(event.clientX) && event.clientX > 0 + ? event.clientX + : targetBounds.left + (targetBounds.width / 2); + const pointerY = Number.isFinite(event.clientY) && event.clientY > 0 + ? event.clientY + : targetBounds.top; + const left = Math.min( + Math.max(8, pointerX - figureBounds.left + 12), + Math.max(8, figureBounds.width - tooltip.offsetWidth - 8) + ); + const top = Math.max(8, pointerY - figureBounds.top - tooltip.offsetHeight - 10); + tooltip.style.left = `${left}px`; + tooltip.style.top = `${top}px`; + } + + function createSvgElement(name, attributes = {}) { + const element = document.createElementNS(SVG_NAMESPACE, name); + + Object.entries(attributes).forEach(([key, value]) => { + element.setAttribute(key, String(value)); + }); + return element; + } + + function createSvgText(x, y, value, className, anchor) { + const text = createSvgElement("text", { x, y, class: className, "text-anchor": anchor }); + text.textContent = value; + return text; + } + + function createNiceConsumptionMaximum(value) { + if (!Number.isFinite(value) || value <= 0) { + return 1; + } + + const magnitude = 10 ** Math.floor(Math.log10(value)); + const normalized = value / magnitude; + const step = normalized <= 1 ? 1 : normalized <= 2 ? 2 : normalized <= 5 ? 5 : 10; + return step * magnitude; + } + + function formatConsumptionPointLabel(point, period) { + if (period === "quarterly") { + return point.fiscalTime[0] || point.fiscalQtr || point.dateKey; + } + + if (period === "monthly") { + return point.fiscalTime[1] || formatConsumptionDate(point.date, { month: "short" }); + } + + return point.fiscalTime[0] || formatConsumptionDate(point.date, { + day: "2-digit", + month: "short", + year: "2-digit" + }); + } + + function formatConsumptionRange(points, period) { + if (points.length === 0) { + return "No data"; + } + + if (period === "quarterly") { + return `${formatConsumptionPointLabel(points[0], period)} - ${formatConsumptionPointLabel(points[points.length - 1], period)}`; + } + + if (period === "monthly") { + const options = { month: "short", year: "numeric" }; + return `${formatConsumptionDate(points[0].date, options)} - ${formatConsumptionDate(points[points.length - 1].date, options)}`; + } + + const options = { day: "2-digit", month: "short" }; + return `${formatConsumptionDate(points[0].date, options)} - ${formatConsumptionDate(points[points.length - 1].date, options)}`; + } + + function parseConsumptionDate(value) { + if (!value) { + return null; + } + + const parts = String(value).split("-").map(Number); + return parts.length === 3 && parts.every(Number.isFinite) + ? new Date(parts[0], parts[1] - 1, parts[2]) + : null; + } + + function formatConsumptionDate(date, options) { + if (!(date instanceof Date) || Number.isNaN(date.getTime())) { + return "-"; + } + + return new Intl.DateTimeFormat("en-US", options).format(date).replace(",", ""); + } + + function formatCompactConsumptionNumber(value) { + const amount = toFiniteNumber(value); + + if (Math.abs(amount) >= 1000000) { + return `${(amount / 1000000).toFixed(amount >= 10000000 ? 0 : 1).replace(".0", "")}M`; + } + + if (Math.abs(amount) >= 1000) { + return `${Math.round(amount / 1000)}K`; + } + + return String(Math.round(amount)); + } + + function formatConsumptionCurrency(value, currencyCode) { + try { + return new Intl.NumberFormat("en-US", { + style: "currency", + currency: currencyCode || "USD", + maximumFractionDigits: 0 + }).format(toFiniteNumber(value)); + } catch (error) { + return `${currencyCode || "USD"} ${Math.round(toFiniteNumber(value)).toLocaleString("en-US")}`; + } + } + + function toFiniteNumber(value) { + const number = Number(value); + return Number.isFinite(number) ? number : 0; + } + function closeAccountPlansModal() { const modal = document.getElementById(ACCOUNT_PLANS_MODAL_ID); @@ -4343,21 +5055,32 @@ min-width: 0; } - .opportunities-extension-products-count { + .opportunities-extension-actions-content { display: inline-flex; align-items: center; justify-content: center; + gap: 4px; + white-space: nowrap; + } + + .opportunities-extension-products-count { + display: inline-grid; + width: auto; + min-width: 20px; + height: 20px; + place-items: center; flex: 0 0 auto; - min-width: 24px; - min-height: 22px; - padding: 2px 7px; + box-sizing: border-box; + padding: 0 3px; border: 1px solid #7ca7b2; border-radius: 999px; background: #e8f4f6; color: #006d7a; - font-size: 11px; + font-size: 10px; font-weight: 700; line-height: 1; + text-align: center; + vertical-align: middle; cursor: pointer; } @@ -4711,7 +5434,7 @@ z-index: 4; display: grid; place-items: center; - padding: 24px; + padding: 12px; background: rgba(0, 0, 0, .42); } @@ -4721,8 +5444,8 @@ .opportunities-extension-account-plans-dialog { display: grid; - width: min(860px, 100%); - max-height: min(720px, calc(100vh - 48px)); + width: min(1180px, 100%); + height: min(760px, calc(100vh - 24px)); grid-template-rows: auto minmax(0, 1fr); overflow: hidden; border: 1px solid #8f8a85; @@ -4737,7 +5460,7 @@ align-items: flex-start; justify-content: space-between; gap: 16px; - padding: 18px 20px; + padding: 14px 20px; border-bottom: 1px solid #dedbd7; } @@ -4755,16 +5478,28 @@ } .opportunities-extension-account-plans-content { + display: grid; + grid-template-rows: auto minmax(0, 1fr); min-height: 0; - overflow: auto; + overflow: hidden; } .opportunities-extension-account-plans-tabs { display: flex; gap: 4px; overflow-x: auto; - padding: 12px 20px 0; + padding: 8px 20px 0; border-bottom: 1px solid #dedbd7; + scrollbar-width: none; + } + + .opportunities-extension-account-plans-tabs::-webkit-scrollbar { + display: none; + } + + .opportunities-extension-account-plans-panels { + min-height: 0; + overflow: hidden; } .opportunities-extension-account-plan-tab { @@ -4796,7 +5531,12 @@ } .opportunities-extension-account-plan-panel { - padding: 24px 20px 28px; + display: grid; + height: 100%; + min-height: 0; + grid-template-rows: auto minmax(0, 1fr); + box-sizing: border-box; + padding: 16px 20px; } .opportunities-extension-account-plan-panel[hidden] { @@ -4805,16 +5545,18 @@ .opportunities-extension-account-plan-details { display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 0 28px; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0 24px; margin: 0; } .opportunities-extension-account-plan-details > div { display: grid; - grid-template-columns: minmax(130px, 38%) minmax(0, 1fr); - gap: 12px; - padding: 13px 0; + grid-template-columns: minmax(104px, 38%) minmax(0, 1fr); + gap: 10px; + min-height: 42px; + align-items: center; + padding: 7px 0; border-bottom: 1px solid #ebe8e5; } @@ -4832,6 +5574,334 @@ overflow-wrap: anywhere; } + .opportunities-extension-consumption-trend { + display: grid; + min-height: 0; + grid-template-rows: auto minmax(0, 1fr); + margin-top: 14px; + padding-top: 12px; + border-top: 1px solid #dedbd7; + } + + .opportunities-extension-consumption-trend-header { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 24px; + } + + .opportunities-extension-consumption-periods { + display: flex; + align-items: center; + gap: 4px; + } + + .opportunities-extension-consumption-periods button { + min-height: 38px; + padding: 7px 14px; + border: 1px solid transparent; + border-radius: 4px; + background: transparent; + color: #312d2a; + font-size: 13px; + font-weight: 600; + line-height: 1.2; + cursor: pointer; + } + + .opportunities-extension-consumption-periods button:hover { + background: #f0eeeb; + } + + .opportunities-extension-consumption-periods button[aria-pressed="true"] { + border-color: #00758f; + background: #e7f6f8; + color: #005e73; + } + + .opportunities-extension-consumption-periods button:focus-visible, + .opportunities-extension-consumption-navigation-button:focus-visible { + outline: 2px solid #00758f; + outline-offset: 2px; + } + + .opportunities-extension-consumption-chart { + display: grid; + min-height: 0; + grid-template-rows: auto minmax(0, 1fr); + margin-top: 4px; + } + + .opportunities-extension-consumption-navigation { + display: grid; + grid-template-columns: 36px minmax(130px, auto) 36px; + align-items: center; + justify-content: center; + gap: 8px; + min-height: 40px; + margin-bottom: 2px; + } + + .opportunities-extension-consumption-navigation strong { + color: #312d2a; + font-size: 13px; + text-align: center; + } + + .opportunities-extension-consumption-navigation-button { + display: grid; + width: 36px; + height: 36px; + place-items: center; + padding: 0; + border: 1px solid #8f8a85; + border-radius: 4px; + background: #ffffff; + color: #312d2a; + cursor: pointer; + } + + .opportunities-extension-consumption-navigation-button:hover:not(:disabled) { + border-color: #00758f; + background: #f0eeeb; + color: #005e73; + } + + .opportunities-extension-consumption-navigation-button:disabled { + border-color: #dedbd7; + color: #b8b2ad; + cursor: default; + } + + .opportunities-extension-consumption-navigation-button svg { + width: 18px; + height: 18px; + } + + .opportunities-extension-consumption-figure { + position: relative; + min-width: 0; + min-height: 0; + margin: 0; + scrollbar-width: none; + } + + .opportunities-extension-consumption-figure::-webkit-scrollbar { + display: none; + } + + .opportunities-extension-consumption-visual { + display: grid; + height: 100%; + min-height: 0; + grid-template-columns: minmax(0, 1fr) 176px; + align-items: center; + gap: 16px; + } + + .opportunities-extension-consumption-svg { + display: block; + width: 100%; + height: 300px; + min-height: 0; + overflow: visible; + } + + .opportunities-extension-consumption-svg .consumption-grid-line { + stroke: #dedbd7; + stroke-width: 1; + shape-rendering: crispEdges; + } + + .opportunities-extension-consumption-svg .consumption-axis-label, + .opportunities-extension-consumption-svg .consumption-x-label { + fill: #5f5a55; + font-family: Arial, sans-serif; + font-size: 12px; + } + + .opportunities-extension-consumption-svg .consumption-value-label { + fill: #312d2a; + font-family: Arial, sans-serif; + font-size: 12px; + font-weight: 700; + } + + .opportunities-extension-consumption-svg .consumption-bar-funded { + fill: #b77888; + } + + .opportunities-extension-consumption-svg .consumption-bar-overage { + fill: #de7907; + } + + .opportunities-extension-consumption-svg .consumption-average-line { + stroke: #de7907; + stroke-width: 1.5; + stroke-dasharray: 6 6; + } + + .opportunities-extension-consumption-svg .consumption-bar-group { + cursor: default; + outline: none; + } + + .opportunities-extension-consumption-svg .consumption-bar-group rect { + transition: filter .16s ease, opacity .16s ease, stroke .16s ease; + } + + .opportunities-extension-consumption-svg .consumption-bar-group:hover rect, + .opportunities-extension-consumption-svg .consumption-bar-group:focus rect { + filter: brightness(1.06); + stroke: #312d2a; + stroke-width: 1.5; + } + + .opportunities-extension-consumption-svg .consumption-animated-label { + opacity: 0; + animation: opportunities-extension-consumption-label-in .24s ease-out forwards; + } + + .opportunities-extension-consumption-svg .consumption-animated-average { + opacity: 0; + animation: opportunities-extension-consumption-average-in .35s .32s ease-out forwards; + } + + @keyframes opportunities-extension-consumption-label-in { + from { opacity: 0; transform: translateY(4px); } + to { opacity: 1; transform: translateY(0); } + } + + @keyframes opportunities-extension-consumption-average-in { + from { opacity: 0; } + to { opacity: 1; } + } + + .opportunities-extension-consumption-legend { + display: grid; + align-content: center; + gap: 4px; + } + + .opportunities-extension-consumption-legend button { + display: grid; + min-width: 0; + grid-template-columns: 16px minmax(0, 1fr); + align-items: center; + gap: 8px; + padding: 6px 4px; + border: 0; + border-radius: 4px; + background: transparent; + color: #312d2a; + font-size: 12px; + line-height: 1.25; + text-align: left; + cursor: pointer; + } + + .opportunities-extension-consumption-legend button:hover { + background: #f0eeeb; + } + + .opportunities-extension-consumption-legend button:focus-visible { + outline: 2px solid #00758f; + outline-offset: 1px; + } + + .opportunities-extension-consumption-legend button[aria-pressed="false"] { + color: #77726d; + opacity: .55; + } + + .opportunities-extension-consumption-legend-swatch { + display: block; + width: 12px; + height: 12px; + justify-self: center; + } + + .opportunities-extension-consumption-legend-funded { + background: #b77888; + } + + .opportunities-extension-consumption-legend-overage { + background: #de7907; + } + + .opportunities-extension-consumption-legend-average { + height: 0; + border-top: 2px dashed #de7907; + } + + .opportunities-extension-consumption-tooltip { + position: absolute; + z-index: 2; + display: grid; + min-width: 190px; + max-width: 260px; + gap: 4px; + padding: 10px 12px; + border: 1px solid #8f8a85; + border-radius: 4px; + background: #ffffff; + box-shadow: 0 4px 12px rgba(0, 0, 0, .2); + color: #312d2a; + font-size: 12px; + line-height: 1.3; + pointer-events: none; + } + + .opportunities-extension-consumption-tooltip[hidden] { + display: none !important; + } + + .opportunities-extension-consumption-tooltip strong { + margin-bottom: 2px; + font-size: 13px; + } + + .opportunities-extension-consumption-chart-skeleton { + height: 100%; + min-height: 0; + padding: 34px 62px 24px; + box-sizing: border-box; + } + + .opportunities-extension-consumption-chart-skeleton-plot { + display: flex; + height: 100%; + min-height: 220px; + align-items: flex-end; + justify-content: space-around; + gap: 12px; + padding: 0 14px; + border-bottom: 1px solid #dedbd7; + border-left: 1px solid #dedbd7; + } + + .opportunities-extension-consumption-chart-skeleton-plot span { + width: min(38px, 6%); + border-radius: 2px 2px 0 0; + background: #dedbd7; + animation: opportunities-extension-skeleton-pulse 1.15s ease-in-out infinite alternate; + } + + .opportunities-extension-consumption-chart-empty, + .opportunities-extension-consumption-chart-error { + display: grid; + min-height: 220px; + place-items: center; + margin: 0; + color: #5f5a55; + font-size: 14px; + text-align: center; + } + + .opportunities-extension-consumption-chart-error { + color: #9f2d20; + } + .opportunities-extension-account-plan-status-indicator { display: inline-block; width: 10px; @@ -4899,8 +5969,12 @@ } @media (prefers-reduced-motion: reduce) { - .opportunities-extension-account-plans-skeleton-bar { + .opportunities-extension-account-plans-skeleton-bar, + .opportunities-extension-consumption-chart-skeleton-plot span, + .opportunities-extension-consumption-svg .consumption-animated-label, + .opportunities-extension-consumption-svg .consumption-animated-average { animation: none; + opacity: 1; } } @@ -5282,10 +6356,18 @@ color: #ffb4a8; } + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-chart-error { + color: #ffb4a8; + } + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-skeleton-bar { background: #5b5753; } + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-chart-skeleton-plot span { + background: #5b5753; + } + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plan-status-indicator { background: #8f8a85; box-shadow: 0 0 0 3px #3b3937; @@ -5354,6 +6436,14 @@ color: #f6f4f2; } + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-navigation strong { + color: #f6f4f2; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-chart-empty { + color: #c9c5c1; + } + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-customer-filter-select-all, .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-table th { border-bottom-color: #4e4a46; @@ -5369,6 +6459,11 @@ border-color: #4e4a46; } + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-trend, + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-chart-skeleton-plot { + border-color: #4e4a46; + } + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plan-tab { color: #c9c5c1; } @@ -5383,6 +6478,97 @@ color: #91e4ed; } + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-periods button { + color: #f6f4f2; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-periods button:hover { + background: #3b3937; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-periods button[aria-pressed="true"] { + border-color: #43c4d5; + background: #173e43; + color: #91e4ed; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-navigation-button { + border-color: #6a6560; + background: #333130; + color: #f6f4f2; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-navigation-button:hover:not(:disabled) { + border-color: #43c4d5; + background: #3b3937; + color: #91e4ed; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-navigation-button:disabled { + border-color: #4e4a46; + color: #746f6a; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-svg .consumption-grid-line { + stroke: #4e4a46; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-svg .consumption-axis-label, + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-svg .consumption-x-label { + fill: #c9c5c1; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-svg .consumption-value-label { + fill: #f6f4f2; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-svg .consumption-bar-funded { + fill: #ca8798; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-svg .consumption-bar-overage { + fill: #f09a32; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-svg .consumption-average-line { + stroke: #f09a32; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-svg .consumption-bar-group:hover rect, + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-svg .consumption-bar-group:focus rect { + stroke: #f6f4f2; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-legend button { + color: #f6f4f2; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-legend button:hover { + background: #3b3937; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-legend button[aria-pressed="false"] { + color: #a8a39e; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-legend-funded { + background: #ca8798; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-legend-overage { + background: #f09a32; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-legend-average { + border-top-color: #f09a32; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-tooltip { + border-color: #6a6560; + background: #333130; + color: #f6f4f2; + } + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-preferences-option:hover { background: #3b3937; } @@ -5733,17 +6919,77 @@ } .opportunities-extension-account-plans-dialog { - max-height: calc(100vh - 24px); + height: calc(100vh - 24px); + } + + .opportunities-extension-account-plan-panel { + padding: 12px 14px; } .opportunities-extension-account-plan-details { + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0 14px; + } + + .opportunities-extension-account-plan-details > div { grid-template-columns: 1fr; + align-content: center; + gap: 3px; + min-height: 54px; + padding: 5px 0; } .opportunities-extension-account-plans-skeleton-details { grid-template-columns: 1fr; } + .opportunities-extension-consumption-trend-header { + align-items: stretch; + } + + .opportunities-extension-consumption-periods { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 3px; + } + + .opportunities-extension-consumption-periods button { + min-width: 0; + padding: 7px 4px; + font-size: 12px; + } + + .opportunities-extension-consumption-chart { + min-height: 0; + } + + .opportunities-extension-consumption-svg { + width: 760px; + height: 245px; + } + + .opportunities-extension-consumption-visual { + width: 920px; + grid-template-columns: 760px 144px; + gap: 12px; + } + + .opportunities-extension-consumption-figure { + overflow-x: auto; + overscroll-behavior-inline: contain; + } + + .opportunities-extension-consumption-chart-skeleton { + min-height: 0; + padding: 24px 18px 18px; + } + + .opportunities-extension-consumption-chart-skeleton-plot { + min-height: 180px; + gap: 4px; + padding: 0 6px; + } + .opportunities-extension-preferences-options { grid-template-columns: 1fr; } diff --git a/dist/firefox/background.js b/dist/firefox/background.js index d8f0fa1..4200856 100644 --- a/dist/firefox/background.js +++ b/dist/firefox/background.js @@ -20,6 +20,13 @@ const CUSTOMER_TOKEN_SERVICE_URL = "https://spa.oracle.com/oalcrm/web/api/g2m-consumer-application/consumerTokenService"; const ACCOUNT_SUMMARY_URL = "https://spa.oracle.com/oalcrm/web/api/provider-proxy/g2m-account/service/accountsummary"; const ACCOUNT_PLANS_URL = "https://spa.oracle.com/oalcrm/web/api/provider-proxy/g2m-consumption/service/v1/account/accountPlans"; + const CONSUMPTION_TREND_URL = "https://spa.oracle.com/oalcrm/web/api/provider-proxy/g2m-consumption/service/v1/plan/trend"; + const CONSUMPTION_TREND_REQUESTS = { + quarterly: "requestConsumptionServiceQuarterly", + monthly: "requestConsumptionServiceMonthly", + weekly: "requestConsumptionServiceWeekly", + daily: "requestConsumptionServiceDaily" + }; const ACCOUNT_PLANS_PAYLOAD = { hierarchy: "single", products: "all", @@ -149,6 +156,20 @@ return true; } + if (message.type === "opportunitiesExtension.requestConsumptionTrend") { + requestConsumptionTrend(message) + .then(sendResponse) + .catch((error) => { + sendResponse({ + ok: false, + error: error.message || "Unable to load consumption trend.", + debugRequests: Array.isArray(error.debugRequests) ? error.debugRequests : [] + }); + }); + + return true; + } + return false; }); @@ -282,6 +303,56 @@ } } + async function requestConsumptionTrend(message) { + const subPlanNum = typeof message.subPlanNum === "string" ? message.subPlanNum.trim() : ""; + const period = typeof message.period === "string" ? message.period.toLowerCase() : ""; + const requestName = CONSUMPTION_TREND_REQUESTS[period]; + const authorization = normalizeMessageAuthorization(message.authorization, "g2m-authorization"); + const debugRequests = []; + + if (!subPlanNum) { + throw new Error("Sub plan number is required for the consumption trend."); + } + + if (!requestName) { + throw new Error(`Unsupported consumption trend period: ${period || "(missing)"}.`); + } + + if (!authorization.value) { + throw new Error("OCICONS-TS authorization is unavailable for the consumption trend."); + } + + const url = `${CONSUMPTION_TREND_URL}/${encodeURIComponent(subPlanNum)}?quarter=All&timePeriod=${period}`; + + try { + const response = await fetchJson(url, { + method: "GET", + credentials: "include", + cache: "no-store", + headers: { + Accept: "application/json", + "G2m-Authorization": authorization.value + } + }, requestName, debugRequests); + const items = Array.isArray(response) + ? response + : Array.isArray(response && response.items) + ? response.items + : []; + + return { + ok: true, + subPlanNum, + period, + items, + debugRequests + }; + } catch (error) { + error.debugRequests = debugRequests; + throw error; + } + } + function normalizeMessageAuthorization(authorization, fallbackHeaderName) { const headerName = authorization && typeof authorization.name === "string" ? authorization.name.trim() diff --git a/dist/firefox/content.js b/dist/firefox/content.js index ade6ea6..51bc626 100644 --- a/dist/firefox/content.js +++ b/dist/firefox/content.js @@ -45,6 +45,19 @@ authenticated: "authenticated", unauthenticated: "unauthenticated" }; + const CONSUMPTION_TREND_PERIODS = [ + { value: "quarterly", label: "Quarterly", windowSize: 8 }, + { value: "monthly", label: "Monthly", windowSize: 13 }, + { value: "weekly", label: "Weekly", windowSize: 13 }, + { value: "daily", label: "Daily", windowSize: 30 } + ]; + const CONSUMPTION_TREND_SERIES = [ + { value: "funded", label: "Funded Allocation" }, + { value: "overage", label: "Overage" }, + { value: "average", label: "Average Consumption" } + ]; + const DEFAULT_CONSUMPTION_TREND_PERIOD = "weekly"; + const SVG_NAMESPACE = "http://www.w3.org/2000/svg"; const STAGE_OPTIONS = ["SQL", "PIPELINE", "UPSIDE", "FORECAST", "WON"]; const STATUS_OPTIONS = ["OPEN", "CLOSED", "LOST", "WON"]; const OPPORTUNITY_TYPE_OPTIONS = [ @@ -102,6 +115,9 @@ }; let productsLastCachedAt = 0; let accountPlansRequestState = new Map(); + let consumptionTrendCache = new Map(); + let consumptionTrendWindowOffsets = new Map(); + let consumptionTrendSeriesFilters = new Map(); let hasSavedCustomerFilter = false; let hasSavedOwnerFilter = false; let customerFilterInitialized = false; @@ -777,6 +793,9 @@ }; productsLastCachedAt = 0; accountPlansRequestState = new Map(); + consumptionTrendCache = new Map(); + consumptionTrendWindowOffsets = new Map(); + consumptionTrendSeriesFilters = new Map(); renderProductsProgress(); setOpportunitiesTableState({ items: [], @@ -1033,10 +1052,10 @@ if (timestamp) { timestamp.hidden = !productsLastCachedAt; timestamp.textContent = productsLastCachedAt - ? `Last cache: ${formatProductsCacheTimestamp(productsLastCachedAt)}` + ? `Last update: ${formatProductsCacheTimestamp(productsLastCachedAt)}` : ""; timestamp.title = productsLastCachedAt - ? `Latest product cache: ${formatProductsCacheTimestamp(productsLastCachedAt)}` + ? `Latest product update: ${formatProductsCacheTimestamp(productsLastCachedAt)}` : ""; } } @@ -1088,6 +1107,9 @@ }; productsLastCachedAt = 0; accountPlansRequestState = new Map(); + consumptionTrendCache = new Map(); + consumptionTrendWindowOffsets = new Map(); + consumptionTrendSeriesFilters = new Map(); stageDashboardAmounts = new Map([["TOTAL", 0], ...STAGE_OPTIONS.map((stage) => [stage, 0])]); opportunitiesTableState = { items: [], @@ -1261,7 +1283,16 @@ } else if (column.key === "status") { cell.append(createStatusBadge(column.display(item))); } else if (column.key === "actions") { - cell.append(createCloudConsumptionButton(item)); + const actionsContent = document.createElement("span"); + actionsContent.className = "opportunities-extension-actions-content"; + const productsResult = opptyProductsByNumber.get(String(item.OptyNumber)); + + if (productsResult) { + actionsContent.append(createProductsCountButton(item.OptyNumber, productsResult)); + } + + actionsContent.append(createCloudConsumptionButton(item)); + cell.append(actionsContent); } else if (column.key === "name" || column.key === "optyNumber") { const opportunityLink = createDetailLink( column.display(item), @@ -1272,13 +1303,6 @@ const nameCellContent = document.createElement("span"); nameCellContent.className = "opportunities-extension-name-content"; nameCellContent.append(opportunityLink); - - const productsResult = opptyProductsByNumber.get(String(item.OptyNumber)); - - if (productsResult) { - nameCellContent.append(createProductsCountButton(item.OptyNumber, productsResult)); - } - cell.append(nameCellContent); } else if (column.key === "optyNumber" && item.OptyNumber) { const opportunityCellContent = document.createElement("span"); @@ -1966,7 +1990,7 @@ button.type = "button"; button.className = "opportunities-extension-action-button"; button.setAttribute("aria-label", `View cloud consumption for ${optyNumber || "opportunity"}`); - button.title = isLoading ? "Loading cloud consumption" : "View cloud consumption"; + button.title = isLoading ? "Loading customer consumption" : "View customer consumption"; button.disabled = Boolean(isLoading); button.classList.toggle("opportunities-extension-action-button-loading", Boolean(isLoading)); button.append(createCloudConsumptionIcon()); @@ -2137,7 +2161,7 @@ panel.setAttribute("role", "tabpanel"); panel.setAttribute("aria-labelledby", tabId); panel.hidden = index !== 0; - panel.append(createAccountPlanDetails(plan)); + panel.append(createAccountPlanDetails(plan), createConsumptionTrendSection(plan)); tab.addEventListener("click", () => { tabList.querySelectorAll("[role='tab']").forEach((candidate) => { @@ -2148,6 +2172,12 @@ panels.querySelectorAll("[role='tabpanel']").forEach((candidate) => { candidate.hidden = candidate !== panel; }); + const trendSection = panel.querySelector(".opportunities-extension-consumption-trend"); + loadConsumptionTrendForPanel( + panel, + plan, + trendSection ? trendSection.dataset.period : DEFAULT_CONSUMPTION_TREND_PERIOD + ); }); tabList.append(tab); panels.append(panel); @@ -2160,6 +2190,14 @@ modal.append(dialog); overlay.append(modal); modal.querySelector("[role='tab'], .opportunities-extension-icon-button")?.focus(); + + if (state === "ready" && Array.isArray(plans) && plans.length > 0) { + const firstPanel = modal.querySelector("[role='tabpanel']"); + + if (firstPanel) { + loadConsumptionTrendForPanel(firstPanel, plans[0], DEFAULT_CONSUMPTION_TREND_PERIOD); + } + } } function createAccountPlansSkeleton() { @@ -2232,6 +2270,680 @@ return details; } + function createConsumptionTrendSection(plan) { + const section = document.createElement("section"); + section.className = "opportunities-extension-consumption-trend"; + section.dataset.subPlanNum = String(plan.subPlanNum || ""); + section.dataset.period = DEFAULT_CONSUMPTION_TREND_PERIOD; + + const header = document.createElement("header"); + header.className = "opportunities-extension-consumption-trend-header"; + + const periodControls = document.createElement("div"); + periodControls.className = "opportunities-extension-consumption-periods"; + periodControls.setAttribute("role", "group"); + periodControls.setAttribute("aria-label", "Consumption period"); + + CONSUMPTION_TREND_PERIODS.forEach((periodOption) => { + const button = document.createElement("button"); + button.type = "button"; + button.dataset.period = periodOption.value; + button.setAttribute("aria-pressed", String(periodOption.value === DEFAULT_CONSUMPTION_TREND_PERIOD)); + button.textContent = periodOption.label; + button.addEventListener("click", () => { + section.dataset.period = periodOption.value; + consumptionTrendWindowOffsets.set( + createConsumptionTrendKey(section.dataset.subPlanNum, periodOption.value), + 0 + ); + loadConsumptionTrendForPanel(section.closest("[role='tabpanel']"), plan, periodOption.value); + }); + periodControls.append(button); + }); + + const chart = document.createElement("div"); + chart.className = "opportunities-extension-consumption-chart"; + chart.setAttribute("aria-live", "polite"); + header.append(periodControls); + section.append(header, chart); + return section; + } + + function loadConsumptionTrendForPanel(panel, plan, period) { + if (!panel) { + return; + } + + const section = panel.querySelector(".opportunities-extension-consumption-trend"); + + if (!section) { + return; + } + + const subPlanNum = String(plan.subPlanNum || "").trim(); + const selectedPeriod = CONSUMPTION_TREND_PERIODS.some((item) => item.value === period) + ? period + : DEFAULT_CONSUMPTION_TREND_PERIOD; + const key = createConsumptionTrendKey(subPlanNum, selectedPeriod); + const chart = section.querySelector(".opportunities-extension-consumption-chart"); + section.dataset.period = selectedPeriod; + chart.dataset.requestKey = key; + updateConsumptionPeriodButtons(section, selectedPeriod); + + if (!subPlanNum) { + renderConsumptionTrendError(chart, "Sub plan number is unavailable."); + return; + } + + const cached = consumptionTrendCache.get(key); + + renderConsumptionTrendSkeleton(chart); + + if (cached && cached.status === "loading") { + cached.promise + .then((items) => renderConsumptionTrendIfCurrent(section, chart, items, selectedPeriod, subPlanNum, key)) + .catch((error) => renderConsumptionTrendErrorIfCurrent(chart, error.message, key)); + return; + } + + const request = requestConsumerTokenServiceOnce() + .then((authorizations) => sendRuntimeMessage({ + type: "opportunitiesExtension.requestConsumptionTrend", + subPlanNum, + period: selectedPeriod, + authorization: authorizations && authorizations.accountPlans + })) + .then((response) => { + appendBackgroundRequestLog(response && response.debugRequests); + + if (!response || !response.ok) { + throw new Error(response && response.error ? response.error : "Unable to load consumption trend."); + } + + return Array.isArray(response.items) ? response.items : []; + }) + .finally(() => { + if (consumptionTrendCache.get(key)?.promise === request) { + consumptionTrendCache.delete(key); + } + }); + + consumptionTrendCache.set(key, { status: "loading", promise: request }); + request + .then((items) => renderConsumptionTrendIfCurrent(section, chart, items, selectedPeriod, subPlanNum, key)) + .catch((error) => renderConsumptionTrendErrorIfCurrent(chart, error.message, key)); + } + + function createConsumptionTrendKey(subPlanNum, period) { + return `${subPlanNum}:${period}`; + } + + function updateConsumptionPeriodButtons(section, period) { + section.querySelectorAll(".opportunities-extension-consumption-periods button").forEach((button) => { + button.setAttribute("aria-pressed", String(button.dataset.period === period)); + }); + } + + function renderConsumptionTrendIfCurrent(section, chart, items, period, subPlanNum, key) { + if (chart.dataset.requestKey === key) { + renderConsumptionTrendChart(section, chart, items, period, subPlanNum); + } + } + + function renderConsumptionTrendErrorIfCurrent(chart, errorMessage, key) { + if (chart.dataset.requestKey === key) { + renderConsumptionTrendError(chart, errorMessage || "Unable to load consumption trend."); + } + } + + function renderConsumptionTrendSkeleton(chart) { + chart.textContent = ""; + chart.setAttribute("aria-busy", "true"); + const skeleton = document.createElement("div"); + skeleton.className = "opportunities-extension-consumption-chart-skeleton"; + const announcement = document.createElement("span"); + announcement.className = "opportunities-extension-visually-hidden"; + announcement.textContent = "Loading consumption trend"; + const plot = document.createElement("div"); + plot.className = "opportunities-extension-consumption-chart-skeleton-plot"; + + for (let index = 0; index < 13; index += 1) { + const bar = document.createElement("span"); + bar.style.height = `${28 + ((index * 17) % 58)}%`; + plot.append(bar); + } + + skeleton.append(announcement, plot); + chart.append(skeleton); + } + + function renderConsumptionTrendError(chart, errorMessage) { + chart.textContent = ""; + chart.setAttribute("aria-busy", "false"); + const error = document.createElement("p"); + error.className = "opportunities-extension-consumption-chart-error"; + error.setAttribute("role", "alert"); + error.textContent = errorMessage; + chart.append(error); + } + + function renderConsumptionTrendChart(section, chart, items, period, subPlanNum) { + chart.textContent = ""; + chart.setAttribute("aria-busy", "false"); + const points = normalizeConsumptionTrendItems(items); + + if (points.length === 0) { + const empty = document.createElement("p"); + empty.className = "opportunities-extension-consumption-chart-empty"; + empty.textContent = "No consumption data found for this period."; + chart.append(empty); + return; + } + + const option = CONSUMPTION_TREND_PERIODS.find((item) => item.value === period); + const windowSize = option ? option.windowSize : points.length; + const offsetKey = createConsumptionTrendKey(subPlanNum, period); + const maxOffset = Math.max(0, Math.ceil(points.length / windowSize) - 1); + const offset = Math.min(consumptionTrendWindowOffsets.get(offsetKey) || 0, maxOffset); + const end = Math.max(0, points.length - (offset * windowSize)); + const start = Math.max(0, end - windowSize); + const visiblePoints = points.slice(start, end); + consumptionTrendWindowOffsets.set(offsetKey, offset); + + const navigation = document.createElement("div"); + navigation.className = "opportunities-extension-consumption-navigation"; + const olderButton = createConsumptionNavigationButton("older", start === 0); + const newerButton = createConsumptionNavigationButton("newer", end === points.length); + const range = document.createElement("strong"); + range.textContent = formatConsumptionRange(visiblePoints, period); + olderButton.addEventListener("click", () => { + consumptionTrendWindowOffsets.set(offsetKey, Math.min(maxOffset, offset + 1)); + renderConsumptionTrendChart(section, chart, items, period, subPlanNum); + }); + newerButton.addEventListener("click", () => { + consumptionTrendWindowOffsets.set(offsetKey, Math.max(0, offset - 1)); + renderConsumptionTrendChart(section, chart, items, period, subPlanNum); + }); + navigation.append(olderButton, range, newerButton); + + const figure = document.createElement("figure"); + figure.className = "opportunities-extension-consumption-figure"; + const visual = document.createElement("div"); + visual.className = "opportunities-extension-consumption-visual"; + const tooltip = document.createElement("div"); + tooltip.className = "opportunities-extension-consumption-tooltip"; + tooltip.hidden = true; + tooltip.setAttribute("role", "tooltip"); + const activeSeries = getConsumptionTrendSeriesFilter(subPlanNum); + const svg = createConsumptionSvg( + visiblePoints, + period, + end === points.length, + activeSeries, + tooltip + ); + const legend = createConsumptionLegend( + section, + chart, + items, + period, + subPlanNum, + activeSeries + ); + visual.append(svg, legend); + figure.append(visual, tooltip); + chart.append(navigation, figure); + } + + function getConsumptionTrendSeriesFilter(subPlanNum) { + if (!consumptionTrendSeriesFilters.has(subPlanNum)) { + consumptionTrendSeriesFilters.set( + subPlanNum, + new Set(CONSUMPTION_TREND_SERIES.map((series) => series.value)) + ); + } + + return consumptionTrendSeriesFilters.get(subPlanNum); + } + + function createConsumptionLegend(section, chart, items, period, subPlanNum, activeSeries) { + const legend = document.createElement("div"); + legend.className = "opportunities-extension-consumption-legend"; + legend.setAttribute("role", "group"); + legend.setAttribute("aria-label", "Chart series filters"); + + CONSUMPTION_TREND_SERIES.forEach((series) => { + const button = document.createElement("button"); + const swatch = document.createElement("span"); + const label = document.createElement("span"); + button.type = "button"; + button.dataset.series = series.value; + button.setAttribute("aria-pressed", String(activeSeries.has(series.value))); + button.setAttribute("aria-label", `${activeSeries.has(series.value) ? "Hide" : "Show"} ${series.label}`); + swatch.className = `opportunities-extension-consumption-legend-swatch opportunities-extension-consumption-legend-${series.value}`; + swatch.setAttribute("aria-hidden", "true"); + label.textContent = series.label; + button.append(swatch, label); + button.addEventListener("click", () => { + if (activeSeries.has(series.value)) { + activeSeries.delete(series.value); + } else { + activeSeries.add(series.value); + } + + renderConsumptionTrendChart(section, chart, items, period, subPlanNum); + chart.querySelector(`[data-series="${series.value}"]`)?.focus(); + }); + legend.append(button); + }); + + return legend; + } + + function createConsumptionNavigationButton(direction, disabled) { + const button = document.createElement("button"); + button.type = "button"; + button.className = "opportunities-extension-consumption-navigation-button"; + button.disabled = disabled; + button.setAttribute("aria-label", direction === "older" ? "Show older consumption data" : "Show newer consumption data"); + button.title = direction === "older" ? "Older" : "Newer"; + const icon = createSvgElement("svg", { + viewBox: "0 0 24 24", + "aria-hidden": "true", + focusable: "false" + }); + icon.append(createSvgElement("path", { + d: direction === "older" ? "m14.5 5-7 7 7 7" : "m9.5 5 7 7-7 7", + fill: "none", + stroke: "currentColor", + "stroke-linecap": "round", + "stroke-linejoin": "round", + "stroke-width": "2" + })); + button.append(icon); + return button; + } + + function normalizeConsumptionTrendItems(items) { + const points = new Map(); + + (Array.isArray(items) ? items : []).forEach((item) => { + const dateKey = String(item.fyDate || (Array.isArray(item.fiscalTime) ? item.fiscalTime.join("|") : "")); + + if (!dateKey) { + return; + } + + if (!points.has(dateKey)) { + points.set(dateKey, { + dateKey, + date: parseConsumptionDate(item.fyDate), + fiscalTime: Array.isArray(item.fiscalTime) ? item.fiscalTime.slice() : [], + fiscalQtr: item.fiscalQtr || "", + funded: 0, + overage: 0, + arr: 0, + currencyCode: item.currencyCode || "USD" + }); + } + + const point = points.get(dateKey); + const amount = toFiniteNumber(item.usedAmount); + const category = String(item.usageCategory || "Funded Allocation").toUpperCase(); + + if (category === "OVERAGE") { + point.overage += amount; + } else { + point.funded += amount; + } + + point.arr = Math.max(point.arr, toFiniteNumber(item.arrCd)); + }); + + return Array.from(points.values()) + .map((point) => ({ ...point, total: point.funded + point.overage })) + .sort((left, right) => left.dateKey.localeCompare(right.dateKey)); + } + + function createConsumptionSvg(points, period, hasPartialLatestPoint, activeSeries, tooltip) { + const width = 1000; + const height = 320; + const plot = { left: 62, top: 24, right: 980, bottom: 258 }; + const plotWidth = plot.right - plot.left; + const plotHeight = plot.bottom - plot.top; + const average = calculateAverageConsumption(points, hasPartialLatestPoint, activeSeries); + const maximum = createNiceConsumptionMaximum(Math.max(average, ...points.map((point) => point.total))); + const svg = createSvgElement("svg", { + viewBox: `0 0 ${width} ${height}`, + role: "img", + "aria-label": `${period} cloud consumption chart` + }); + svg.classList.add("opportunities-extension-consumption-svg"); + + for (let tick = 0; tick <= 5; tick += 1) { + const value = (maximum / 5) * tick; + const y = plot.bottom - ((value / maximum) * plotHeight); + svg.append( + createSvgElement("line", { x1: plot.left, y1: y, x2: plot.right, y2: y, class: "consumption-grid-line" }), + createSvgText(plot.left - 10, y + 4, formatCompactConsumptionNumber(value), "consumption-axis-label", "end") + ); + } + + const step = plotWidth / Math.max(points.length, 1); + const barWidth = Math.min(44, Math.max(10, step * 0.58)); + + points.forEach((point, index) => { + const x = plot.left + (step * index) + ((step - barWidth) / 2); + const fundedAmount = activeSeries.has("funded") ? point.funded : 0; + const overageAmount = activeSeries.has("overage") ? point.overage : 0; + const visibleTotal = fundedAmount + overageAmount; + const fundedHeight = (fundedAmount / maximum) * plotHeight; + const overageHeight = (overageAmount / maximum) * plotHeight; + const group = createSvgElement("g", { + class: "consumption-bar-group", + tabindex: "0", + role: "img", + "aria-label": createConsumptionPointAriaLabel(point, period) + }); + + ["pointerenter", "pointermove", "focus"].forEach((eventName) => { + group.addEventListener(eventName, (event) => { + showConsumptionTooltip(tooltip, point, period, event); + }); + }); + ["pointerleave", "blur"].forEach((eventName) => { + group.addEventListener(eventName, () => { + tooltip.hidden = true; + }); + }); + + if (fundedHeight > 0) { + const fundedBar = createSvgElement("rect", { + x, + y: plot.bottom - fundedHeight, + width: barWidth, + height: fundedHeight, + class: "consumption-bar-funded" + }); + appendConsumptionBarAnimation( + fundedBar, + plot.bottom, + plot.bottom - fundedHeight, + fundedHeight, + index * 34 + ); + group.append(fundedBar); + } + + if (overageHeight > 0) { + const overageTop = plot.bottom - fundedHeight - overageHeight; + const overageBar = createSvgElement("rect", { + x, + y: overageTop, + width: barWidth, + height: overageHeight, + class: "consumption-bar-overage" + }); + appendConsumptionBarAnimation( + overageBar, + plot.bottom - fundedHeight, + overageTop, + overageHeight, + (index * 34) + 90 + ); + group.append(overageBar); + } + + const top = plot.bottom - fundedHeight - overageHeight; + + if (visibleTotal > 0) { + const valueLabel = createSvgText( + x + (barWidth / 2), + Math.max(plot.top + 11, top - 7), + formatCompactConsumptionNumber(visibleTotal), + "consumption-value-label consumption-animated-label", + "middle" + ); + valueLabel.style.animationDelay = `${(index * 34) + 360}ms`; + group.append(valueLabel); + } + + const label = createSvgText( + x + (barWidth / 2), + plot.bottom + 18, + formatConsumptionPointLabel(point, period), + "consumption-x-label", + period === "monthly" || period === "quarterly" ? "middle" : "end" + ); + + if (period === "weekly" || period === "daily") { + label.setAttribute("transform", `rotate(-65 ${x + (barWidth / 2)} ${plot.bottom + 18})`); + } + + group.append(label); + svg.append(group); + }); + + if (activeSeries.has("average")) { + const averageY = plot.bottom - ((average / maximum) * plotHeight); + const averageLine = createSvgElement("line", { + x1: plot.left, + y1: averageY, + x2: plot.right, + y2: averageY, + class: "consumption-average-line consumption-animated-average" + }); + svg.append(averageLine); + } + + return svg; + } + + function getConsumptionPointTotal(point, activeSeries) { + const selectedSeries = activeSeries && typeof activeSeries.has === "function" + ? activeSeries + : new Set(["funded", "overage"]); + return (selectedSeries.has("funded") ? point.funded : 0) + + (selectedSeries.has("overage") ? point.overage : 0); + } + + function calculateAverageConsumption(points, hasPartialLatestPoint, activeSeries) { + const completePoints = hasPartialLatestPoint && points.length > 1 ? points.slice(0, -1) : points; + return completePoints.reduce( + (sum, point) => sum + getConsumptionPointTotal(point, activeSeries), + 0 + ) / Math.max(completePoints.length, 1); + } + + function appendConsumptionBarAnimation(element, fromY, toY, height, delayMs) { + if (prefersReducedMotion()) { + return; + } + + element.append( + createSvgElement("animate", { + attributeName: "y", + from: fromY, + to: toY, + dur: "0.52s", + begin: `${delayMs}ms`, + fill: "freeze", + calcMode: "spline", + keyTimes: "0;1", + keySplines: "0.2 0.8 0.2 1" + }), + createSvgElement("animate", { + attributeName: "height", + from: "0", + to: height, + dur: "0.52s", + begin: `${delayMs}ms`, + fill: "freeze", + calcMode: "spline", + keyTimes: "0;1", + keySplines: "0.2 0.8 0.2 1" + }) + ); + } + + function prefersReducedMotion() { + return typeof window.matchMedia === "function" + && window.matchMedia("(prefers-reduced-motion: reduce)").matches; + } + + function createConsumptionPointAriaLabel(point, period) { + return `${formatConsumptionPointLabel(point, period)}. ` + + `Funded Allocation: ${formatConsumptionCurrency(point.funded, point.currencyCode)}. ` + + `Overage: ${formatConsumptionCurrency(point.overage, point.currencyCode)}. ` + + `Total: ${formatConsumptionCurrency(point.total, point.currencyCode)}.`; + } + + function showConsumptionTooltip(tooltip, point, period, event) { + const figure = tooltip.parentElement; + + if (!figure) { + return; + } + + tooltip.textContent = ""; + const title = document.createElement("strong"); + title.textContent = formatConsumptionPointLabel(point, period); + const details = document.createElement("span"); + details.textContent = `Funded Allocation: ${formatConsumptionCurrency(point.funded, point.currencyCode)}`; + const overage = document.createElement("span"); + overage.textContent = `Overage: ${formatConsumptionCurrency(point.overage, point.currencyCode)}`; + const total = document.createElement("span"); + total.textContent = `Total: ${formatConsumptionCurrency(point.total, point.currencyCode)}`; + tooltip.append(title, details, overage, total); + tooltip.hidden = false; + + const figureBounds = figure.getBoundingClientRect(); + const targetBounds = event.currentTarget.getBoundingClientRect(); + const pointerX = Number.isFinite(event.clientX) && event.clientX > 0 + ? event.clientX + : targetBounds.left + (targetBounds.width / 2); + const pointerY = Number.isFinite(event.clientY) && event.clientY > 0 + ? event.clientY + : targetBounds.top; + const left = Math.min( + Math.max(8, pointerX - figureBounds.left + 12), + Math.max(8, figureBounds.width - tooltip.offsetWidth - 8) + ); + const top = Math.max(8, pointerY - figureBounds.top - tooltip.offsetHeight - 10); + tooltip.style.left = `${left}px`; + tooltip.style.top = `${top}px`; + } + + function createSvgElement(name, attributes = {}) { + const element = document.createElementNS(SVG_NAMESPACE, name); + + Object.entries(attributes).forEach(([key, value]) => { + element.setAttribute(key, String(value)); + }); + return element; + } + + function createSvgText(x, y, value, className, anchor) { + const text = createSvgElement("text", { x, y, class: className, "text-anchor": anchor }); + text.textContent = value; + return text; + } + + function createNiceConsumptionMaximum(value) { + if (!Number.isFinite(value) || value <= 0) { + return 1; + } + + const magnitude = 10 ** Math.floor(Math.log10(value)); + const normalized = value / magnitude; + const step = normalized <= 1 ? 1 : normalized <= 2 ? 2 : normalized <= 5 ? 5 : 10; + return step * magnitude; + } + + function formatConsumptionPointLabel(point, period) { + if (period === "quarterly") { + return point.fiscalTime[0] || point.fiscalQtr || point.dateKey; + } + + if (period === "monthly") { + return point.fiscalTime[1] || formatConsumptionDate(point.date, { month: "short" }); + } + + return point.fiscalTime[0] || formatConsumptionDate(point.date, { + day: "2-digit", + month: "short", + year: "2-digit" + }); + } + + function formatConsumptionRange(points, period) { + if (points.length === 0) { + return "No data"; + } + + if (period === "quarterly") { + return `${formatConsumptionPointLabel(points[0], period)} - ${formatConsumptionPointLabel(points[points.length - 1], period)}`; + } + + if (period === "monthly") { + const options = { month: "short", year: "numeric" }; + return `${formatConsumptionDate(points[0].date, options)} - ${formatConsumptionDate(points[points.length - 1].date, options)}`; + } + + const options = { day: "2-digit", month: "short" }; + return `${formatConsumptionDate(points[0].date, options)} - ${formatConsumptionDate(points[points.length - 1].date, options)}`; + } + + function parseConsumptionDate(value) { + if (!value) { + return null; + } + + const parts = String(value).split("-").map(Number); + return parts.length === 3 && parts.every(Number.isFinite) + ? new Date(parts[0], parts[1] - 1, parts[2]) + : null; + } + + function formatConsumptionDate(date, options) { + if (!(date instanceof Date) || Number.isNaN(date.getTime())) { + return "-"; + } + + return new Intl.DateTimeFormat("en-US", options).format(date).replace(",", ""); + } + + function formatCompactConsumptionNumber(value) { + const amount = toFiniteNumber(value); + + if (Math.abs(amount) >= 1000000) { + return `${(amount / 1000000).toFixed(amount >= 10000000 ? 0 : 1).replace(".0", "")}M`; + } + + if (Math.abs(amount) >= 1000) { + return `${Math.round(amount / 1000)}K`; + } + + return String(Math.round(amount)); + } + + function formatConsumptionCurrency(value, currencyCode) { + try { + return new Intl.NumberFormat("en-US", { + style: "currency", + currency: currencyCode || "USD", + maximumFractionDigits: 0 + }).format(toFiniteNumber(value)); + } catch (error) { + return `${currencyCode || "USD"} ${Math.round(toFiniteNumber(value)).toLocaleString("en-US")}`; + } + } + + function toFiniteNumber(value) { + const number = Number(value); + return Number.isFinite(number) ? number : 0; + } + function closeAccountPlansModal() { const modal = document.getElementById(ACCOUNT_PLANS_MODAL_ID); @@ -4343,21 +5055,32 @@ min-width: 0; } - .opportunities-extension-products-count { + .opportunities-extension-actions-content { display: inline-flex; align-items: center; justify-content: center; + gap: 4px; + white-space: nowrap; + } + + .opportunities-extension-products-count { + display: inline-grid; + width: auto; + min-width: 20px; + height: 20px; + place-items: center; flex: 0 0 auto; - min-width: 24px; - min-height: 22px; - padding: 2px 7px; + box-sizing: border-box; + padding: 0 3px; border: 1px solid #7ca7b2; border-radius: 999px; background: #e8f4f6; color: #006d7a; - font-size: 11px; + font-size: 10px; font-weight: 700; line-height: 1; + text-align: center; + vertical-align: middle; cursor: pointer; } @@ -4711,7 +5434,7 @@ z-index: 4; display: grid; place-items: center; - padding: 24px; + padding: 12px; background: rgba(0, 0, 0, .42); } @@ -4721,8 +5444,8 @@ .opportunities-extension-account-plans-dialog { display: grid; - width: min(860px, 100%); - max-height: min(720px, calc(100vh - 48px)); + width: min(1180px, 100%); + height: min(760px, calc(100vh - 24px)); grid-template-rows: auto minmax(0, 1fr); overflow: hidden; border: 1px solid #8f8a85; @@ -4737,7 +5460,7 @@ align-items: flex-start; justify-content: space-between; gap: 16px; - padding: 18px 20px; + padding: 14px 20px; border-bottom: 1px solid #dedbd7; } @@ -4755,16 +5478,28 @@ } .opportunities-extension-account-plans-content { + display: grid; + grid-template-rows: auto minmax(0, 1fr); min-height: 0; - overflow: auto; + overflow: hidden; } .opportunities-extension-account-plans-tabs { display: flex; gap: 4px; overflow-x: auto; - padding: 12px 20px 0; + padding: 8px 20px 0; border-bottom: 1px solid #dedbd7; + scrollbar-width: none; + } + + .opportunities-extension-account-plans-tabs::-webkit-scrollbar { + display: none; + } + + .opportunities-extension-account-plans-panels { + min-height: 0; + overflow: hidden; } .opportunities-extension-account-plan-tab { @@ -4796,7 +5531,12 @@ } .opportunities-extension-account-plan-panel { - padding: 24px 20px 28px; + display: grid; + height: 100%; + min-height: 0; + grid-template-rows: auto minmax(0, 1fr); + box-sizing: border-box; + padding: 16px 20px; } .opportunities-extension-account-plan-panel[hidden] { @@ -4805,16 +5545,18 @@ .opportunities-extension-account-plan-details { display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 0 28px; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0 24px; margin: 0; } .opportunities-extension-account-plan-details > div { display: grid; - grid-template-columns: minmax(130px, 38%) minmax(0, 1fr); - gap: 12px; - padding: 13px 0; + grid-template-columns: minmax(104px, 38%) minmax(0, 1fr); + gap: 10px; + min-height: 42px; + align-items: center; + padding: 7px 0; border-bottom: 1px solid #ebe8e5; } @@ -4832,6 +5574,334 @@ overflow-wrap: anywhere; } + .opportunities-extension-consumption-trend { + display: grid; + min-height: 0; + grid-template-rows: auto minmax(0, 1fr); + margin-top: 14px; + padding-top: 12px; + border-top: 1px solid #dedbd7; + } + + .opportunities-extension-consumption-trend-header { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 24px; + } + + .opportunities-extension-consumption-periods { + display: flex; + align-items: center; + gap: 4px; + } + + .opportunities-extension-consumption-periods button { + min-height: 38px; + padding: 7px 14px; + border: 1px solid transparent; + border-radius: 4px; + background: transparent; + color: #312d2a; + font-size: 13px; + font-weight: 600; + line-height: 1.2; + cursor: pointer; + } + + .opportunities-extension-consumption-periods button:hover { + background: #f0eeeb; + } + + .opportunities-extension-consumption-periods button[aria-pressed="true"] { + border-color: #00758f; + background: #e7f6f8; + color: #005e73; + } + + .opportunities-extension-consumption-periods button:focus-visible, + .opportunities-extension-consumption-navigation-button:focus-visible { + outline: 2px solid #00758f; + outline-offset: 2px; + } + + .opportunities-extension-consumption-chart { + display: grid; + min-height: 0; + grid-template-rows: auto minmax(0, 1fr); + margin-top: 4px; + } + + .opportunities-extension-consumption-navigation { + display: grid; + grid-template-columns: 36px minmax(130px, auto) 36px; + align-items: center; + justify-content: center; + gap: 8px; + min-height: 40px; + margin-bottom: 2px; + } + + .opportunities-extension-consumption-navigation strong { + color: #312d2a; + font-size: 13px; + text-align: center; + } + + .opportunities-extension-consumption-navigation-button { + display: grid; + width: 36px; + height: 36px; + place-items: center; + padding: 0; + border: 1px solid #8f8a85; + border-radius: 4px; + background: #ffffff; + color: #312d2a; + cursor: pointer; + } + + .opportunities-extension-consumption-navigation-button:hover:not(:disabled) { + border-color: #00758f; + background: #f0eeeb; + color: #005e73; + } + + .opportunities-extension-consumption-navigation-button:disabled { + border-color: #dedbd7; + color: #b8b2ad; + cursor: default; + } + + .opportunities-extension-consumption-navigation-button svg { + width: 18px; + height: 18px; + } + + .opportunities-extension-consumption-figure { + position: relative; + min-width: 0; + min-height: 0; + margin: 0; + scrollbar-width: none; + } + + .opportunities-extension-consumption-figure::-webkit-scrollbar { + display: none; + } + + .opportunities-extension-consumption-visual { + display: grid; + height: 100%; + min-height: 0; + grid-template-columns: minmax(0, 1fr) 176px; + align-items: center; + gap: 16px; + } + + .opportunities-extension-consumption-svg { + display: block; + width: 100%; + height: 300px; + min-height: 0; + overflow: visible; + } + + .opportunities-extension-consumption-svg .consumption-grid-line { + stroke: #dedbd7; + stroke-width: 1; + shape-rendering: crispEdges; + } + + .opportunities-extension-consumption-svg .consumption-axis-label, + .opportunities-extension-consumption-svg .consumption-x-label { + fill: #5f5a55; + font-family: Arial, sans-serif; + font-size: 12px; + } + + .opportunities-extension-consumption-svg .consumption-value-label { + fill: #312d2a; + font-family: Arial, sans-serif; + font-size: 12px; + font-weight: 700; + } + + .opportunities-extension-consumption-svg .consumption-bar-funded { + fill: #b77888; + } + + .opportunities-extension-consumption-svg .consumption-bar-overage { + fill: #de7907; + } + + .opportunities-extension-consumption-svg .consumption-average-line { + stroke: #de7907; + stroke-width: 1.5; + stroke-dasharray: 6 6; + } + + .opportunities-extension-consumption-svg .consumption-bar-group { + cursor: default; + outline: none; + } + + .opportunities-extension-consumption-svg .consumption-bar-group rect { + transition: filter .16s ease, opacity .16s ease, stroke .16s ease; + } + + .opportunities-extension-consumption-svg .consumption-bar-group:hover rect, + .opportunities-extension-consumption-svg .consumption-bar-group:focus rect { + filter: brightness(1.06); + stroke: #312d2a; + stroke-width: 1.5; + } + + .opportunities-extension-consumption-svg .consumption-animated-label { + opacity: 0; + animation: opportunities-extension-consumption-label-in .24s ease-out forwards; + } + + .opportunities-extension-consumption-svg .consumption-animated-average { + opacity: 0; + animation: opportunities-extension-consumption-average-in .35s .32s ease-out forwards; + } + + @keyframes opportunities-extension-consumption-label-in { + from { opacity: 0; transform: translateY(4px); } + to { opacity: 1; transform: translateY(0); } + } + + @keyframes opportunities-extension-consumption-average-in { + from { opacity: 0; } + to { opacity: 1; } + } + + .opportunities-extension-consumption-legend { + display: grid; + align-content: center; + gap: 4px; + } + + .opportunities-extension-consumption-legend button { + display: grid; + min-width: 0; + grid-template-columns: 16px minmax(0, 1fr); + align-items: center; + gap: 8px; + padding: 6px 4px; + border: 0; + border-radius: 4px; + background: transparent; + color: #312d2a; + font-size: 12px; + line-height: 1.25; + text-align: left; + cursor: pointer; + } + + .opportunities-extension-consumption-legend button:hover { + background: #f0eeeb; + } + + .opportunities-extension-consumption-legend button:focus-visible { + outline: 2px solid #00758f; + outline-offset: 1px; + } + + .opportunities-extension-consumption-legend button[aria-pressed="false"] { + color: #77726d; + opacity: .55; + } + + .opportunities-extension-consumption-legend-swatch { + display: block; + width: 12px; + height: 12px; + justify-self: center; + } + + .opportunities-extension-consumption-legend-funded { + background: #b77888; + } + + .opportunities-extension-consumption-legend-overage { + background: #de7907; + } + + .opportunities-extension-consumption-legend-average { + height: 0; + border-top: 2px dashed #de7907; + } + + .opportunities-extension-consumption-tooltip { + position: absolute; + z-index: 2; + display: grid; + min-width: 190px; + max-width: 260px; + gap: 4px; + padding: 10px 12px; + border: 1px solid #8f8a85; + border-radius: 4px; + background: #ffffff; + box-shadow: 0 4px 12px rgba(0, 0, 0, .2); + color: #312d2a; + font-size: 12px; + line-height: 1.3; + pointer-events: none; + } + + .opportunities-extension-consumption-tooltip[hidden] { + display: none !important; + } + + .opportunities-extension-consumption-tooltip strong { + margin-bottom: 2px; + font-size: 13px; + } + + .opportunities-extension-consumption-chart-skeleton { + height: 100%; + min-height: 0; + padding: 34px 62px 24px; + box-sizing: border-box; + } + + .opportunities-extension-consumption-chart-skeleton-plot { + display: flex; + height: 100%; + min-height: 220px; + align-items: flex-end; + justify-content: space-around; + gap: 12px; + padding: 0 14px; + border-bottom: 1px solid #dedbd7; + border-left: 1px solid #dedbd7; + } + + .opportunities-extension-consumption-chart-skeleton-plot span { + width: min(38px, 6%); + border-radius: 2px 2px 0 0; + background: #dedbd7; + animation: opportunities-extension-skeleton-pulse 1.15s ease-in-out infinite alternate; + } + + .opportunities-extension-consumption-chart-empty, + .opportunities-extension-consumption-chart-error { + display: grid; + min-height: 220px; + place-items: center; + margin: 0; + color: #5f5a55; + font-size: 14px; + text-align: center; + } + + .opportunities-extension-consumption-chart-error { + color: #9f2d20; + } + .opportunities-extension-account-plan-status-indicator { display: inline-block; width: 10px; @@ -4899,8 +5969,12 @@ } @media (prefers-reduced-motion: reduce) { - .opportunities-extension-account-plans-skeleton-bar { + .opportunities-extension-account-plans-skeleton-bar, + .opportunities-extension-consumption-chart-skeleton-plot span, + .opportunities-extension-consumption-svg .consumption-animated-label, + .opportunities-extension-consumption-svg .consumption-animated-average { animation: none; + opacity: 1; } } @@ -5282,10 +6356,18 @@ color: #ffb4a8; } + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-chart-error { + color: #ffb4a8; + } + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-skeleton-bar { background: #5b5753; } + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-chart-skeleton-plot span { + background: #5b5753; + } + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plan-status-indicator { background: #8f8a85; box-shadow: 0 0 0 3px #3b3937; @@ -5354,6 +6436,14 @@ color: #f6f4f2; } + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-navigation strong { + color: #f6f4f2; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-chart-empty { + color: #c9c5c1; + } + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-customer-filter-select-all, .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-table th { border-bottom-color: #4e4a46; @@ -5369,6 +6459,11 @@ border-color: #4e4a46; } + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-trend, + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-chart-skeleton-plot { + border-color: #4e4a46; + } + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plan-tab { color: #c9c5c1; } @@ -5383,6 +6478,97 @@ color: #91e4ed; } + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-periods button { + color: #f6f4f2; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-periods button:hover { + background: #3b3937; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-periods button[aria-pressed="true"] { + border-color: #43c4d5; + background: #173e43; + color: #91e4ed; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-navigation-button { + border-color: #6a6560; + background: #333130; + color: #f6f4f2; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-navigation-button:hover:not(:disabled) { + border-color: #43c4d5; + background: #3b3937; + color: #91e4ed; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-navigation-button:disabled { + border-color: #4e4a46; + color: #746f6a; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-svg .consumption-grid-line { + stroke: #4e4a46; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-svg .consumption-axis-label, + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-svg .consumption-x-label { + fill: #c9c5c1; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-svg .consumption-value-label { + fill: #f6f4f2; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-svg .consumption-bar-funded { + fill: #ca8798; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-svg .consumption-bar-overage { + fill: #f09a32; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-svg .consumption-average-line { + stroke: #f09a32; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-svg .consumption-bar-group:hover rect, + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-svg .consumption-bar-group:focus rect { + stroke: #f6f4f2; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-legend button { + color: #f6f4f2; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-legend button:hover { + background: #3b3937; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-legend button[aria-pressed="false"] { + color: #a8a39e; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-legend-funded { + background: #ca8798; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-legend-overage { + background: #f09a32; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-legend-average { + border-top-color: #f09a32; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-tooltip { + border-color: #6a6560; + background: #333130; + color: #f6f4f2; + } + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-preferences-option:hover { background: #3b3937; } @@ -5733,17 +6919,77 @@ } .opportunities-extension-account-plans-dialog { - max-height: calc(100vh - 24px); + height: calc(100vh - 24px); + } + + .opportunities-extension-account-plan-panel { + padding: 12px 14px; } .opportunities-extension-account-plan-details { + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0 14px; + } + + .opportunities-extension-account-plan-details > div { grid-template-columns: 1fr; + align-content: center; + gap: 3px; + min-height: 54px; + padding: 5px 0; } .opportunities-extension-account-plans-skeleton-details { grid-template-columns: 1fr; } + .opportunities-extension-consumption-trend-header { + align-items: stretch; + } + + .opportunities-extension-consumption-periods { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 3px; + } + + .opportunities-extension-consumption-periods button { + min-width: 0; + padding: 7px 4px; + font-size: 12px; + } + + .opportunities-extension-consumption-chart { + min-height: 0; + } + + .opportunities-extension-consumption-svg { + width: 760px; + height: 245px; + } + + .opportunities-extension-consumption-visual { + width: 920px; + grid-template-columns: 760px 144px; + gap: 12px; + } + + .opportunities-extension-consumption-figure { + overflow-x: auto; + overscroll-behavior-inline: contain; + } + + .opportunities-extension-consumption-chart-skeleton { + min-height: 0; + padding: 24px 18px 18px; + } + + .opportunities-extension-consumption-chart-skeleton-plot { + min-height: 180px; + gap: 4px; + padding: 0 6px; + } + .opportunities-extension-preferences-options { grid-template-columns: 1fr; } diff --git a/package.json b/package.json index 04a8644..678f9d2 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "type": "module", "scripts": { "build": "node scripts/build.mjs", - "check": "node --check src/content.js && node scripts/build.mjs --check" + "check": "node --check src/content.js && node --check src/background.js && node tests/consumptionTrend.test.cjs && node scripts/build.mjs --check", + "test": "node tests/consumptionTrend.test.cjs" } } diff --git a/src/background.js b/src/background.js index d8f0fa1..4200856 100644 --- a/src/background.js +++ b/src/background.js @@ -20,6 +20,13 @@ const CUSTOMER_TOKEN_SERVICE_URL = "https://spa.oracle.com/oalcrm/web/api/g2m-consumer-application/consumerTokenService"; const ACCOUNT_SUMMARY_URL = "https://spa.oracle.com/oalcrm/web/api/provider-proxy/g2m-account/service/accountsummary"; const ACCOUNT_PLANS_URL = "https://spa.oracle.com/oalcrm/web/api/provider-proxy/g2m-consumption/service/v1/account/accountPlans"; + const CONSUMPTION_TREND_URL = "https://spa.oracle.com/oalcrm/web/api/provider-proxy/g2m-consumption/service/v1/plan/trend"; + const CONSUMPTION_TREND_REQUESTS = { + quarterly: "requestConsumptionServiceQuarterly", + monthly: "requestConsumptionServiceMonthly", + weekly: "requestConsumptionServiceWeekly", + daily: "requestConsumptionServiceDaily" + }; const ACCOUNT_PLANS_PAYLOAD = { hierarchy: "single", products: "all", @@ -149,6 +156,20 @@ return true; } + if (message.type === "opportunitiesExtension.requestConsumptionTrend") { + requestConsumptionTrend(message) + .then(sendResponse) + .catch((error) => { + sendResponse({ + ok: false, + error: error.message || "Unable to load consumption trend.", + debugRequests: Array.isArray(error.debugRequests) ? error.debugRequests : [] + }); + }); + + return true; + } + return false; }); @@ -282,6 +303,56 @@ } } + async function requestConsumptionTrend(message) { + const subPlanNum = typeof message.subPlanNum === "string" ? message.subPlanNum.trim() : ""; + const period = typeof message.period === "string" ? message.period.toLowerCase() : ""; + const requestName = CONSUMPTION_TREND_REQUESTS[period]; + const authorization = normalizeMessageAuthorization(message.authorization, "g2m-authorization"); + const debugRequests = []; + + if (!subPlanNum) { + throw new Error("Sub plan number is required for the consumption trend."); + } + + if (!requestName) { + throw new Error(`Unsupported consumption trend period: ${period || "(missing)"}.`); + } + + if (!authorization.value) { + throw new Error("OCICONS-TS authorization is unavailable for the consumption trend."); + } + + const url = `${CONSUMPTION_TREND_URL}/${encodeURIComponent(subPlanNum)}?quarter=All&timePeriod=${period}`; + + try { + const response = await fetchJson(url, { + method: "GET", + credentials: "include", + cache: "no-store", + headers: { + Accept: "application/json", + "G2m-Authorization": authorization.value + } + }, requestName, debugRequests); + const items = Array.isArray(response) + ? response + : Array.isArray(response && response.items) + ? response.items + : []; + + return { + ok: true, + subPlanNum, + period, + items, + debugRequests + }; + } catch (error) { + error.debugRequests = debugRequests; + throw error; + } + } + function normalizeMessageAuthorization(authorization, fallbackHeaderName) { const headerName = authorization && typeof authorization.name === "string" ? authorization.name.trim() diff --git a/src/content.js b/src/content.js index ade6ea6..51bc626 100644 --- a/src/content.js +++ b/src/content.js @@ -45,6 +45,19 @@ authenticated: "authenticated", unauthenticated: "unauthenticated" }; + const CONSUMPTION_TREND_PERIODS = [ + { value: "quarterly", label: "Quarterly", windowSize: 8 }, + { value: "monthly", label: "Monthly", windowSize: 13 }, + { value: "weekly", label: "Weekly", windowSize: 13 }, + { value: "daily", label: "Daily", windowSize: 30 } + ]; + const CONSUMPTION_TREND_SERIES = [ + { value: "funded", label: "Funded Allocation" }, + { value: "overage", label: "Overage" }, + { value: "average", label: "Average Consumption" } + ]; + const DEFAULT_CONSUMPTION_TREND_PERIOD = "weekly"; + const SVG_NAMESPACE = "http://www.w3.org/2000/svg"; const STAGE_OPTIONS = ["SQL", "PIPELINE", "UPSIDE", "FORECAST", "WON"]; const STATUS_OPTIONS = ["OPEN", "CLOSED", "LOST", "WON"]; const OPPORTUNITY_TYPE_OPTIONS = [ @@ -102,6 +115,9 @@ }; let productsLastCachedAt = 0; let accountPlansRequestState = new Map(); + let consumptionTrendCache = new Map(); + let consumptionTrendWindowOffsets = new Map(); + let consumptionTrendSeriesFilters = new Map(); let hasSavedCustomerFilter = false; let hasSavedOwnerFilter = false; let customerFilterInitialized = false; @@ -777,6 +793,9 @@ }; productsLastCachedAt = 0; accountPlansRequestState = new Map(); + consumptionTrendCache = new Map(); + consumptionTrendWindowOffsets = new Map(); + consumptionTrendSeriesFilters = new Map(); renderProductsProgress(); setOpportunitiesTableState({ items: [], @@ -1033,10 +1052,10 @@ if (timestamp) { timestamp.hidden = !productsLastCachedAt; timestamp.textContent = productsLastCachedAt - ? `Last cache: ${formatProductsCacheTimestamp(productsLastCachedAt)}` + ? `Last update: ${formatProductsCacheTimestamp(productsLastCachedAt)}` : ""; timestamp.title = productsLastCachedAt - ? `Latest product cache: ${formatProductsCacheTimestamp(productsLastCachedAt)}` + ? `Latest product update: ${formatProductsCacheTimestamp(productsLastCachedAt)}` : ""; } } @@ -1088,6 +1107,9 @@ }; productsLastCachedAt = 0; accountPlansRequestState = new Map(); + consumptionTrendCache = new Map(); + consumptionTrendWindowOffsets = new Map(); + consumptionTrendSeriesFilters = new Map(); stageDashboardAmounts = new Map([["TOTAL", 0], ...STAGE_OPTIONS.map((stage) => [stage, 0])]); opportunitiesTableState = { items: [], @@ -1261,7 +1283,16 @@ } else if (column.key === "status") { cell.append(createStatusBadge(column.display(item))); } else if (column.key === "actions") { - cell.append(createCloudConsumptionButton(item)); + const actionsContent = document.createElement("span"); + actionsContent.className = "opportunities-extension-actions-content"; + const productsResult = opptyProductsByNumber.get(String(item.OptyNumber)); + + if (productsResult) { + actionsContent.append(createProductsCountButton(item.OptyNumber, productsResult)); + } + + actionsContent.append(createCloudConsumptionButton(item)); + cell.append(actionsContent); } else if (column.key === "name" || column.key === "optyNumber") { const opportunityLink = createDetailLink( column.display(item), @@ -1272,13 +1303,6 @@ const nameCellContent = document.createElement("span"); nameCellContent.className = "opportunities-extension-name-content"; nameCellContent.append(opportunityLink); - - const productsResult = opptyProductsByNumber.get(String(item.OptyNumber)); - - if (productsResult) { - nameCellContent.append(createProductsCountButton(item.OptyNumber, productsResult)); - } - cell.append(nameCellContent); } else if (column.key === "optyNumber" && item.OptyNumber) { const opportunityCellContent = document.createElement("span"); @@ -1966,7 +1990,7 @@ button.type = "button"; button.className = "opportunities-extension-action-button"; button.setAttribute("aria-label", `View cloud consumption for ${optyNumber || "opportunity"}`); - button.title = isLoading ? "Loading cloud consumption" : "View cloud consumption"; + button.title = isLoading ? "Loading customer consumption" : "View customer consumption"; button.disabled = Boolean(isLoading); button.classList.toggle("opportunities-extension-action-button-loading", Boolean(isLoading)); button.append(createCloudConsumptionIcon()); @@ -2137,7 +2161,7 @@ panel.setAttribute("role", "tabpanel"); panel.setAttribute("aria-labelledby", tabId); panel.hidden = index !== 0; - panel.append(createAccountPlanDetails(plan)); + panel.append(createAccountPlanDetails(plan), createConsumptionTrendSection(plan)); tab.addEventListener("click", () => { tabList.querySelectorAll("[role='tab']").forEach((candidate) => { @@ -2148,6 +2172,12 @@ panels.querySelectorAll("[role='tabpanel']").forEach((candidate) => { candidate.hidden = candidate !== panel; }); + const trendSection = panel.querySelector(".opportunities-extension-consumption-trend"); + loadConsumptionTrendForPanel( + panel, + plan, + trendSection ? trendSection.dataset.period : DEFAULT_CONSUMPTION_TREND_PERIOD + ); }); tabList.append(tab); panels.append(panel); @@ -2160,6 +2190,14 @@ modal.append(dialog); overlay.append(modal); modal.querySelector("[role='tab'], .opportunities-extension-icon-button")?.focus(); + + if (state === "ready" && Array.isArray(plans) && plans.length > 0) { + const firstPanel = modal.querySelector("[role='tabpanel']"); + + if (firstPanel) { + loadConsumptionTrendForPanel(firstPanel, plans[0], DEFAULT_CONSUMPTION_TREND_PERIOD); + } + } } function createAccountPlansSkeleton() { @@ -2232,6 +2270,680 @@ return details; } + function createConsumptionTrendSection(plan) { + const section = document.createElement("section"); + section.className = "opportunities-extension-consumption-trend"; + section.dataset.subPlanNum = String(plan.subPlanNum || ""); + section.dataset.period = DEFAULT_CONSUMPTION_TREND_PERIOD; + + const header = document.createElement("header"); + header.className = "opportunities-extension-consumption-trend-header"; + + const periodControls = document.createElement("div"); + periodControls.className = "opportunities-extension-consumption-periods"; + periodControls.setAttribute("role", "group"); + periodControls.setAttribute("aria-label", "Consumption period"); + + CONSUMPTION_TREND_PERIODS.forEach((periodOption) => { + const button = document.createElement("button"); + button.type = "button"; + button.dataset.period = periodOption.value; + button.setAttribute("aria-pressed", String(periodOption.value === DEFAULT_CONSUMPTION_TREND_PERIOD)); + button.textContent = periodOption.label; + button.addEventListener("click", () => { + section.dataset.period = periodOption.value; + consumptionTrendWindowOffsets.set( + createConsumptionTrendKey(section.dataset.subPlanNum, periodOption.value), + 0 + ); + loadConsumptionTrendForPanel(section.closest("[role='tabpanel']"), plan, periodOption.value); + }); + periodControls.append(button); + }); + + const chart = document.createElement("div"); + chart.className = "opportunities-extension-consumption-chart"; + chart.setAttribute("aria-live", "polite"); + header.append(periodControls); + section.append(header, chart); + return section; + } + + function loadConsumptionTrendForPanel(panel, plan, period) { + if (!panel) { + return; + } + + const section = panel.querySelector(".opportunities-extension-consumption-trend"); + + if (!section) { + return; + } + + const subPlanNum = String(plan.subPlanNum || "").trim(); + const selectedPeriod = CONSUMPTION_TREND_PERIODS.some((item) => item.value === period) + ? period + : DEFAULT_CONSUMPTION_TREND_PERIOD; + const key = createConsumptionTrendKey(subPlanNum, selectedPeriod); + const chart = section.querySelector(".opportunities-extension-consumption-chart"); + section.dataset.period = selectedPeriod; + chart.dataset.requestKey = key; + updateConsumptionPeriodButtons(section, selectedPeriod); + + if (!subPlanNum) { + renderConsumptionTrendError(chart, "Sub plan number is unavailable."); + return; + } + + const cached = consumptionTrendCache.get(key); + + renderConsumptionTrendSkeleton(chart); + + if (cached && cached.status === "loading") { + cached.promise + .then((items) => renderConsumptionTrendIfCurrent(section, chart, items, selectedPeriod, subPlanNum, key)) + .catch((error) => renderConsumptionTrendErrorIfCurrent(chart, error.message, key)); + return; + } + + const request = requestConsumerTokenServiceOnce() + .then((authorizations) => sendRuntimeMessage({ + type: "opportunitiesExtension.requestConsumptionTrend", + subPlanNum, + period: selectedPeriod, + authorization: authorizations && authorizations.accountPlans + })) + .then((response) => { + appendBackgroundRequestLog(response && response.debugRequests); + + if (!response || !response.ok) { + throw new Error(response && response.error ? response.error : "Unable to load consumption trend."); + } + + return Array.isArray(response.items) ? response.items : []; + }) + .finally(() => { + if (consumptionTrendCache.get(key)?.promise === request) { + consumptionTrendCache.delete(key); + } + }); + + consumptionTrendCache.set(key, { status: "loading", promise: request }); + request + .then((items) => renderConsumptionTrendIfCurrent(section, chart, items, selectedPeriod, subPlanNum, key)) + .catch((error) => renderConsumptionTrendErrorIfCurrent(chart, error.message, key)); + } + + function createConsumptionTrendKey(subPlanNum, period) { + return `${subPlanNum}:${period}`; + } + + function updateConsumptionPeriodButtons(section, period) { + section.querySelectorAll(".opportunities-extension-consumption-periods button").forEach((button) => { + button.setAttribute("aria-pressed", String(button.dataset.period === period)); + }); + } + + function renderConsumptionTrendIfCurrent(section, chart, items, period, subPlanNum, key) { + if (chart.dataset.requestKey === key) { + renderConsumptionTrendChart(section, chart, items, period, subPlanNum); + } + } + + function renderConsumptionTrendErrorIfCurrent(chart, errorMessage, key) { + if (chart.dataset.requestKey === key) { + renderConsumptionTrendError(chart, errorMessage || "Unable to load consumption trend."); + } + } + + function renderConsumptionTrendSkeleton(chart) { + chart.textContent = ""; + chart.setAttribute("aria-busy", "true"); + const skeleton = document.createElement("div"); + skeleton.className = "opportunities-extension-consumption-chart-skeleton"; + const announcement = document.createElement("span"); + announcement.className = "opportunities-extension-visually-hidden"; + announcement.textContent = "Loading consumption trend"; + const plot = document.createElement("div"); + plot.className = "opportunities-extension-consumption-chart-skeleton-plot"; + + for (let index = 0; index < 13; index += 1) { + const bar = document.createElement("span"); + bar.style.height = `${28 + ((index * 17) % 58)}%`; + plot.append(bar); + } + + skeleton.append(announcement, plot); + chart.append(skeleton); + } + + function renderConsumptionTrendError(chart, errorMessage) { + chart.textContent = ""; + chart.setAttribute("aria-busy", "false"); + const error = document.createElement("p"); + error.className = "opportunities-extension-consumption-chart-error"; + error.setAttribute("role", "alert"); + error.textContent = errorMessage; + chart.append(error); + } + + function renderConsumptionTrendChart(section, chart, items, period, subPlanNum) { + chart.textContent = ""; + chart.setAttribute("aria-busy", "false"); + const points = normalizeConsumptionTrendItems(items); + + if (points.length === 0) { + const empty = document.createElement("p"); + empty.className = "opportunities-extension-consumption-chart-empty"; + empty.textContent = "No consumption data found for this period."; + chart.append(empty); + return; + } + + const option = CONSUMPTION_TREND_PERIODS.find((item) => item.value === period); + const windowSize = option ? option.windowSize : points.length; + const offsetKey = createConsumptionTrendKey(subPlanNum, period); + const maxOffset = Math.max(0, Math.ceil(points.length / windowSize) - 1); + const offset = Math.min(consumptionTrendWindowOffsets.get(offsetKey) || 0, maxOffset); + const end = Math.max(0, points.length - (offset * windowSize)); + const start = Math.max(0, end - windowSize); + const visiblePoints = points.slice(start, end); + consumptionTrendWindowOffsets.set(offsetKey, offset); + + const navigation = document.createElement("div"); + navigation.className = "opportunities-extension-consumption-navigation"; + const olderButton = createConsumptionNavigationButton("older", start === 0); + const newerButton = createConsumptionNavigationButton("newer", end === points.length); + const range = document.createElement("strong"); + range.textContent = formatConsumptionRange(visiblePoints, period); + olderButton.addEventListener("click", () => { + consumptionTrendWindowOffsets.set(offsetKey, Math.min(maxOffset, offset + 1)); + renderConsumptionTrendChart(section, chart, items, period, subPlanNum); + }); + newerButton.addEventListener("click", () => { + consumptionTrendWindowOffsets.set(offsetKey, Math.max(0, offset - 1)); + renderConsumptionTrendChart(section, chart, items, period, subPlanNum); + }); + navigation.append(olderButton, range, newerButton); + + const figure = document.createElement("figure"); + figure.className = "opportunities-extension-consumption-figure"; + const visual = document.createElement("div"); + visual.className = "opportunities-extension-consumption-visual"; + const tooltip = document.createElement("div"); + tooltip.className = "opportunities-extension-consumption-tooltip"; + tooltip.hidden = true; + tooltip.setAttribute("role", "tooltip"); + const activeSeries = getConsumptionTrendSeriesFilter(subPlanNum); + const svg = createConsumptionSvg( + visiblePoints, + period, + end === points.length, + activeSeries, + tooltip + ); + const legend = createConsumptionLegend( + section, + chart, + items, + period, + subPlanNum, + activeSeries + ); + visual.append(svg, legend); + figure.append(visual, tooltip); + chart.append(navigation, figure); + } + + function getConsumptionTrendSeriesFilter(subPlanNum) { + if (!consumptionTrendSeriesFilters.has(subPlanNum)) { + consumptionTrendSeriesFilters.set( + subPlanNum, + new Set(CONSUMPTION_TREND_SERIES.map((series) => series.value)) + ); + } + + return consumptionTrendSeriesFilters.get(subPlanNum); + } + + function createConsumptionLegend(section, chart, items, period, subPlanNum, activeSeries) { + const legend = document.createElement("div"); + legend.className = "opportunities-extension-consumption-legend"; + legend.setAttribute("role", "group"); + legend.setAttribute("aria-label", "Chart series filters"); + + CONSUMPTION_TREND_SERIES.forEach((series) => { + const button = document.createElement("button"); + const swatch = document.createElement("span"); + const label = document.createElement("span"); + button.type = "button"; + button.dataset.series = series.value; + button.setAttribute("aria-pressed", String(activeSeries.has(series.value))); + button.setAttribute("aria-label", `${activeSeries.has(series.value) ? "Hide" : "Show"} ${series.label}`); + swatch.className = `opportunities-extension-consumption-legend-swatch opportunities-extension-consumption-legend-${series.value}`; + swatch.setAttribute("aria-hidden", "true"); + label.textContent = series.label; + button.append(swatch, label); + button.addEventListener("click", () => { + if (activeSeries.has(series.value)) { + activeSeries.delete(series.value); + } else { + activeSeries.add(series.value); + } + + renderConsumptionTrendChart(section, chart, items, period, subPlanNum); + chart.querySelector(`[data-series="${series.value}"]`)?.focus(); + }); + legend.append(button); + }); + + return legend; + } + + function createConsumptionNavigationButton(direction, disabled) { + const button = document.createElement("button"); + button.type = "button"; + button.className = "opportunities-extension-consumption-navigation-button"; + button.disabled = disabled; + button.setAttribute("aria-label", direction === "older" ? "Show older consumption data" : "Show newer consumption data"); + button.title = direction === "older" ? "Older" : "Newer"; + const icon = createSvgElement("svg", { + viewBox: "0 0 24 24", + "aria-hidden": "true", + focusable: "false" + }); + icon.append(createSvgElement("path", { + d: direction === "older" ? "m14.5 5-7 7 7 7" : "m9.5 5 7 7-7 7", + fill: "none", + stroke: "currentColor", + "stroke-linecap": "round", + "stroke-linejoin": "round", + "stroke-width": "2" + })); + button.append(icon); + return button; + } + + function normalizeConsumptionTrendItems(items) { + const points = new Map(); + + (Array.isArray(items) ? items : []).forEach((item) => { + const dateKey = String(item.fyDate || (Array.isArray(item.fiscalTime) ? item.fiscalTime.join("|") : "")); + + if (!dateKey) { + return; + } + + if (!points.has(dateKey)) { + points.set(dateKey, { + dateKey, + date: parseConsumptionDate(item.fyDate), + fiscalTime: Array.isArray(item.fiscalTime) ? item.fiscalTime.slice() : [], + fiscalQtr: item.fiscalQtr || "", + funded: 0, + overage: 0, + arr: 0, + currencyCode: item.currencyCode || "USD" + }); + } + + const point = points.get(dateKey); + const amount = toFiniteNumber(item.usedAmount); + const category = String(item.usageCategory || "Funded Allocation").toUpperCase(); + + if (category === "OVERAGE") { + point.overage += amount; + } else { + point.funded += amount; + } + + point.arr = Math.max(point.arr, toFiniteNumber(item.arrCd)); + }); + + return Array.from(points.values()) + .map((point) => ({ ...point, total: point.funded + point.overage })) + .sort((left, right) => left.dateKey.localeCompare(right.dateKey)); + } + + function createConsumptionSvg(points, period, hasPartialLatestPoint, activeSeries, tooltip) { + const width = 1000; + const height = 320; + const plot = { left: 62, top: 24, right: 980, bottom: 258 }; + const plotWidth = plot.right - plot.left; + const plotHeight = plot.bottom - plot.top; + const average = calculateAverageConsumption(points, hasPartialLatestPoint, activeSeries); + const maximum = createNiceConsumptionMaximum(Math.max(average, ...points.map((point) => point.total))); + const svg = createSvgElement("svg", { + viewBox: `0 0 ${width} ${height}`, + role: "img", + "aria-label": `${period} cloud consumption chart` + }); + svg.classList.add("opportunities-extension-consumption-svg"); + + for (let tick = 0; tick <= 5; tick += 1) { + const value = (maximum / 5) * tick; + const y = plot.bottom - ((value / maximum) * plotHeight); + svg.append( + createSvgElement("line", { x1: plot.left, y1: y, x2: plot.right, y2: y, class: "consumption-grid-line" }), + createSvgText(plot.left - 10, y + 4, formatCompactConsumptionNumber(value), "consumption-axis-label", "end") + ); + } + + const step = plotWidth / Math.max(points.length, 1); + const barWidth = Math.min(44, Math.max(10, step * 0.58)); + + points.forEach((point, index) => { + const x = plot.left + (step * index) + ((step - barWidth) / 2); + const fundedAmount = activeSeries.has("funded") ? point.funded : 0; + const overageAmount = activeSeries.has("overage") ? point.overage : 0; + const visibleTotal = fundedAmount + overageAmount; + const fundedHeight = (fundedAmount / maximum) * plotHeight; + const overageHeight = (overageAmount / maximum) * plotHeight; + const group = createSvgElement("g", { + class: "consumption-bar-group", + tabindex: "0", + role: "img", + "aria-label": createConsumptionPointAriaLabel(point, period) + }); + + ["pointerenter", "pointermove", "focus"].forEach((eventName) => { + group.addEventListener(eventName, (event) => { + showConsumptionTooltip(tooltip, point, period, event); + }); + }); + ["pointerleave", "blur"].forEach((eventName) => { + group.addEventListener(eventName, () => { + tooltip.hidden = true; + }); + }); + + if (fundedHeight > 0) { + const fundedBar = createSvgElement("rect", { + x, + y: plot.bottom - fundedHeight, + width: barWidth, + height: fundedHeight, + class: "consumption-bar-funded" + }); + appendConsumptionBarAnimation( + fundedBar, + plot.bottom, + plot.bottom - fundedHeight, + fundedHeight, + index * 34 + ); + group.append(fundedBar); + } + + if (overageHeight > 0) { + const overageTop = plot.bottom - fundedHeight - overageHeight; + const overageBar = createSvgElement("rect", { + x, + y: overageTop, + width: barWidth, + height: overageHeight, + class: "consumption-bar-overage" + }); + appendConsumptionBarAnimation( + overageBar, + plot.bottom - fundedHeight, + overageTop, + overageHeight, + (index * 34) + 90 + ); + group.append(overageBar); + } + + const top = plot.bottom - fundedHeight - overageHeight; + + if (visibleTotal > 0) { + const valueLabel = createSvgText( + x + (barWidth / 2), + Math.max(plot.top + 11, top - 7), + formatCompactConsumptionNumber(visibleTotal), + "consumption-value-label consumption-animated-label", + "middle" + ); + valueLabel.style.animationDelay = `${(index * 34) + 360}ms`; + group.append(valueLabel); + } + + const label = createSvgText( + x + (barWidth / 2), + plot.bottom + 18, + formatConsumptionPointLabel(point, period), + "consumption-x-label", + period === "monthly" || period === "quarterly" ? "middle" : "end" + ); + + if (period === "weekly" || period === "daily") { + label.setAttribute("transform", `rotate(-65 ${x + (barWidth / 2)} ${plot.bottom + 18})`); + } + + group.append(label); + svg.append(group); + }); + + if (activeSeries.has("average")) { + const averageY = plot.bottom - ((average / maximum) * plotHeight); + const averageLine = createSvgElement("line", { + x1: plot.left, + y1: averageY, + x2: plot.right, + y2: averageY, + class: "consumption-average-line consumption-animated-average" + }); + svg.append(averageLine); + } + + return svg; + } + + function getConsumptionPointTotal(point, activeSeries) { + const selectedSeries = activeSeries && typeof activeSeries.has === "function" + ? activeSeries + : new Set(["funded", "overage"]); + return (selectedSeries.has("funded") ? point.funded : 0) + + (selectedSeries.has("overage") ? point.overage : 0); + } + + function calculateAverageConsumption(points, hasPartialLatestPoint, activeSeries) { + const completePoints = hasPartialLatestPoint && points.length > 1 ? points.slice(0, -1) : points; + return completePoints.reduce( + (sum, point) => sum + getConsumptionPointTotal(point, activeSeries), + 0 + ) / Math.max(completePoints.length, 1); + } + + function appendConsumptionBarAnimation(element, fromY, toY, height, delayMs) { + if (prefersReducedMotion()) { + return; + } + + element.append( + createSvgElement("animate", { + attributeName: "y", + from: fromY, + to: toY, + dur: "0.52s", + begin: `${delayMs}ms`, + fill: "freeze", + calcMode: "spline", + keyTimes: "0;1", + keySplines: "0.2 0.8 0.2 1" + }), + createSvgElement("animate", { + attributeName: "height", + from: "0", + to: height, + dur: "0.52s", + begin: `${delayMs}ms`, + fill: "freeze", + calcMode: "spline", + keyTimes: "0;1", + keySplines: "0.2 0.8 0.2 1" + }) + ); + } + + function prefersReducedMotion() { + return typeof window.matchMedia === "function" + && window.matchMedia("(prefers-reduced-motion: reduce)").matches; + } + + function createConsumptionPointAriaLabel(point, period) { + return `${formatConsumptionPointLabel(point, period)}. ` + + `Funded Allocation: ${formatConsumptionCurrency(point.funded, point.currencyCode)}. ` + + `Overage: ${formatConsumptionCurrency(point.overage, point.currencyCode)}. ` + + `Total: ${formatConsumptionCurrency(point.total, point.currencyCode)}.`; + } + + function showConsumptionTooltip(tooltip, point, period, event) { + const figure = tooltip.parentElement; + + if (!figure) { + return; + } + + tooltip.textContent = ""; + const title = document.createElement("strong"); + title.textContent = formatConsumptionPointLabel(point, period); + const details = document.createElement("span"); + details.textContent = `Funded Allocation: ${formatConsumptionCurrency(point.funded, point.currencyCode)}`; + const overage = document.createElement("span"); + overage.textContent = `Overage: ${formatConsumptionCurrency(point.overage, point.currencyCode)}`; + const total = document.createElement("span"); + total.textContent = `Total: ${formatConsumptionCurrency(point.total, point.currencyCode)}`; + tooltip.append(title, details, overage, total); + tooltip.hidden = false; + + const figureBounds = figure.getBoundingClientRect(); + const targetBounds = event.currentTarget.getBoundingClientRect(); + const pointerX = Number.isFinite(event.clientX) && event.clientX > 0 + ? event.clientX + : targetBounds.left + (targetBounds.width / 2); + const pointerY = Number.isFinite(event.clientY) && event.clientY > 0 + ? event.clientY + : targetBounds.top; + const left = Math.min( + Math.max(8, pointerX - figureBounds.left + 12), + Math.max(8, figureBounds.width - tooltip.offsetWidth - 8) + ); + const top = Math.max(8, pointerY - figureBounds.top - tooltip.offsetHeight - 10); + tooltip.style.left = `${left}px`; + tooltip.style.top = `${top}px`; + } + + function createSvgElement(name, attributes = {}) { + const element = document.createElementNS(SVG_NAMESPACE, name); + + Object.entries(attributes).forEach(([key, value]) => { + element.setAttribute(key, String(value)); + }); + return element; + } + + function createSvgText(x, y, value, className, anchor) { + const text = createSvgElement("text", { x, y, class: className, "text-anchor": anchor }); + text.textContent = value; + return text; + } + + function createNiceConsumptionMaximum(value) { + if (!Number.isFinite(value) || value <= 0) { + return 1; + } + + const magnitude = 10 ** Math.floor(Math.log10(value)); + const normalized = value / magnitude; + const step = normalized <= 1 ? 1 : normalized <= 2 ? 2 : normalized <= 5 ? 5 : 10; + return step * magnitude; + } + + function formatConsumptionPointLabel(point, period) { + if (period === "quarterly") { + return point.fiscalTime[0] || point.fiscalQtr || point.dateKey; + } + + if (period === "monthly") { + return point.fiscalTime[1] || formatConsumptionDate(point.date, { month: "short" }); + } + + return point.fiscalTime[0] || formatConsumptionDate(point.date, { + day: "2-digit", + month: "short", + year: "2-digit" + }); + } + + function formatConsumptionRange(points, period) { + if (points.length === 0) { + return "No data"; + } + + if (period === "quarterly") { + return `${formatConsumptionPointLabel(points[0], period)} - ${formatConsumptionPointLabel(points[points.length - 1], period)}`; + } + + if (period === "monthly") { + const options = { month: "short", year: "numeric" }; + return `${formatConsumptionDate(points[0].date, options)} - ${formatConsumptionDate(points[points.length - 1].date, options)}`; + } + + const options = { day: "2-digit", month: "short" }; + return `${formatConsumptionDate(points[0].date, options)} - ${formatConsumptionDate(points[points.length - 1].date, options)}`; + } + + function parseConsumptionDate(value) { + if (!value) { + return null; + } + + const parts = String(value).split("-").map(Number); + return parts.length === 3 && parts.every(Number.isFinite) + ? new Date(parts[0], parts[1] - 1, parts[2]) + : null; + } + + function formatConsumptionDate(date, options) { + if (!(date instanceof Date) || Number.isNaN(date.getTime())) { + return "-"; + } + + return new Intl.DateTimeFormat("en-US", options).format(date).replace(",", ""); + } + + function formatCompactConsumptionNumber(value) { + const amount = toFiniteNumber(value); + + if (Math.abs(amount) >= 1000000) { + return `${(amount / 1000000).toFixed(amount >= 10000000 ? 0 : 1).replace(".0", "")}M`; + } + + if (Math.abs(amount) >= 1000) { + return `${Math.round(amount / 1000)}K`; + } + + return String(Math.round(amount)); + } + + function formatConsumptionCurrency(value, currencyCode) { + try { + return new Intl.NumberFormat("en-US", { + style: "currency", + currency: currencyCode || "USD", + maximumFractionDigits: 0 + }).format(toFiniteNumber(value)); + } catch (error) { + return `${currencyCode || "USD"} ${Math.round(toFiniteNumber(value)).toLocaleString("en-US")}`; + } + } + + function toFiniteNumber(value) { + const number = Number(value); + return Number.isFinite(number) ? number : 0; + } + function closeAccountPlansModal() { const modal = document.getElementById(ACCOUNT_PLANS_MODAL_ID); @@ -4343,21 +5055,32 @@ min-width: 0; } - .opportunities-extension-products-count { + .opportunities-extension-actions-content { display: inline-flex; align-items: center; justify-content: center; + gap: 4px; + white-space: nowrap; + } + + .opportunities-extension-products-count { + display: inline-grid; + width: auto; + min-width: 20px; + height: 20px; + place-items: center; flex: 0 0 auto; - min-width: 24px; - min-height: 22px; - padding: 2px 7px; + box-sizing: border-box; + padding: 0 3px; border: 1px solid #7ca7b2; border-radius: 999px; background: #e8f4f6; color: #006d7a; - font-size: 11px; + font-size: 10px; font-weight: 700; line-height: 1; + text-align: center; + vertical-align: middle; cursor: pointer; } @@ -4711,7 +5434,7 @@ z-index: 4; display: grid; place-items: center; - padding: 24px; + padding: 12px; background: rgba(0, 0, 0, .42); } @@ -4721,8 +5444,8 @@ .opportunities-extension-account-plans-dialog { display: grid; - width: min(860px, 100%); - max-height: min(720px, calc(100vh - 48px)); + width: min(1180px, 100%); + height: min(760px, calc(100vh - 24px)); grid-template-rows: auto minmax(0, 1fr); overflow: hidden; border: 1px solid #8f8a85; @@ -4737,7 +5460,7 @@ align-items: flex-start; justify-content: space-between; gap: 16px; - padding: 18px 20px; + padding: 14px 20px; border-bottom: 1px solid #dedbd7; } @@ -4755,16 +5478,28 @@ } .opportunities-extension-account-plans-content { + display: grid; + grid-template-rows: auto minmax(0, 1fr); min-height: 0; - overflow: auto; + overflow: hidden; } .opportunities-extension-account-plans-tabs { display: flex; gap: 4px; overflow-x: auto; - padding: 12px 20px 0; + padding: 8px 20px 0; border-bottom: 1px solid #dedbd7; + scrollbar-width: none; + } + + .opportunities-extension-account-plans-tabs::-webkit-scrollbar { + display: none; + } + + .opportunities-extension-account-plans-panels { + min-height: 0; + overflow: hidden; } .opportunities-extension-account-plan-tab { @@ -4796,7 +5531,12 @@ } .opportunities-extension-account-plan-panel { - padding: 24px 20px 28px; + display: grid; + height: 100%; + min-height: 0; + grid-template-rows: auto minmax(0, 1fr); + box-sizing: border-box; + padding: 16px 20px; } .opportunities-extension-account-plan-panel[hidden] { @@ -4805,16 +5545,18 @@ .opportunities-extension-account-plan-details { display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 0 28px; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0 24px; margin: 0; } .opportunities-extension-account-plan-details > div { display: grid; - grid-template-columns: minmax(130px, 38%) minmax(0, 1fr); - gap: 12px; - padding: 13px 0; + grid-template-columns: minmax(104px, 38%) minmax(0, 1fr); + gap: 10px; + min-height: 42px; + align-items: center; + padding: 7px 0; border-bottom: 1px solid #ebe8e5; } @@ -4832,6 +5574,334 @@ overflow-wrap: anywhere; } + .opportunities-extension-consumption-trend { + display: grid; + min-height: 0; + grid-template-rows: auto minmax(0, 1fr); + margin-top: 14px; + padding-top: 12px; + border-top: 1px solid #dedbd7; + } + + .opportunities-extension-consumption-trend-header { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 24px; + } + + .opportunities-extension-consumption-periods { + display: flex; + align-items: center; + gap: 4px; + } + + .opportunities-extension-consumption-periods button { + min-height: 38px; + padding: 7px 14px; + border: 1px solid transparent; + border-radius: 4px; + background: transparent; + color: #312d2a; + font-size: 13px; + font-weight: 600; + line-height: 1.2; + cursor: pointer; + } + + .opportunities-extension-consumption-periods button:hover { + background: #f0eeeb; + } + + .opportunities-extension-consumption-periods button[aria-pressed="true"] { + border-color: #00758f; + background: #e7f6f8; + color: #005e73; + } + + .opportunities-extension-consumption-periods button:focus-visible, + .opportunities-extension-consumption-navigation-button:focus-visible { + outline: 2px solid #00758f; + outline-offset: 2px; + } + + .opportunities-extension-consumption-chart { + display: grid; + min-height: 0; + grid-template-rows: auto minmax(0, 1fr); + margin-top: 4px; + } + + .opportunities-extension-consumption-navigation { + display: grid; + grid-template-columns: 36px minmax(130px, auto) 36px; + align-items: center; + justify-content: center; + gap: 8px; + min-height: 40px; + margin-bottom: 2px; + } + + .opportunities-extension-consumption-navigation strong { + color: #312d2a; + font-size: 13px; + text-align: center; + } + + .opportunities-extension-consumption-navigation-button { + display: grid; + width: 36px; + height: 36px; + place-items: center; + padding: 0; + border: 1px solid #8f8a85; + border-radius: 4px; + background: #ffffff; + color: #312d2a; + cursor: pointer; + } + + .opportunities-extension-consumption-navigation-button:hover:not(:disabled) { + border-color: #00758f; + background: #f0eeeb; + color: #005e73; + } + + .opportunities-extension-consumption-navigation-button:disabled { + border-color: #dedbd7; + color: #b8b2ad; + cursor: default; + } + + .opportunities-extension-consumption-navigation-button svg { + width: 18px; + height: 18px; + } + + .opportunities-extension-consumption-figure { + position: relative; + min-width: 0; + min-height: 0; + margin: 0; + scrollbar-width: none; + } + + .opportunities-extension-consumption-figure::-webkit-scrollbar { + display: none; + } + + .opportunities-extension-consumption-visual { + display: grid; + height: 100%; + min-height: 0; + grid-template-columns: minmax(0, 1fr) 176px; + align-items: center; + gap: 16px; + } + + .opportunities-extension-consumption-svg { + display: block; + width: 100%; + height: 300px; + min-height: 0; + overflow: visible; + } + + .opportunities-extension-consumption-svg .consumption-grid-line { + stroke: #dedbd7; + stroke-width: 1; + shape-rendering: crispEdges; + } + + .opportunities-extension-consumption-svg .consumption-axis-label, + .opportunities-extension-consumption-svg .consumption-x-label { + fill: #5f5a55; + font-family: Arial, sans-serif; + font-size: 12px; + } + + .opportunities-extension-consumption-svg .consumption-value-label { + fill: #312d2a; + font-family: Arial, sans-serif; + font-size: 12px; + font-weight: 700; + } + + .opportunities-extension-consumption-svg .consumption-bar-funded { + fill: #b77888; + } + + .opportunities-extension-consumption-svg .consumption-bar-overage { + fill: #de7907; + } + + .opportunities-extension-consumption-svg .consumption-average-line { + stroke: #de7907; + stroke-width: 1.5; + stroke-dasharray: 6 6; + } + + .opportunities-extension-consumption-svg .consumption-bar-group { + cursor: default; + outline: none; + } + + .opportunities-extension-consumption-svg .consumption-bar-group rect { + transition: filter .16s ease, opacity .16s ease, stroke .16s ease; + } + + .opportunities-extension-consumption-svg .consumption-bar-group:hover rect, + .opportunities-extension-consumption-svg .consumption-bar-group:focus rect { + filter: brightness(1.06); + stroke: #312d2a; + stroke-width: 1.5; + } + + .opportunities-extension-consumption-svg .consumption-animated-label { + opacity: 0; + animation: opportunities-extension-consumption-label-in .24s ease-out forwards; + } + + .opportunities-extension-consumption-svg .consumption-animated-average { + opacity: 0; + animation: opportunities-extension-consumption-average-in .35s .32s ease-out forwards; + } + + @keyframes opportunities-extension-consumption-label-in { + from { opacity: 0; transform: translateY(4px); } + to { opacity: 1; transform: translateY(0); } + } + + @keyframes opportunities-extension-consumption-average-in { + from { opacity: 0; } + to { opacity: 1; } + } + + .opportunities-extension-consumption-legend { + display: grid; + align-content: center; + gap: 4px; + } + + .opportunities-extension-consumption-legend button { + display: grid; + min-width: 0; + grid-template-columns: 16px minmax(0, 1fr); + align-items: center; + gap: 8px; + padding: 6px 4px; + border: 0; + border-radius: 4px; + background: transparent; + color: #312d2a; + font-size: 12px; + line-height: 1.25; + text-align: left; + cursor: pointer; + } + + .opportunities-extension-consumption-legend button:hover { + background: #f0eeeb; + } + + .opportunities-extension-consumption-legend button:focus-visible { + outline: 2px solid #00758f; + outline-offset: 1px; + } + + .opportunities-extension-consumption-legend button[aria-pressed="false"] { + color: #77726d; + opacity: .55; + } + + .opportunities-extension-consumption-legend-swatch { + display: block; + width: 12px; + height: 12px; + justify-self: center; + } + + .opportunities-extension-consumption-legend-funded { + background: #b77888; + } + + .opportunities-extension-consumption-legend-overage { + background: #de7907; + } + + .opportunities-extension-consumption-legend-average { + height: 0; + border-top: 2px dashed #de7907; + } + + .opportunities-extension-consumption-tooltip { + position: absolute; + z-index: 2; + display: grid; + min-width: 190px; + max-width: 260px; + gap: 4px; + padding: 10px 12px; + border: 1px solid #8f8a85; + border-radius: 4px; + background: #ffffff; + box-shadow: 0 4px 12px rgba(0, 0, 0, .2); + color: #312d2a; + font-size: 12px; + line-height: 1.3; + pointer-events: none; + } + + .opportunities-extension-consumption-tooltip[hidden] { + display: none !important; + } + + .opportunities-extension-consumption-tooltip strong { + margin-bottom: 2px; + font-size: 13px; + } + + .opportunities-extension-consumption-chart-skeleton { + height: 100%; + min-height: 0; + padding: 34px 62px 24px; + box-sizing: border-box; + } + + .opportunities-extension-consumption-chart-skeleton-plot { + display: flex; + height: 100%; + min-height: 220px; + align-items: flex-end; + justify-content: space-around; + gap: 12px; + padding: 0 14px; + border-bottom: 1px solid #dedbd7; + border-left: 1px solid #dedbd7; + } + + .opportunities-extension-consumption-chart-skeleton-plot span { + width: min(38px, 6%); + border-radius: 2px 2px 0 0; + background: #dedbd7; + animation: opportunities-extension-skeleton-pulse 1.15s ease-in-out infinite alternate; + } + + .opportunities-extension-consumption-chart-empty, + .opportunities-extension-consumption-chart-error { + display: grid; + min-height: 220px; + place-items: center; + margin: 0; + color: #5f5a55; + font-size: 14px; + text-align: center; + } + + .opportunities-extension-consumption-chart-error { + color: #9f2d20; + } + .opportunities-extension-account-plan-status-indicator { display: inline-block; width: 10px; @@ -4899,8 +5969,12 @@ } @media (prefers-reduced-motion: reduce) { - .opportunities-extension-account-plans-skeleton-bar { + .opportunities-extension-account-plans-skeleton-bar, + .opportunities-extension-consumption-chart-skeleton-plot span, + .opportunities-extension-consumption-svg .consumption-animated-label, + .opportunities-extension-consumption-svg .consumption-animated-average { animation: none; + opacity: 1; } } @@ -5282,10 +6356,18 @@ color: #ffb4a8; } + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-chart-error { + color: #ffb4a8; + } + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-skeleton-bar { background: #5b5753; } + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-chart-skeleton-plot span { + background: #5b5753; + } + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plan-status-indicator { background: #8f8a85; box-shadow: 0 0 0 3px #3b3937; @@ -5354,6 +6436,14 @@ color: #f6f4f2; } + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-navigation strong { + color: #f6f4f2; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-chart-empty { + color: #c9c5c1; + } + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-customer-filter-select-all, .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-table th { border-bottom-color: #4e4a46; @@ -5369,6 +6459,11 @@ border-color: #4e4a46; } + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-trend, + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-chart-skeleton-plot { + border-color: #4e4a46; + } + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plan-tab { color: #c9c5c1; } @@ -5383,6 +6478,97 @@ color: #91e4ed; } + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-periods button { + color: #f6f4f2; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-periods button:hover { + background: #3b3937; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-periods button[aria-pressed="true"] { + border-color: #43c4d5; + background: #173e43; + color: #91e4ed; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-navigation-button { + border-color: #6a6560; + background: #333130; + color: #f6f4f2; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-navigation-button:hover:not(:disabled) { + border-color: #43c4d5; + background: #3b3937; + color: #91e4ed; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-navigation-button:disabled { + border-color: #4e4a46; + color: #746f6a; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-svg .consumption-grid-line { + stroke: #4e4a46; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-svg .consumption-axis-label, + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-svg .consumption-x-label { + fill: #c9c5c1; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-svg .consumption-value-label { + fill: #f6f4f2; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-svg .consumption-bar-funded { + fill: #ca8798; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-svg .consumption-bar-overage { + fill: #f09a32; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-svg .consumption-average-line { + stroke: #f09a32; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-svg .consumption-bar-group:hover rect, + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-svg .consumption-bar-group:focus rect { + stroke: #f6f4f2; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-legend button { + color: #f6f4f2; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-legend button:hover { + background: #3b3937; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-legend button[aria-pressed="false"] { + color: #a8a39e; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-legend-funded { + background: #ca8798; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-legend-overage { + background: #f09a32; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-legend-average { + border-top-color: #f09a32; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-tooltip { + border-color: #6a6560; + background: #333130; + color: #f6f4f2; + } + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-preferences-option:hover { background: #3b3937; } @@ -5733,17 +6919,77 @@ } .opportunities-extension-account-plans-dialog { - max-height: calc(100vh - 24px); + height: calc(100vh - 24px); + } + + .opportunities-extension-account-plan-panel { + padding: 12px 14px; } .opportunities-extension-account-plan-details { + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0 14px; + } + + .opportunities-extension-account-plan-details > div { grid-template-columns: 1fr; + align-content: center; + gap: 3px; + min-height: 54px; + padding: 5px 0; } .opportunities-extension-account-plans-skeleton-details { grid-template-columns: 1fr; } + .opportunities-extension-consumption-trend-header { + align-items: stretch; + } + + .opportunities-extension-consumption-periods { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 3px; + } + + .opportunities-extension-consumption-periods button { + min-width: 0; + padding: 7px 4px; + font-size: 12px; + } + + .opportunities-extension-consumption-chart { + min-height: 0; + } + + .opportunities-extension-consumption-svg { + width: 760px; + height: 245px; + } + + .opportunities-extension-consumption-visual { + width: 920px; + grid-template-columns: 760px 144px; + gap: 12px; + } + + .opportunities-extension-consumption-figure { + overflow-x: auto; + overscroll-behavior-inline: contain; + } + + .opportunities-extension-consumption-chart-skeleton { + min-height: 0; + padding: 24px 18px 18px; + } + + .opportunities-extension-consumption-chart-skeleton-plot { + min-height: 180px; + gap: 4px; + padding: 0 6px; + } + .opportunities-extension-preferences-options { grid-template-columns: 1fr; } diff --git a/tests/consumptionTrend.test.cjs b/tests/consumptionTrend.test.cjs new file mode 100644 index 0000000..db1a3fc --- /dev/null +++ b/tests/consumptionTrend.test.cjs @@ -0,0 +1,174 @@ +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const vm = require("node:vm"); + +const root = path.resolve(__dirname, ".."); + +function loadContentHelpers() { + const source = fs.readFileSync(path.join(root, "src", "content.js"), "utf8"); + const marker = "\n if (insertTile()) {"; + const instrumented = source.replace(marker, ` + globalThis.__consumptionTrendTest = { + normalizeConsumptionTrendItems, + formatConsumptionPointLabel, + createNiceConsumptionMaximum, + calculateAverageConsumption + }; +${marker}`); + const context = { + chrome: { runtime: { onMessage: { addListener() {} } } }, + document: { getElementById: () => ({}) }, + window: { + location: { + hostname: "eeho.fa.us2.oraclecloud.com", + pathname: "/fscmUI/faces/FuseWelcome" + } + }, + Intl, + Date, + Map, + Set, + Number, + String, + Array, + Object, + Math + }; + + vm.runInNewContext(instrumented, context); + return context.__consumptionTrendTest; +} + +function loadBackground(fetchCalls) { + let listener; + const source = fs.readFileSync(path.join(root, "src", "background.js"), "utf8"); + const headers = { + get(name) { + return name.toLowerCase() === "content-type" ? "application/json" : null; + }, + forEach(callback) { + callback("application/json", "content-type"); + } + }; + const context = { + chrome: { + runtime: { + onMessage: { + addListener(callback) { + listener = callback; + } + } + } + }, + fetch: async (url, options) => { + fetchCalls.push({ url, options }); + return { + ok: true, + status: 200, + statusText: "OK", + headers, + text: async () => JSON.stringify([{ fyDate: "2026-09-01", usedAmount: 25 }]) + }; + }, + URL, + Date, + JSON, + Map, + Set, + Promise, + String, + Array, + Object, + Number, + Math, + setTimeout, + clearTimeout + }; + + vm.runInNewContext(source, context); + return listener; +} + +function dispatch(listener, message) { + return new Promise((resolve) => { + const asynchronous = listener(message, {}, resolve); + assert.equal(asynchronous, true); + }); +} + +async function run() { + const helpers = loadContentHelpers(); + const points = helpers.normalizeConsumptionTrendItems([ + { + fyDate: "2026-08-22", + fiscalTime: ["22-Aug-26"], + fiscalQtr: "FY27-Q1", + usageCategory: "Funded Allocation", + usedAmount: 31000, + arrCd: 120000, + currencyCode: "BRL" + }, + { + fyDate: "2026-08-22", + fiscalTime: ["22-Aug-26"], + usageCategory: "OVERAGE", + usedAmount: 4200, + currencyCode: "BRL" + }, + { + fyDate: "2026-08-15", + fiscalTime: ["15-Aug-26"], + usageCategory: null, + usedAmount: 29000, + currencyCode: "BRL" + } + ]); + + assert.equal(points.length, 2); + assert.equal(points[0].dateKey, "2026-08-15"); + assert.equal(points[0].funded, 29000); + assert.equal(points[1].funded, 31000); + assert.equal(points[1].overage, 4200); + assert.equal(points[1].total, 35200); + assert.equal(points[1].arr, 120000); + assert.equal(helpers.formatConsumptionPointLabel(points[1], "weekly"), "22-Aug-26"); + assert.equal(helpers.createNiceConsumptionMaximum(35200), 50000); + assert.equal(helpers.calculateAverageConsumption(points, true), 29000); + assert.equal(helpers.calculateAverageConsumption(points, false), 32100); + assert.equal(helpers.calculateAverageConsumption(points, true, new Set(["funded"])), 29000); + assert.equal(helpers.calculateAverageConsumption(points, false, new Set(["overage"])), 2100); + + const fetchCalls = []; + const listener = loadBackground(fetchCalls); + const periods = ["quarterly", "monthly", "weekly", "daily"]; + + for (const period of periods) { + const response = await dispatch(listener, { + type: "opportunitiesExtension.requestConsumptionTrend", + subPlanNum: "11276098", + period, + authorization: { name: "g2m-authorization", value: "ocicons-secret" } + }); + + assert.equal(response.ok, true); + assert.equal(response.period, period); + assert.equal(response.items.length, 1); + assert.equal(response.debugRequests[0].name, `requestConsumptionService${period[0].toUpperCase()}${period.slice(1)}`); + } + + assert.equal(fetchCalls.length, 4); + fetchCalls.forEach((call, index) => { + assert.equal(call.options.method, "GET"); + assert.equal(call.options.headers["G2m-Authorization"], "ocicons-secret"); + assert.match(call.url, /\/11276098\?quarter=All&timePeriod=/); + assert.ok(call.url.endsWith(periods[index])); + }); + + console.log("Consumption trend tests passed."); +} + +run().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/tests/fixtures/consumption-trend.html b/tests/fixtures/consumption-trend.html new file mode 100644 index 0000000..69ac380 --- /dev/null +++ b/tests/fixtures/consumption-trend.html @@ -0,0 +1,103 @@ + + + + + + Consumption Trend Visual Test + + + +
+ + +