Skip to content

Commit dfeef01

Browse files
feat(billing): surface capacity equivalences on the nav compute gauge (#4350)
* feat(billing): surface capacity equivalences on the nav compute gauge Wire the (previously orphaned) BillingEquivalencesChipsComponent into the nav compute-gauge tooltip. When serverConfig.billing.equivalences is set, derive a human-readable remaining-capacity estimate per entry: count = floor(totalRemaining / unitCost), totalRemaining = max(0, (meterQuota + extrasRemaining) - meterUsed). Only consumption-scaled kinds (easy/hard) with a finite positive unitCost render; the unit-cost framing handles per-period and one-shot grants with no special branch. Config-driven, no-op when absent. Also adds a mount fade/scale-in and a low-remaining (>= 80%) pulse to the ring to make the gauge more noticeable; both respect prefers-reduced-motion. Tests (15 new) cover the derivation, filtering, one-shot grant, exhaustion and ring motion. Documents the equivalences contract in the module README. Closes #4349 * fix(billing): address review on the nav-gauge equivalences - Header comment now quotes the exact rendered chip text (integer counts, no '~'). - Open-tooltip test captures + restores the original visualViewport descriptor instead of clobbering/deleting it, so it never disturbs a real/global one and Vuetify overlay teardown stays safe.
1 parent e7d9c1d commit dfeef01

3 files changed

Lines changed: 383 additions & 10 deletions

File tree

src/modules/billing/README.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,3 +49,39 @@ Auto-colored progress bar (green/orange/red). Shows "Unlimited" for uncapped pla
4949
```vue
5050
<BillingPlanBadgeComponent :plan="currentPlan" />
5151
```
52+
53+
### `<BillingNavComputeGaugeComponent>`
54+
55+
Sidenav compute-usage indicator (meter mode). A color-coded ring + `X% used`
56+
label; the hover tooltip shows `used / total compute · resets <day>`. The ring
57+
fades/scales in on mount and pulses near exhaustion (≥ 80%) — both motions
58+
respect `prefers-reduced-motion`.
59+
60+
When the server config defines `billing.equivalences`, the tooltip also surfaces
61+
a human-readable remaining-capacity estimate via `<BillingEquivalencesChipsComponent>`.
62+
63+
### `<BillingEquivalencesChipsComponent>` + the `equivalences` contract
64+
65+
Renders capacity chips (`{ kind, count, label }`) color-coded by kind
66+
(`easy` → green, `hard` → amber, `feature` → brand).
67+
68+
The nav gauge derives those chips from `serverConfig.billing.equivalences` — an
69+
**opaque passthrough** from the backend auth config that **downstream projects
70+
define** (the devkit ships only a neutral demo default):
71+
72+
```js
73+
// serverConfig.billing.equivalences (null / absent / [] → gauge shows raw units only)
74+
[
75+
{ kind: 'easy', unitCost: 200, label: 'easy operations' },
76+
{ kind: 'hard', unitCost: 2000, label: 'heavy operations' },
77+
]
78+
```
79+
80+
- `unitCost` = compute units consumed per ONE operation of that kind (finite, `> 0`).
81+
- The gauge renders `count = floor(totalRemaining / unitCost)` per entry, where
82+
`totalRemaining = max(0, (meterQuota + extrasRemaining) − meterUsed)`.
83+
- Only the consumption-scaled kinds `easy` / `hard` are surfaced; entries with a
84+
non-positive/non-finite `unitCost`, a non-string `label`, or any other kind are
85+
dropped. The unit-cost framing means **per-period and one-shot grants need no
86+
special handling** — for a one-shot grant, `totalRemaining` is simply the
87+
remaining grant.

src/modules/billing/components/billing.navComputeGauge.component.vue

Lines changed: 117 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,15 @@
55
nav items (icon in #prepend, title), only the icon is a colored circle
66
reflecting consumption level.
77
8-
Hover tooltip exposes the precise figures: "X / Y compute · resets <day>".
8+
Hover tooltip exposes the precise figures: "X / Y compute · resets <day>",
9+
plus optional capacity chips ("N easy / M heavy ops") when the server config
10+
defines billing.equivalences (rendered via BillingEquivalencesChipsComponent).
911
Admin users see a permanent ∞ rainbow state (no quota applies).
1012
1113
Click → navigates to /users/billing.
1214
Auto-fetches on mount + on window.focus.
15+
The ring fades/scales in on mount and pulses when usage nears exhaustion
16+
(>= 80%); both motions respect prefers-reduced-motion.
1317
1418
Gate: hidden when not logged in or meterMode false.
1519
-->
@@ -27,15 +31,16 @@
2731
<template #prepend>
2832
<!-- Admin: rainbow circular indicator (full ring) -->
2933
<div v-if="isAdmin" class="nav-gauge-admin-ring me-8" aria-hidden="true" />
30-
<!-- Standard: color-coded progress ring -->
31-
<v-progress-circular
32-
v-else
33-
:model-value="pctUsed"
34-
:color="iconColor"
35-
size="24"
36-
width="6"
37-
class="me-8"
38-
/>
34+
<!-- Standard: color-coded progress ring (mount fade/scale-in; pulses near exhaustion) -->
35+
<div v-else class="nav-gauge-ring-enter me-8">
36+
<v-progress-circular
37+
:model-value="pctUsed"
38+
:color="iconColor"
39+
:class="{ 'nav-gauge-ring-alert': isLow }"
40+
size="24"
41+
width="6"
42+
/>
43+
</div>
3944
</template>
4045
<!-- Admin: ∞ rainbow label -->
4146
<v-list-item-title v-if="isAdmin">
@@ -48,17 +53,25 @@
4853
<template v-else>
4954
<div>{{ usedDisplay }} / {{ totalDisplay }} compute</div>
5055
<div v-if="resetLabel">{{ resetLabel }}</div>
56+
<BillingEquivalencesChipsComponent
57+
v-if="equivalenceChips.length"
58+
:equivalences="equivalenceChips"
59+
class="mt-2"
60+
/>
5161
</template>
5262
</v-tooltip>
5363
</template>
5464

5565
<script>
5666
import { useBillingStore } from '../stores/billing.store.js';
5767
import { useAuthStore } from '../../auth/stores/auth.store.js';
68+
import BillingEquivalencesChipsComponent from './billing.equivalencesChips.component.vue';
5869
5970
export default {
6071
name: 'BillingNavComputeGaugeComponent',
6172
73+
components: { BillingEquivalencesChipsComponent },
74+
6275
setup() {
6376
const billingStore = useBillingStore();
6477
const authStore = useAuthStore();
@@ -133,6 +146,61 @@ export default {
133146
return 'success';
134147
},
135148
149+
/**
150+
* @desc True when usage has reached the warning/error band (>= 80%).
151+
* Drives the low-remaining pulse on the ring.
152+
* @returns {boolean}
153+
*/
154+
isLow() {
155+
return this.iconColor === 'warning' || this.iconColor === 'error';
156+
},
157+
158+
/**
159+
* @desc Remaining compute the gauge can still spend: total quota (included +
160+
* extras) minus consumed, floored at 0. Consistent with the used/total figures
161+
* shown in the tooltip; drives the equivalence-chip estimates.
162+
* @returns {number}
163+
*/
164+
totalRemaining() {
165+
return Math.max(0, this.totalQuota - this.meterUsed);
166+
},
167+
168+
/**
169+
* @desc Per-operation capacity equivalences from server config (downstream-defined,
170+
* opaque passthrough via serverConfig.billing.equivalences). Returns [] when unset.
171+
* @returns {Array<{kind: string, unitCost: number, label: string}>}
172+
*/
173+
equivalencesConfig() {
174+
const eq = this.authStore.serverConfig?.billing?.equivalences;
175+
return Array.isArray(eq) ? eq : [];
176+
},
177+
178+
/**
179+
* @desc Human-readable remaining-capacity chips derived from the equivalence unit
180+
* costs: count = floor(totalRemaining / unitCost). Only consumption-scaled kinds
181+
* (easy/hard) with a finite positive unitCost render — this guards against
182+
* division-by-zero / Infinity and drops the non-consumption 'feature' kind.
183+
* Works identically for per-period and one-shot grants (no special branch): when
184+
* the quota is a one-shot total, totalRemaining is simply the remaining grant.
185+
* @returns {Array<{kind: string, count: number, label: string}>}
186+
*/
187+
equivalenceChips() {
188+
return this.equivalencesConfig
189+
.filter(
190+
(e) =>
191+
e &&
192+
(e.kind === 'easy' || e.kind === 'hard') &&
193+
typeof e.label === 'string' &&
194+
Number.isFinite(e.unitCost) &&
195+
e.unitCost > 0,
196+
)
197+
.map((e) => ({
198+
kind: e.kind,
199+
count: Math.floor(this.totalRemaining / e.unitCost),
200+
label: e.label,
201+
}));
202+
},
203+
136204
usedDisplay() {
137205
return this.meterUsed.toLocaleString();
138206
},
@@ -225,4 +293,43 @@ export default {
225293
opacity: 0.6;
226294
flex-shrink: 0;
227295
}
296+
297+
/* ── Nav gauge ring motion (#4349) ──────────────────────────────────────────
298+
Mount fade/scale-in to draw the eye, plus a low-remaining pulse near
299+
exhaustion. Both are gated on prefers-reduced-motion: no-preference, so
300+
reduced-motion users always get a static ring. */
301+
.nav-gauge-ring-enter {
302+
display: inline-flex;
303+
}
304+
305+
@media (prefers-reduced-motion: no-preference) {
306+
.nav-gauge-ring-enter {
307+
animation: nav-gauge-in 420ms ease-out both;
308+
}
309+
310+
.nav-gauge-ring-alert {
311+
animation: nav-gauge-pulse 1.8s ease-in-out infinite;
312+
}
313+
}
314+
315+
@keyframes nav-gauge-in {
316+
from {
317+
opacity: 0;
318+
transform: scale(0.6);
319+
}
320+
to {
321+
opacity: 1;
322+
transform: scale(1);
323+
}
324+
}
325+
326+
@keyframes nav-gauge-pulse {
327+
0%,
328+
100% {
329+
opacity: 1;
330+
}
331+
50% {
332+
opacity: 0.55;
333+
}
334+
}
228335
</style>

0 commit comments

Comments
 (0)