Skip to content

Commit b454cef

Browse files
authored
Fix undefined in capacity bar (#1955)
* failing test catches the bug * fix decimal split math * delete unused CapacityMetric component * use Intl.NumberFormat to incredible effect * might as well use Intl.NumberFormat for round() too * make sure we're handling numbers between -1 and 1
1 parent 1ce32d3 commit b454cef

File tree

6 files changed

+112
-109
lines changed

6 files changed

+112
-109
lines changed

app/components/CapacityBar.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,8 @@ export const CapacityBar = ({
4343
<span className="ml-1 !normal-case text-mono-sm text-quaternary">({unit})</span>
4444
</div>
4545
<div className="flex -translate-y-0.5 items-baseline">
46-
<div className="font-light text-sans-2xl">{wholeNumber.toLocaleString()}</div>
47-
<div className="text-sans-xl text-quaternary">{decimal || ''}%</div>
46+
<div className="font-light text-sans-2xl">{wholeNumber}</div>
47+
<div className="text-sans-xl text-quaternary">{decimal}%</div>
4848
</div>
4949
</div>
5050
<div className="p-3 pt-1">

app/components/CapacityMetric.tsx

Lines changed: 0 additions & 89 deletions
This file was deleted.

libs/util/array.spec.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
import { type ReactElement } from 'react'
99
import { expect, test } from 'vitest'
1010

11-
import { groupBy, intersperse, lowestBy, sortBy, sumBy } from './array'
11+
import { groupBy, intersperse, lowestBy, sortBy, splitOnceBy, sumBy } from './array'
1212

1313
test('sortBy', () => {
1414
expect(sortBy(['d', 'b', 'c', 'a'])).toEqual(['a', 'b', 'c', 'd'])
@@ -97,3 +97,10 @@ test('intersperse', () => {
9797
expect(result.map(getText)).toEqual(['a', ',', 'b', ',', 'or', 'c'])
9898
expect(result.map(getKey)).toEqual(['a', 'sep-1', 'b', 'sep-2', 'conj', 'c'])
9999
})
100+
101+
test('splitOnceBy', () => {
102+
expect(splitOnceBy([], () => false)).toEqual([[], []])
103+
expect(splitOnceBy([1, 2, 3], () => false)).toEqual([[1, 2, 3], []])
104+
expect(splitOnceBy([1, 2, 3], () => true)).toEqual([[], [1, 2, 3]])
105+
expect(splitOnceBy([1, 2, 3], (x) => x % 2 === 0)).toEqual([[1], [2, 3]])
106+
})

libs/util/array.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,3 +102,11 @@ export function intersperse(
102102
return [sep0, item]
103103
})
104104
}
105+
106+
/**
107+
* Split array at first element where `by` is true. That element lands in the second array.
108+
*/
109+
export function splitOnceBy<T>(array: T[], by: (t: T) => boolean) {
110+
const i = array.findIndex(by)
111+
return i === -1 ? [array, []] : [array.slice(0, i), array.slice(i)]
112+
}

libs/util/math.spec.ts

Lines changed: 74 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,14 @@
55
*
66
* Copyright Oxide Computer Company
77
*/
8-
import { expect, it } from 'vitest'
8+
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
99

1010
import { GiB } from '.'
1111
import { round, splitDecimal } from './math'
1212

1313
it('rounds properly', () => {
14+
expect(round(0.456, 2)).toEqual(0.46)
15+
expect(round(-0.456, 2)).toEqual(-0.46)
1416
expect(round(123.456, 0)).toEqual(123)
1517
expect(round(123.456, 1)).toEqual(123.5)
1618
expect(round(123.456, 2)).toEqual(123.46)
@@ -19,18 +21,79 @@ it('rounds properly', () => {
1921
expect(round(123.0001, 3)).toEqual(123) // period is culled if decimals are all zeros
2022
expect(round(1.9, 0)).toEqual(2)
2123
expect(round(1.9, 1)).toEqual(1.9)
24+
expect(round(4.997, 2)).toEqual(5)
2225
expect(round(5 / 2, 2)).toEqual(2.5) // math expressions are resolved
2326
expect(round(1879048192 / GiB, 2)).toEqual(1.75) // constants can be evaluated
2427
})
2528

26-
it.each([
27-
[1.23, ['1', '.23']],
28-
[1, ['1', '']], // whole number decimal should be an empty string
29-
[1.252525, ['1', '.25']],
30-
[1.259, ['1', '.26']], // should correctly round the decimal
31-
[-50.2, ['-50', '.2']], // should correctly not round down to -51
32-
[1000.5, ['1,000', '.5']], // testing localeString
33-
[49.00000001, ['49', '']],
34-
])('splitDecimal', (input, output) => {
35-
expect(splitDecimal(input)).toEqual(output)
29+
describe('splitDecimal', () => {
30+
describe('with default locale', () => {
31+
it.each([
32+
[0.23, ['0', '.23']],
33+
[0.236, ['0', '.24']],
34+
[-0.236, ['-0', '.24']],
35+
[1.23, ['1', '.23']],
36+
[1, ['1', '']], // whole number decimal should be an empty string
37+
38+
// values just below whole numbers
39+
[5 - Number.EPSILON, ['5', '']],
40+
[4.997, ['5', '']],
41+
[-4.997, ['-5', '']],
42+
[0.997, ['1', '']],
43+
44+
// values just above whole numbers
45+
[49.00000001, ['49', '']],
46+
[5 + Number.EPSILON, ['5', '']],
47+
48+
[1.252525, ['1', '.25']],
49+
[1.259, ['1', '.26']], // should correctly round the decimal
50+
[-50.2, ['-50', '.2']], // should correctly not round down to -51
51+
[1000.5, ['1,000', '.5']], // test localeString grouping
52+
])('splitDecimal %d -> %s', (input, output) => {
53+
expect(splitDecimal(input)).toEqual(output)
54+
})
55+
})
56+
57+
describe('with de-DE locale', () => {
58+
const originalLanguage = global.navigator.language
59+
60+
beforeAll(() => {
61+
Object.defineProperty(global.navigator, 'language', {
62+
value: 'de-DE',
63+
writable: true,
64+
})
65+
})
66+
67+
it.each([
68+
[0.23, ['0', ',23']],
69+
[0.236, ['0', ',24']],
70+
[-0.236, ['-0', ',24']],
71+
[1.23, ['1', ',23']],
72+
[1, ['1', '']], // whole number decimal should be an empty string
73+
74+
// values just below whole numbers
75+
[5 - Number.EPSILON, ['5', '']],
76+
[4.997, ['5', '']],
77+
[-4.997, ['-5', '']],
78+
[0.997, ['1', '']],
79+
80+
// values just above whole numbers
81+
[49.00000001, ['49', '']],
82+
[5 + Number.EPSILON, ['5', '']],
83+
84+
[1.252525, ['1', ',25']],
85+
[1.259, ['1', ',26']], // should correctly round the decimal
86+
[-50.2, ['-50', ',2']], // should correctly not round down to -51
87+
[1000.5, ['1.000', ',5']], // test localeString grouping
88+
])('splitDecimal %d -> %s', (input, output) => {
89+
expect(splitDecimal(input)).toEqual(output)
90+
})
91+
92+
afterAll(() => {
93+
Object.defineProperty(global.navigator, 'language', {
94+
value: originalLanguage,
95+
writable: true,
96+
})
97+
})
98+
})
3699
})

libs/util/math.ts

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,29 @@
66
* Copyright Oxide Computer Company
77
*/
88

9-
export function splitDecimal(value: number) {
10-
const wholeNumber = Math.trunc(value)
11-
const decimal = value % 1 !== 0 ? round(value % 1, 2) : null
9+
import { splitOnceBy } from '.'
10+
11+
/**
12+
* Get the two parts of a number (before decimal and after-and-including
13+
* decimal) as strings. Round to 2 decimal points if necessary.
14+
*
15+
* If there is no decimal, we will only have whole parts (which can include
16+
* minus sign, group separators [comma in en-US], and of course actual number
17+
* groups). Those will get joined and the decimal part will be the empty string.
18+
*/
19+
export function splitDecimal(value: number): [string, string] {
20+
const nf = Intl.NumberFormat(navigator.language, { maximumFractionDigits: 2 })
21+
const parts = nf.formatToParts(value)
22+
23+
const [wholeParts, decimalParts] = splitOnceBy(parts, (p) => p.type === 'decimal')
24+
1225
return [
13-
wholeNumber.toLocaleString(),
14-
decimal ? '.' + decimal.toLocaleString().split('.')[1] : '',
26+
wholeParts.map((p) => p.value).join(''),
27+
decimalParts.map((p) => p.value).join(''),
1528
]
1629
}
1730

1831
export function round(num: number, digits: number) {
19-
return Number(num.toFixed(digits))
32+
const nf = Intl.NumberFormat(navigator.language, { maximumFractionDigits: digits })
33+
return Number(nf.format(num))
2034
}

0 commit comments

Comments
 (0)