Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 115 additions & 0 deletions src/modules/billing/components/billing.computeGauge.component.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
<template>
<div
v-if="show"
class="compute-gauge px-4 py-2"
:class="{ expanded: isExpanded }"
tabindex="0"
role="region"
aria-label="Compute usage"
@mouseenter="isExpanded = true"
@mouseleave="isExpanded = false"
@focus="isExpanded = true"
@blur="isExpanded = false"
>
<div class="d-flex align-center justify-space-between mb-1">
<span id="compute-gauge-label" class="text-label-small text-medium-emphasis">Compute</span>
<span class="text-label-small font-weight-medium">{{ meterUsed }} / {{ meterQuota }}</span>
Comment on lines +6 to +16
</div>
<v-progress-linear
:model-value="progressPercent"
:color="barColor"
height="4"
rounded
aria-labelledby="compute-gauge-label"
/>
<div v-show="isExpanded" class="text-caption text-medium-emphasis mt-2">
{{ remaining }} remaining · resets {{ resetDate }}
</div>
</div>
</template>

<script setup>
/**
* BillingComputeGaugeComponent
*
* Compact sidenav usage gauge — renders only when meterMode is active.
* Displays meterUsed / meterQuota with a color-coded progress bar.
* Hover reveals: "{remaining} remaining · resets {date}".
*
* Color thresholds:
* < 80% → primary
* ≥ 80% → warning
* ≥ 100% → error
*/
import { ref, computed } from 'vue';
import { useBillingStore } from '../stores/billing.store.js';
import { useAuthStore } from '../../auth/stores/auth.store.js';

/** @desc Expands the detail line — triggered by hover OR keyboard focus. */
const isExpanded = ref(false);
const billingStore = useBillingStore();
const authStore = useAuthStore();

/**
* @desc Render the gauge only when the user is logged in, the app is in
* meter mode, and the billing store has meter data to display.
* @returns {boolean}
*/
const show = computed(() => {
if (!authStore.isLoggedIn) return false;
if (!authStore.serverConfig?.billing?.meterMode) return false;
return Boolean(billingStore.usageMeter);
});

/** @desc Current meter usage from the billing store. Defaults to 0 before data loads. @returns {number} */
const meterUsed = computed(() => billingStore.usageMeter?.meterUsed ?? 0);
/** @desc Current meter quota (total allowance) from the billing store. @returns {number} */
const meterQuota = computed(() => billingStore.usageMeter?.meterQuota ?? 0);

/** @desc Remaining units, clamped to 0 when meterUsed exceeds meterQuota. @returns {number} */
const remaining = computed(() => Math.max(0, meterQuota.value - meterUsed.value));

/**
* @desc Progress as a 0–100 percentage. Guards against division by zero when
* meterQuota is 0 (e.g. free plan before any grant lands).
* @returns {number}
*/
const progressPercent = computed(() => {
const quota = meterQuota.value;
if (!quota) return 0;
return Math.min(100, Math.round((meterUsed.value / quota) * 100));
});

/**
* @desc Bar color — primary below 80 %, warning at 80–99 %, error at 100 %+.
* @returns {'primary'|'warning'|'error'}
*/
const barColor = computed(() => {
const pct = progressPercent.value;
Comment on lines +73 to +88
if (pct >= 100) return 'error';
if (pct >= 80) return 'warning';
return 'primary';
});

/**
* @desc Human-readable reset date derived from the current meter week.
* Falls back to an em-dash when the billing store has no reset date yet.
* @returns {string}
*/
const resetDate = computed(() => {
const d = billingStore.usageMeter?.weekResetAt;
if (!d) return '—';
return new Date(d).toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
});
Comment on lines +95 to +103
</script>

