From 040b124868f2096059d77d1b54a461d9cb7c645c Mon Sep 17 00:00:00 2001 From: Pierre Brisorgueil Date: Mon, 11 May 2026 21:22:58 +0200 Subject: [PATCH] feat(billing): sidenav compute gauge above sign-out row (meter mode) Add BillingComputeGaugeComponent in billing/components showing meterUsed/meterQuota with a color-coded progress bar (primary < 80 %, warning >= 80 %, error >= 100 %). Hover reveals remaining units + reset date. Gated on meterMode + isLoggedIn so non-billing apps stay dormant. Mounted in core.navigation.component.vue above the Sign out row, following the same cross-module import pattern as user.view.vue importing BillingSubscriptionsComponent. 10 unit tests added. --- .../billing.computeGauge.component.vue | 115 +++++++++++++ ...lling.computeGauge.component.unit.tests.js | 161 ++++++++++++++++++ .../components/core.navigation.component.vue | 10 ++ 3 files changed, 286 insertions(+) create mode 100644 src/modules/billing/components/billing.computeGauge.component.vue create mode 100644 src/modules/billing/tests/billing.computeGauge.component.unit.tests.js diff --git a/src/modules/billing/components/billing.computeGauge.component.vue b/src/modules/billing/components/billing.computeGauge.component.vue new file mode 100644 index 000000000..ac763bae9 --- /dev/null +++ b/src/modules/billing/components/billing.computeGauge.component.vue @@ -0,0 +1,115 @@ + + + + + diff --git a/src/modules/billing/tests/billing.computeGauge.component.unit.tests.js b/src/modules/billing/tests/billing.computeGauge.component.unit.tests.js new file mode 100644 index 000000000..b66361200 --- /dev/null +++ b/src/modules/billing/tests/billing.computeGauge.component.unit.tests.js @@ -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'); + }); +}); diff --git a/src/modules/core/components/core.navigation.component.vue b/src/modules/core/components/core.navigation.component.vue index 8b22bc92b..f6afece7c 100644 --- a/src/modules/core/components/core.navigation.component.vue +++ b/src/modules/core/components/core.navigation.component.vue @@ -96,6 +96,8 @@ + + @@ -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 {