From 4629591ebdcd2f38102c2337dbe02c730f3e9baf Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Fri, 6 Nov 2026 16:13:15 -0400 Subject: [PATCH] fix(settings): cap the storage usage bar when usage is over the limit Segments are now sized as percentages of the bar and capped at 100%, so usage above the plan limit no longer overflows the card. The bar turns red when over the limit. The Backups tooltip now shows the backups size instead of the Drive size. --- .../components/Usage/UsageBar.test.tsx | 95 +++++++++++++++++++ .../NewSettings/components/Usage/UsageBar.tsx | 79 ++++++++------- src/views/NewSettings/utils/index.ts | 1 + .../NewSettings/utils/usageBarUtils.test.ts | 61 ++++++++++++ src/views/NewSettings/utils/usageBarUtils.ts | 42 ++++++++ 5 files changed, 236 insertions(+), 42 deletions(-) create mode 100644 src/views/NewSettings/components/Usage/UsageBar.test.tsx create mode 100644 src/views/NewSettings/utils/usageBarUtils.test.ts create mode 100644 src/views/NewSettings/utils/usageBarUtils.ts diff --git a/src/views/NewSettings/components/Usage/UsageBar.test.tsx b/src/views/NewSettings/components/Usage/UsageBar.test.tsx new file mode 100644 index 0000000000..2feadb2641 --- /dev/null +++ b/src/views/NewSettings/components/Usage/UsageBar.test.tsx @@ -0,0 +1,95 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, test, vi } from 'vitest'; +import UsageBar from './UsageBar'; + +vi.mock('@internxt/ui', () => ({ + Tooltip: ({ + title, + subtitle, + className, + children, + }: { + title: string; + subtitle: string; + className: string; + children: React.ReactNode; + }) => ( +
+ {`${title}: ${subtitle}`} + {children} +
+ ), +})); + +const GB = 1024 ** 3; + +const renderBar = (props = {}) => + render(); + +const getSegmentElement = (title: string) => screen.getByTestId(`segment-${title}`).parentElement as HTMLElement; + +const getSegmentPercentage = (title: string) => Number.parseFloat(getSegmentElement(title).style.width); + +describe('UsageBar', () => { + test('when usage is over the limit, then both segments are red', () => { + renderBar({ driveUsage: 30 * GB, backupsUsage: 10 * GB }); + + expect(screen.getByTestId('segment-Drive').className).toContain('bg-red'); + expect(screen.getByTestId('segment-Backup').className).toContain('bg-red'); + }); + + test('when usage is over the limit, then the segments together fill exactly 100%', () => { + renderBar({ driveUsage: 30 * GB, backupsUsage: 10 * GB }); + + expect(getSegmentPercentage('Drive') + getSegmentPercentage('Backup')).toBeCloseTo(100); + }); + + test('when usage is under the limit, then the segments keep their regular colors and percentages', () => { + renderBar({ driveUsage: GB / 4, backupsUsage: GB / 4 }); + + expect(screen.getByTestId('segment-Drive').className).toContain('bg-primary'); + expect(screen.getByTestId('segment-Backup').className).toContain('bg-indigo'); + expect(getSegmentPercentage('Drive')).toBe(25); + }); + + test('when drive usage is non-zero, then the drive segment has a 5px minimum width', () => { + renderBar({ driveUsage: 1 }); + + expect(getSegmentElement('Drive').className).toContain('min-w-[5px]'); + }); + + test('when drive usage is zero, then the drive segment has no minimum width', () => { + renderBar(); + + expect(getSegmentElement('Drive').className).not.toContain('min-w-[5px]'); + }); + + test('when drive and backups usage differ, then the backup tooltip shows the backups usage', () => { + renderBar({ driveUsage: 2 * GB, backupsUsage: 3 * GB, spaceLimit: 10 * GB }); + + expect(screen.getByText('Drive: 2GB')).toBeInTheDocument(); + expect(screen.getByText('Backup: 3GB')).toBeInTheDocument(); + }); + + test('when only drive has usage, then the drive segment has no divider margin and is rounded on both sides', () => { + renderBar({ driveUsage: 2 * GB }); + + const { className } = screen.getByTestId('segment-Drive'); + expect(className).not.toContain('mr-0.5'); + expect(className).toContain('rounded-lg'); + }); + + test('when only backups have usage, then the backup segment is rounded on both sides', () => { + renderBar({ backupsUsage: GB / 2 }); + + expect(screen.getByTestId('segment-Backup').className).toContain('rounded-lg'); + }); + + test('when drive and backups have usage, then the divider margin is kept on the drive segment', () => { + renderBar({ driveUsage: GB / 4, backupsUsage: GB / 4 }); + + expect(screen.getByTestId('segment-Drive').className).toContain('mr-0.5'); + expect(screen.getByTestId('segment-Drive').className).toContain('rounded-l-lg'); + expect(screen.getByTestId('segment-Backup').className).toContain('rounded-r-lg'); + }); +}); diff --git a/src/views/NewSettings/components/Usage/UsageBar.tsx b/src/views/NewSettings/components/Usage/UsageBar.tsx index 629908230d..92df8639db 100644 --- a/src/views/NewSettings/components/Usage/UsageBar.tsx +++ b/src/views/NewSettings/components/Usage/UsageBar.tsx @@ -1,6 +1,6 @@ -import { useEffect, useRef, useState } from 'react'; import { bytesToString } from 'app/drive/services/size.service'; import { Tooltip } from '@internxt/ui'; +import { getUsageBarPercentages } from 'views/NewSettings/utils'; const UsageBar = ({ backupsUsage, @@ -13,52 +13,47 @@ const UsageBar = ({ spaceLimit: string | number; height: string | number; }) => { - const [barWidth, setBarWidth] = useState(0); - const barRef = useRef(null); const spaceLimitBytes = Number(spaceLimit); const driveUsageBytes = Number(driveUsage); const backupsUsageBytes = Number(backupsUsage); - const percentageDriveUsed = (driveUsageBytes * 100) / spaceLimitBytes; - const percentageBackupUsed = (backupsUsageBytes * 100) / spaceLimitBytes; - const driveWidth = (barWidth * percentageDriveUsed) / 100; - const backupWidth = (barWidth * percentageBackupUsed) / 100; - - useEffect(() => { - const bar = barRef.current; - - if (bar) { - const listener = () => { - setBarWidth(bar.getBoundingClientRect().width); - }; - bar.addEventListener('resize', listener); - - listener(); - - return () => bar.removeEventListener('resize', listener); - } - }, []); + const { drivePercentage, backupPercentage, isOverLimit } = getUsageBarPercentages({ + driveUsageBytes, + backupsUsageBytes, + spaceLimitBytes, + }); + const driveColor = isOverLimit ? 'bg-red' : 'bg-primary'; + const backupColor = isOverLimit ? 'bg-red' : 'bg-indigo'; + const hasDriveSegment = drivePercentage > 0; + const hasBackupsSegment = backupPercentage > 0; + const driveShapeClass = hasBackupsSegment ? 'mr-0.5 rounded-l-lg' : 'rounded-lg'; + const backupShapeClass = hasDriveSegment ? 'rounded-r-lg' : 'rounded-lg'; + const driveMinWidthClass = drivePercentage > 0 ? 'min-w-[5px]' : ''; return ( -
- -
0 && driveWidth < 5 ? 5 : driveWidth}px` }}>
-
- - -
-
+
+
+ +
+
+
+ +
+ +
+
+
); }; diff --git a/src/views/NewSettings/utils/index.ts b/src/views/NewSettings/utils/index.ts index 74d8e540ca..3ec1b8537c 100644 --- a/src/views/NewSettings/utils/index.ts +++ b/src/views/NewSettings/utils/index.ts @@ -2,3 +2,4 @@ export * from './LogsUtils'; export * from './membersUtils'; export * from './productUtils'; export * from './suscriptionUtils'; +export * from './usageBarUtils'; diff --git a/src/views/NewSettings/utils/usageBarUtils.test.ts b/src/views/NewSettings/utils/usageBarUtils.test.ts new file mode 100644 index 0000000000..6954487ec7 --- /dev/null +++ b/src/views/NewSettings/utils/usageBarUtils.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, test } from 'vitest'; +import { getUsageBarPercentages } from './usageBarUtils'; + +const GB = 1024 ** 3; + +const arrange = (overrides = {}) => + getUsageBarPercentages({ + driveUsageBytes: 0, + backupsUsageBytes: 0, + spaceLimitBytes: 10 * GB, + ...overrides, + }); + +describe('getUsageBarPercentages', () => { + test('when usage is under the limit, then percentages are relative to the limit and not over limit', () => { + const result = arrange({ driveUsageBytes: 2.5 * GB, backupsUsageBytes: 5 * GB }); + + expect(result).toEqual({ drivePercentage: 25, backupPercentage: 50, isOverLimit: false }); + }); + + test('when usage is exactly the limit, then the bar is full and not over limit', () => { + const result = arrange({ driveUsageBytes: 4 * GB, backupsUsageBytes: 6 * GB }); + + expect(result).toEqual({ drivePercentage: 40, backupPercentage: 60, isOverLimit: false }); + }); + + test('when drive usage is far over the limit, then the drive segment takes the whole bar', () => { + const result = arrange({ driveUsageBytes: 33.8 * GB, spaceLimitBytes: GB }); + + expect(result).toEqual({ drivePercentage: 100, backupPercentage: 0, isOverLimit: true }); + }); + + test('when drive plus backups are over the limit, then they are scaled to 100% keeping their ratio', () => { + const result = arrange({ driveUsageBytes: 15 * GB, backupsUsageBytes: 5 * GB }); + + expect(result).toEqual({ drivePercentage: 75, backupPercentage: 25, isOverLimit: true }); + }); + + test('when the limit is zero and there is usage, then the bar is full and over limit', () => { + const result = arrange({ driveUsageBytes: GB, spaceLimitBytes: 0 }); + + expect(result).toEqual({ drivePercentage: 100, backupPercentage: 0, isOverLimit: true }); + }); + + test('when the limit is invalid and there is no usage, then nothing is drawn', () => { + const result = arrange({ spaceLimitBytes: Number.NaN }); + + expect(result).toEqual({ drivePercentage: 0, backupPercentage: 0, isOverLimit: false }); + }); + + test('when values are not finite, then percentages are never NaN or Infinity', () => { + const result = arrange({ driveUsageBytes: Number.NaN, backupsUsageBytes: Infinity, spaceLimitBytes: Infinity }); + + expect(Number.isFinite(result.drivePercentage)).toBe(true); + expect(Number.isFinite(result.backupPercentage)).toBe(true); + }); + + test('when usage is zero, then both segments are empty', () => { + expect(arrange()).toEqual({ drivePercentage: 0, backupPercentage: 0, isOverLimit: false }); + }); +}); diff --git a/src/views/NewSettings/utils/usageBarUtils.ts b/src/views/NewSettings/utils/usageBarUtils.ts new file mode 100644 index 0000000000..a368c0b954 --- /dev/null +++ b/src/views/NewSettings/utils/usageBarUtils.ts @@ -0,0 +1,42 @@ +interface UsageBarPercentagesParams { + driveUsageBytes: number; + backupsUsageBytes: number; + spaceLimitBytes: number; +} + +interface UsageBarPercentages { + drivePercentage: number; + backupPercentage: number; + isOverLimit: boolean; +} + +const toNonNegativeNumber = (value: number): number => (Number.isFinite(value) && value > 0 ? value : 0); + +/** + * Computes the share of the bar (0-100) taken by the Drive and Backups segments. + * Over the limit (or with an invalid limit and some usage) both segments are scaled + * proportionally so together they fill exactly 100%. + */ +export const getUsageBarPercentages = ({ + driveUsageBytes, + backupsUsageBytes, + spaceLimitBytes, +}: UsageBarPercentagesParams): UsageBarPercentages => { + const driveUsage = toNonNegativeNumber(driveUsageBytes); + const backupsUsage = toNonNegativeNumber(backupsUsageBytes); + const spaceLimit = toNonNegativeNumber(spaceLimitBytes); + const totalUsage = driveUsage + backupsUsage; + const isOverLimit = totalUsage > spaceLimit; + + if (totalUsage === 0) { + return { drivePercentage: 0, backupPercentage: 0, isOverLimit }; + } + + const denominator = isOverLimit ? totalUsage : spaceLimit; + + return { + drivePercentage: (driveUsage * 100) / denominator, + backupPercentage: (backupsUsage * 100) / denominator, + isOverLimit, + }; +};