<style scoped>
.compute-gauge {
border-top: 1px solid rgba(var(--v-theme-on-surface), 0.08);
border-bottom: 1px solid rgba(var(--v-theme-on-surface), 0.08);
transition: background 0.2s;
}
.compute-gauge.expanded {
background: rgba(var(--v-theme-on-surface), 0.04);
}
</style>
161 changes: 161 additions & 0 deletions src/modules/billing/tests/billing.computeGauge.component.unit.tests.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { mount } from '@vue/test-utils';
import { createPinia, setActivePinia } from 'pinia';
import { createVuetify } from 'vuetify';
import { useAuthStore } from '../../auth/stores/auth.store.js';
import { useBillingStore } from '../stores/billing.store.js';
import BillingComputeGaugeComponent from '../components/billing.computeGauge.component.vue';

const vuetify = createVuetify();

/**
* Mount BillingComputeGaugeComponent with Vuetify + Pinia installed.
* @returns {import('@vue/test-utils').VueWrapper}
*/
const mountComponent = () =>
mount(BillingComputeGaugeComponent, {
global: { plugins: [vuetify] },
});

describe('BillingComputeGaugeComponent', () => {
beforeEach(() => {
setActivePinia(createPinia());
});

// ── Visibility gate ──────────────────────────────────────────────────────

it('hides when user is not logged in', () => {
const authStore = useAuthStore();
const billingStore = useBillingStore();
// cookieExpire = 0 → isLoggedIn = false
authStore.cookieExpire = 0;
authStore.serverConfig = { billing: { meterMode: true } };
billingStore.usageMeter = { meterUsed: 5, meterQuota: 10, weekResetAt: null };

const wrapper = mountComponent();
expect(wrapper.find('.compute-gauge').exists()).toBe(false);
});

it('hides when meterMode is false', () => {
const authStore = useAuthStore();
const billingStore = useBillingStore();
authStore.cookieExpire = Date.now() + 86400000;
authStore.serverConfig = { billing: { meterMode: false } };
billingStore.usageMeter = { meterUsed: 5, meterQuota: 10, weekResetAt: null };

const wrapper = mountComponent();
expect(wrapper.find('.compute-gauge').exists()).toBe(false);
});

it('hides when meterMode is true but usageMeter is null', () => {
const authStore = useAuthStore();
const billingStore = useBillingStore();
authStore.cookieExpire = Date.now() + 86400000;
authStore.serverConfig = { billing: { meterMode: true } };
billingStore.usageMeter = null;

const wrapper = mountComponent();
expect(wrapper.find('.compute-gauge').exists()).toBe(false);
});

it('renders gauge and usage numbers when meterMode is true', () => {
const authStore = useAuthStore();
const billingStore = useBillingStore();
authStore.cookieExpire = Date.now() + 86400000;
authStore.serverConfig = { billing: { meterMode: true } };
billingStore.usageMeter = { meterUsed: 400, meterQuota: 1600, weekResetAt: null };

const wrapper = mountComponent();
expect(wrapper.find('.compute-gauge').exists()).toBe(true);
expect(wrapper.text()).toContain('400');
expect(wrapper.text()).toContain('1600');
});

// ── Color thresholds ─────────────────────────────────────────────────────

it('uses primary color when usage is below 80%', () => {
const authStore = useAuthStore();
const billingStore = useBillingStore();
authStore.cookieExpire = Date.now() + 86400000;
authStore.serverConfig = { billing: { meterMode: true } };
billingStore.usageMeter = { meterUsed: 50, meterQuota: 100, weekResetAt: null };

const wrapper = mountComponent();
expect(wrapper.vm.barColor).toBe('primary');
});

it('uses warning color when usage is at 80%', () => {
const authStore = useAuthStore();
const billingStore = useBillingStore();
authStore.cookieExpire = Date.now() + 86400000;
authStore.serverConfig = { billing: { meterMode: true } };
billingStore.usageMeter = { meterUsed: 80, meterQuota: 100, weekResetAt: null };

const wrapper = mountComponent();
expect(wrapper.vm.barColor).toBe('warning');
});

it('uses error color when usage is at or above 100%', () => {
const authStore = useAuthStore();
const billingStore = useBillingStore();
authStore.cookieExpire = Date.now() + 86400000;
authStore.serverConfig = { billing: { meterMode: true } };
billingStore.usageMeter = { meterUsed: 100, meterQuota: 100, weekResetAt: null };

const wrapper = mountComponent();
expect(wrapper.vm.barColor).toBe('error');
});

// ── Edge cases ───────────────────────────────────────────────────────────

it('guards against division by zero when meterQuota is 0', () => {
const authStore = useAuthStore();
const billingStore = useBillingStore();
authStore.cookieExpire = Date.now() + 86400000;
authStore.serverConfig = { billing: { meterMode: true } };
billingStore.usageMeter = { meterUsed: 0, meterQuota: 0, weekResetAt: null };

const wrapper = mountComponent();
// show requires usageMeter to be truthy (non-null object) — it is
expect(wrapper.find('.compute-gauge').exists()).toBe(true);
expect(wrapper.vm.progressPercent).toBe(0);
expect(wrapper.vm.barColor).toBe('primary');
});

it('shows em-dash as reset date when weekResetAt is null', () => {
const authStore = useAuthStore();
const billingStore = useBillingStore();
authStore.cookieExpire = Date.now() + 86400000;
authStore.serverConfig = { billing: { meterMode: true } };
billingStore.usageMeter = { meterUsed: 10, meterQuota: 100, weekResetAt: null };

const wrapper = mountComponent();
expect(wrapper.vm.resetDate).toBe('—');
});

it('formats reset date from weekResetAt ISO string', () => {
const authStore = useAuthStore();
const billingStore = useBillingStore();
authStore.cookieExpire = Date.now() + 86400000;
authStore.serverConfig = { billing: { meterMode: true } };
billingStore.usageMeter = { meterUsed: 10, meterQuota: 100, weekResetAt: '2026-05-18T00:00:00.000Z' };

const wrapper = mountComponent();
// Just assert it's a non-empty non-dash string — locale-dependent formatting
expect(wrapper.vm.resetDate).not.toBe('—');
expect(wrapper.vm.resetDate.length).toBeGreaterThan(0);
});

it('clamps remaining to 0 when meterUsed exceeds meterQuota', () => {
const authStore = useAuthStore();
const billingStore = useBillingStore();
authStore.cookieExpire = Date.now() + 86400000;
authStore.serverConfig = { billing: { meterMode: true } };
billingStore.usageMeter = { meterUsed: 150, meterQuota: 100, weekResetAt: null };

const wrapper = mountComponent();
expect(wrapper.vm.remaining).toBe(0);
expect(wrapper.vm.progressPercent).toBe(100);
expect(wrapper.vm.barColor).toBe('error');
});
});
10 changes: 10 additions & 0 deletions src/modules/core/components/core.navigation.component.vue
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,8 @@
</v-list-item>
</v-list>
</template>
<!-- Compute usage gauge — visible only in meter-mode apps -->
<billingComputeGauge />
<v-divider :color="navColor" :thickness="isGlass ? 1 : 3" :style="isGlass ? { opacity: 0.15 } : {}"></v-divider>
<!-- Sign out -->
<v-list :style="listStyle" nav>
Expand Down Expand Up @@ -126,11 +128,19 @@ import { useTheme, useDisplay } from 'vuetify';
import { useAuthStore } from '../../auth/stores/auth.store';
import { useCoreStore } from '../stores/core.store';
import { liquidGlassStyle } from '../../../lib/helpers/theme';
// billing module is a devkit core dependency (not optional) — all downstream
// projects include it. This follows the same pattern as user.view.vue importing
// BillingSubscriptionsComponent. A consolidated cross-module refactor is tracked
// as tech debt; this PR does not introduce a new pattern.
import billingComputeGauge from '../../billing/components/billing.computeGauge.component.vue';
/**
* Component definition.
*/
export default {
name: 'DevkitNavigation',
components: {
billingComputeGauge,
},
data() {
const theme = useTheme();
return {
Expand Down
Loading