Compare commits

...
Author SHA1 Message Date
Nariman Jelveh 771506a58d feat(dashboard): show add-on credit as its own bar on the Usage tab
The Usage tab told an account how much add-on credit it had left in a
line of text under the monthly meter. It now gets a meter of its own
there: a heading, how much of the credit bought has been spent, and a
bar, with the line saying what is left and when it is used kept under
it. An account that never had add-on credit sees the tab as before.
2026-11-10 15:10:17 -07:00
5 changed files with 99 additions and 12 deletions

No files matched your search

+32 -7
View File
@@ -19,7 +19,7 @@
import { formatCount, formatCredits, formatDollarsFromMicrocents, usageIsCredits } from './credits.js';
import { isOrgSeat } from './orgSeat.js';
import { addonCreditsRemaining, meterPercent, monthlyUsageOrThrow, usageBudget } from './usageBudget.js';
import { addonCreditsBudget, addonCreditsRemaining, meterPercent, monthlyUsageOrThrow, usageBudget } from './usageBudget.js';
import { getMonthlyUsage, getStorageSpace } from './usageRequests.js';
// Whether the server reported credits — decides whether the usage surfaces
@@ -77,10 +77,23 @@ const TabUsage = {
<div class="usage-progbar-wrapper">
<div class="usage-progbar" style="width: 0;"></div>
</div>
<p class="usage-addon-credits" style="display: none;">
<span class="usage-addon-left"></span>
<span class="usage-addon-note">&middot; ${i18n('addon_credits_note')}</span>
</p>
<div class="usage-addon" style="display: none;">
<div class="driver-usage-header">
<h3 id="usage-addon-title" style="margin:0; font-size: 14px; flex-grow: 1; font-weight: 500;">${i18n('addon_credits')}</h3>
<div style="font-size: 13px; margin-bottom: 3px; opacity:0.85;">
<span class="usage-addon-percent">--%</span>
<span> &middot; </span>
${i18n('used_of', { used: '<span class="usage-addon-used">--</span>', available: '<span class="usage-addon-capacity">--</span>' }, false)}
</div>
</div>
<div class="usage-progbar-wrapper" role="progressbar" aria-labelledby="usage-addon-title" aria-valuemin="0" aria-valuemax="100">
<div class="usage-addon-bar" style="width: 0;"></div>
</div>
<p class="usage-addon-credits">
<span class="usage-addon-left"></span>
<span class="usage-addon-note">&middot; ${i18n('addon_credits_note')}</span>
</p>
</div>
<h3 style="margin:15px 0 10px 0; font-size: 14px; font-weight: 500;">${i18n('usage_details')}</h3>
<div class="driver-usage-details-content visible">
</div>
@@ -330,8 +343,20 @@ async function update_usage_details ($el_window) {
'background-color': window.usage_bar_color(budget.barPercent),
});
$('.usage-addon-credits').toggle(addonLeft !== null);
if ( addonLeft !== null ) {
// Its own meter under the monthly one, for an account that has ever had any.
const addon = addonCreditsBudget(res.allowanceInfo);
$('.usage-addon').toggle(addon !== null);
if ( addon !== null ) {
$('.usage-addon-used').html(amount(addon.used));
$('.usage-addon-capacity').html(amount(addon.capacity));
$('.usage-addon-percent').html(`${addon.percent}%`);
$('.usage-addon-bar').css({
width: `${addon.barPercent}%`,
'background-color': window.usage_bar_color(addon.barPercent),
}).parent().attr({
'aria-valuenow': addon.percent,
'aria-valuetext': `${addon.percent}%`,
});
$('.usage-addon-left').html(i18n('addon_credits_available', { amount: amount(addonLeft) }));
}
+24 -2
View File
@@ -30,8 +30,8 @@ export const meterPercent = (share) => (share < 100 ? Math.min(99, Math.round(sh
/**
* @typedef {Object} UsageBudget
* @property {number} used - Allowance-charged spend, in the server's units.
* @property {number} capacity - The monthly plan allowance.
* @property {number} used - Spend charged to it, in the server's units.
* @property {number} capacity - What the meter is out of: the monthly plan allowance, or the add-on credit bought.
* @property {number} percent - `used` as a whole-number share of `capacity`.
* @property {number} barPercent - The share clamped to 0-100, for a bar width.
*/
@@ -83,6 +83,28 @@ export const addonCreditsRemaining = (allowanceInfo) => {
return Math.max(0, purchased - nonNegative(addons.consumedPurchaseCredits));
};
/**
* The add-on credit meter: what has been spent of all the add-on credit the
* account ever bought. Null when it has never had any, as there is no pool
* to meter.
*
* @param {Object | null | undefined} allowanceInfo
* @returns {UsageBudget | null}
*/
export const addonCreditsBudget = (allowanceInfo) => {
const left = addonCreditsRemaining(allowanceInfo);
if ( left === null ) return null;
const capacity = nonNegative(allowanceInfo.addons.purchasedCredits);
const used = capacity - left;
const share = (used / capacity) * 100;
return {
used,
capacity,
percent: meterPercent(share),
barPercent: Math.max(0, Math.min(100, share)),
};
};
/**
* `getMonthlyUsage()` resolves with whatever body the server answered, an
* error included. Throws unless that body is a usage report, so a failed
+36 -1
View File
@@ -18,7 +18,7 @@
*/
import { describe, expect, it } from 'vitest';
import { addonCreditsRemaining, meterPercent, monthlyUsageOrThrow, usageBudget } from './usageBudget.js';
import { addonCreditsBudget, addonCreditsRemaining, meterPercent, monthlyUsageOrThrow, usageBudget } from './usageBudget.js';
const info = (allowance, addons = {}) => ({
monthUsageAllowance: allowance,
@@ -148,6 +148,41 @@ describe('addonCreditsRemaining', () => {
});
});
describe('addonCreditsBudget', () => {
it('has no meter for an account that never had add-on credit', () => {
expect(addonCreditsBudget(info(900_000_000))).toBeNull();
expect(addonCreditsBudget(info(900_000_000, { purchasedCredits: 0 }))).toBeNull();
expect(addonCreditsBudget(undefined)).toBeNull();
});
it('meters what was spent of the credit bought', () => {
expect(addonCreditsBudget(info(900_000_000, {
purchasedCredits: 2_500,
consumedPurchaseCredits: 850,
}))).toEqual({ used: 850, capacity: 2_500, percent: 34, barPercent: 34 });
});
it('starts empty when nothing has been spent, or the spend is unreadable', () => {
expect(addonCreditsBudget(info(0, { purchasedCredits: 1_000 })))
.toEqual({ used: 0, capacity: 1_000, percent: 0, barPercent: 0 });
expect(addonCreditsBudget(info(0, { purchasedCredits: 1_000, consumedPurchaseCredits: NaN })).used).toBe(0);
});
it('reads full, never past it, once the pool is spent', () => {
expect(addonCreditsBudget(info(0, {
purchasedCredits: 1_000,
consumedPurchaseCredits: 1_200,
}))).toEqual({ used: 1_000, capacity: 1_000, percent: 100, barPercent: 100 });
});
it('holds at 99% while any credit is left', () => {
expect(addonCreditsBudget(info(0, {
purchasedCredits: 100_000,
consumedPurchaseCredits: 99_999,
})).percent).toBe(99);
});
});
describe('monthlyUsageOrThrow', () => {
it('passes a usage report through', () => {
const res = { usage: { total: 0 }, allowanceInfo: null };
+6 -2
View File
@@ -5037,17 +5037,21 @@ fieldset[name=number-code] {
align-items: stretch;
}
#storage-bar, #storage-bar-host, .usage-progbar {
#storage-bar, #storage-bar-host, .usage-progbar, .usage-addon-bar {
height: 100%;
background-color: var(--dashboard-bento-usage-bar-fill, #ea580c);
width: 0;
transition: width 0.4s ease, background-color 0.4s ease;
}
#storage-used-percent, .usage-progbar-percent {
#storage-used-percent, .usage-progbar-percent, .usage-addon-percent {
font-weight: 500;
}
.usage-addon {
margin-top: 18px;
}
.usage-addon-credits {
margin: 8px 0 0;
font-size: 13px;
+1
View File
@@ -28,6 +28,7 @@ const en = {
account_password: 'Verify Account Password',
access_granted_to: 'Access Granted To',
add_another_account: 'Add Another Account',
addon_credits: 'Add-on credits',
addon_credits_available: '+ {{amount}} add-on credits left',
addon_credits_note: 'Used once your monthly credits run out.',
add_app: 'Add an app',