diff --git a/app/components/CapacityBar.tsx b/app/components/CapacityBar.tsx
index 41f8503e2b..060edd9179 100644
--- a/app/components/CapacityBar.tsx
+++ b/app/components/CapacityBar.tsx
@@ -43,8 +43,8 @@ export const CapacityBar = ({
({unit})
diff --git a/app/components/CapacityMetric.tsx b/app/components/CapacityMetric.tsx
deleted file mode 100644
index f26f055c83..0000000000
--- a/app/components/CapacityMetric.tsx
+++ /dev/null
@@ -1,89 +0,0 @@
-/*
- * This Source Code Form is subject to the terms of the Mozilla Public
- * License, v. 2.0. If a copy of the MPL was not distributed with this
- * file, you can obtain one at https://mozilla.org/MPL/2.0/.
- *
- * Copyright Oxide Computer Company
- */
-import { useMemo } from 'react'
-
-import { useApiQuery, type SystemMetricName } from '@oxide/api'
-import { splitDecimal } from '@oxide/util'
-
-import RoundedSector from 'app/components/RoundedSector'
-
-// exported to use in the loader because it needs to be identical
-export const capacityQueryParams = {
- // beginning of time, aka 1970
- startTime: new Date(0),
- // kind of janky to use pageload time. we can think about making it live
- // later. ideally refetch would be coordinated with the graphs
- endTime: new Date(),
- limit: 1,
- order: 'descending' as const,
-}
-
-export const CapacityMetric = ({
- icon,
- title,
- // unit,
- metricName,
- valueTransform = (x) => x,
- capacity,
-}: {
- icon: JSX.Element
- title: string
- // unit: string
- metricName: SystemMetricName
- valueTransform?: (n: number) => number
- capacity: number
-}) => {
- // this is going to return at most one data point
- const { data } = useApiQuery(
- 'systemMetric',
- { path: { metricName }, query: capacityQueryParams },
- { placeholderData: (x) => x }
- )
-
- const metrics = useMemo(() => data?.items || [], [data])
- const datum = metrics && metrics.length > 0 ? metrics[metrics.length - 1].datum.datum : 0
- // it's always a number but let's rule out the other options without doing a cast
- const utilization = valueTransform(typeof datum === 'number' ? datum : 0)
- const utilizationPct = (utilization * 100) / capacity
- const [wholeNumber, decimal] = splitDecimal(utilizationPct)
-
- return (
-
-
-
- {title}
- {/* ({unit}) */}
-
-
-
-
- {icon}
-
-
-
- {wholeNumber.toLocaleString()}
-
- {decimal || ''}%
-
-
-
-
-
-
-
- )
-}
diff --git a/libs/util/array.spec.tsx b/libs/util/array.spec.tsx
index d948eccf54..cf6226a6e9 100644
--- a/libs/util/array.spec.tsx
+++ b/libs/util/array.spec.tsx
@@ -8,7 +8,7 @@
import { type ReactElement } from 'react'
import { expect, test } from 'vitest'
-import { groupBy, intersperse, lowestBy, sortBy, sumBy } from './array'
+import { groupBy, intersperse, lowestBy, sortBy, splitOnceBy, sumBy } from './array'
test('sortBy', () => {
expect(sortBy(['d', 'b', 'c', 'a'])).toEqual(['a', 'b', 'c', 'd'])
@@ -97,3 +97,10 @@ test('intersperse', () => {
expect(result.map(getText)).toEqual(['a', ',', 'b', ',', 'or', 'c'])
expect(result.map(getKey)).toEqual(['a', 'sep-1', 'b', 'sep-2', 'conj', 'c'])
})
+
+test('splitOnceBy', () => {
+ expect(splitOnceBy([], () => false)).toEqual([[], []])
+ expect(splitOnceBy([1, 2, 3], () => false)).toEqual([[1, 2, 3], []])
+ expect(splitOnceBy([1, 2, 3], () => true)).toEqual([[], [1, 2, 3]])
+ expect(splitOnceBy([1, 2, 3], (x) => x % 2 === 0)).toEqual([[1], [2, 3]])
+})
diff --git a/libs/util/array.ts b/libs/util/array.ts
index ab5ff348b4..bc2de39634 100644
--- a/libs/util/array.ts
+++ b/libs/util/array.ts
@@ -102,3 +102,11 @@ export function intersperse(
return [sep0, item]
})
}
+
+/**
+ * Split array at first element where `by` is true. That element lands in the second array.
+ */
+export function splitOnceBy
(array: T[], by: (t: T) => boolean) {
+ const i = array.findIndex(by)
+ return i === -1 ? [array, []] : [array.slice(0, i), array.slice(i)]
+}
diff --git a/libs/util/math.spec.ts b/libs/util/math.spec.ts
index cca1f9b865..5d5f743d4f 100644
--- a/libs/util/math.spec.ts
+++ b/libs/util/math.spec.ts
@@ -5,12 +5,14 @@
*
* Copyright Oxide Computer Company
*/
-import { expect, it } from 'vitest'
+import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { GiB } from '.'
import { round, splitDecimal } from './math'
it('rounds properly', () => {
+ expect(round(0.456, 2)).toEqual(0.46)
+ expect(round(-0.456, 2)).toEqual(-0.46)
expect(round(123.456, 0)).toEqual(123)
expect(round(123.456, 1)).toEqual(123.5)
expect(round(123.456, 2)).toEqual(123.46)
@@ -19,18 +21,79 @@ it('rounds properly', () => {
expect(round(123.0001, 3)).toEqual(123) // period is culled if decimals are all zeros
expect(round(1.9, 0)).toEqual(2)
expect(round(1.9, 1)).toEqual(1.9)
+ expect(round(4.997, 2)).toEqual(5)
expect(round(5 / 2, 2)).toEqual(2.5) // math expressions are resolved
expect(round(1879048192 / GiB, 2)).toEqual(1.75) // constants can be evaluated
})
-it.each([
- [1.23, ['1', '.23']],
- [1, ['1', '']], // whole number decimal should be an empty string
- [1.252525, ['1', '.25']],
- [1.259, ['1', '.26']], // should correctly round the decimal
- [-50.2, ['-50', '.2']], // should correctly not round down to -51
- [1000.5, ['1,000', '.5']], // testing localeString
- [49.00000001, ['49', '']],
-])('splitDecimal', (input, output) => {
- expect(splitDecimal(input)).toEqual(output)
+describe('splitDecimal', () => {
+ describe('with default locale', () => {
+ it.each([
+ [0.23, ['0', '.23']],
+ [0.236, ['0', '.24']],
+ [-0.236, ['-0', '.24']],
+ [1.23, ['1', '.23']],
+ [1, ['1', '']], // whole number decimal should be an empty string
+
+ // values just below whole numbers
+ [5 - Number.EPSILON, ['5', '']],
+ [4.997, ['5', '']],
+ [-4.997, ['-5', '']],
+ [0.997, ['1', '']],
+
+ // values just above whole numbers
+ [49.00000001, ['49', '']],
+ [5 + Number.EPSILON, ['5', '']],
+
+ [1.252525, ['1', '.25']],
+ [1.259, ['1', '.26']], // should correctly round the decimal
+ [-50.2, ['-50', '.2']], // should correctly not round down to -51
+ [1000.5, ['1,000', '.5']], // test localeString grouping
+ ])('splitDecimal %d -> %s', (input, output) => {
+ expect(splitDecimal(input)).toEqual(output)
+ })
+ })
+
+ describe('with de-DE locale', () => {
+ const originalLanguage = global.navigator.language
+
+ beforeAll(() => {
+ Object.defineProperty(global.navigator, 'language', {
+ value: 'de-DE',
+ writable: true,
+ })
+ })
+
+ it.each([
+ [0.23, ['0', ',23']],
+ [0.236, ['0', ',24']],
+ [-0.236, ['-0', ',24']],
+ [1.23, ['1', ',23']],
+ [1, ['1', '']], // whole number decimal should be an empty string
+
+ // values just below whole numbers
+ [5 - Number.EPSILON, ['5', '']],
+ [4.997, ['5', '']],
+ [-4.997, ['-5', '']],
+ [0.997, ['1', '']],
+
+ // values just above whole numbers
+ [49.00000001, ['49', '']],
+ [5 + Number.EPSILON, ['5', '']],
+
+ [1.252525, ['1', ',25']],
+ [1.259, ['1', ',26']], // should correctly round the decimal
+ [-50.2, ['-50', ',2']], // should correctly not round down to -51
+ [1000.5, ['1.000', ',5']], // test localeString grouping
+ ])('splitDecimal %d -> %s', (input, output) => {
+ expect(splitDecimal(input)).toEqual(output)
+ })
+
+ afterAll(() => {
+ Object.defineProperty(global.navigator, 'language', {
+ value: originalLanguage,
+ writable: true,
+ })
+ })
+ })
})
diff --git a/libs/util/math.ts b/libs/util/math.ts
index 4b36a4b2e9..0af7c57b17 100644
--- a/libs/util/math.ts
+++ b/libs/util/math.ts
@@ -6,15 +6,29 @@
* Copyright Oxide Computer Company
*/
-export function splitDecimal(value: number) {
- const wholeNumber = Math.trunc(value)
- const decimal = value % 1 !== 0 ? round(value % 1, 2) : null
+import { splitOnceBy } from '.'
+
+/**
+ * Get the two parts of a number (before decimal and after-and-including
+ * decimal) as strings. Round to 2 decimal points if necessary.
+ *
+ * If there is no decimal, we will only have whole parts (which can include
+ * minus sign, group separators [comma in en-US], and of course actual number
+ * groups). Those will get joined and the decimal part will be the empty string.
+ */
+export function splitDecimal(value: number): [string, string] {
+ const nf = Intl.NumberFormat(navigator.language, { maximumFractionDigits: 2 })
+ const parts = nf.formatToParts(value)
+
+ const [wholeParts, decimalParts] = splitOnceBy(parts, (p) => p.type === 'decimal')
+
return [
- wholeNumber.toLocaleString(),
- decimal ? '.' + decimal.toLocaleString().split('.')[1] : '',
+ wholeParts.map((p) => p.value).join(''),
+ decimalParts.map((p) => p.value).join(''),
]
}
export function round(num: number, digits: number) {
- return Number(num.toFixed(digits))
+ const nf = Intl.NumberFormat(navigator.language, { maximumFractionDigits: digits })
+ return Number(nf.format(num))
}