chore(utils): improve formatNumber to keep precision (#5761)
This commit is contained in:
parent
41fd14dd00
commit
a21feea699
@ -1,4 +1,4 @@
|
|||||||
import { getNumberFormat } from '@vegaprotocol/utils';
|
import { formatNumber } from '@vegaprotocol/utils';
|
||||||
import sortBy from 'lodash/sortBy';
|
import sortBy from 'lodash/sortBy';
|
||||||
import omit from 'lodash/omit';
|
import omit from 'lodash/omit';
|
||||||
import { useReferralProgramQuery } from './__generated__/CurrentReferralProgram';
|
import { useReferralProgramQuery } from './__generated__/CurrentReferralProgram';
|
||||||
@ -107,9 +107,7 @@ export const useReferralProgram = () => {
|
|||||||
discountFactor: Number(t.referralDiscountFactor),
|
discountFactor: Number(t.referralDiscountFactor),
|
||||||
discount: BigNumber(t.referralDiscountFactor).times(100).toFixed(2) + '%',
|
discount: BigNumber(t.referralDiscountFactor).times(100).toFixed(2) + '%',
|
||||||
minimumVolume: Number(t.minimumRunningNotionalTakerVolume),
|
minimumVolume: Number(t.minimumRunningNotionalTakerVolume),
|
||||||
volume: getNumberFormat(0).format(
|
volume: formatNumber(t.minimumRunningNotionalTakerVolume, 0),
|
||||||
Number(t.minimumRunningNotionalTakerVolume)
|
|
||||||
),
|
|
||||||
epochs: Number(t.minimumEpochs),
|
epochs: Number(t.minimumEpochs),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
@ -14,9 +14,9 @@ import {
|
|||||||
import { useVegaWallet } from '@vegaprotocol/wallet';
|
import { useVegaWallet } from '@vegaprotocol/wallet';
|
||||||
import {
|
import {
|
||||||
addDecimalsFormatNumber,
|
addDecimalsFormatNumber,
|
||||||
|
formatNumber,
|
||||||
getDateFormat,
|
getDateFormat,
|
||||||
getDateTimeFormat,
|
getDateTimeFormat,
|
||||||
getNumberFormat,
|
|
||||||
getUserLocale,
|
getUserLocale,
|
||||||
removePaginationWrapper,
|
removePaginationWrapper,
|
||||||
} from '@vegaprotocol/utils';
|
} from '@vegaprotocol/utils';
|
||||||
@ -323,7 +323,7 @@ export const Statistics = ({
|
|||||||
}
|
}
|
||||||
description={<QUSDTooltip />}
|
description={<QUSDTooltip />}
|
||||||
>
|
>
|
||||||
{getNumberFormat(0).format(Number(totalCommissionValue))}
|
{formatNumber(totalCommissionValue, 0)}
|
||||||
</StatTile>
|
</StatTile>
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -563,8 +563,8 @@ export const RefereesTable = ({
|
|||||||
)
|
)
|
||||||
.map((r) => ({
|
.map((r) => ({
|
||||||
...r,
|
...r,
|
||||||
volume: getNumberFormat(0).format(r.volume),
|
volume: formatNumber(r.volume, 0),
|
||||||
commission: getNumberFormat(0).format(r.commission),
|
commission: formatNumber(r.commission, 0),
|
||||||
}))
|
}))
|
||||||
.reverse()}
|
.reverse()}
|
||||||
/>
|
/>
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { Splash } from '@vegaprotocol/ui-toolkit';
|
import { Splash } from '@vegaprotocol/ui-toolkit';
|
||||||
import { getNumberFormat } from '@vegaprotocol/utils';
|
import { formatNumber } from '@vegaprotocol/utils';
|
||||||
import { type useTeams } from '../../lib/hooks/use-teams';
|
import { type useTeams } from '../../lib/hooks/use-teams';
|
||||||
import { useT } from '../../lib/use-t';
|
import { useT } from '../../lib/use-t';
|
||||||
import { Table } from '../table';
|
import { Table } from '../table';
|
||||||
@ -15,8 +15,7 @@ export const CompetitionsLeaderboard = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const t = useT();
|
const t = useT();
|
||||||
|
|
||||||
const num = (n?: number | string) =>
|
const num = (n?: number | string) => (!n ? '-' : formatNumber(n, 0));
|
||||||
!n ? '-' : getNumberFormat(0).format(Number(n));
|
|
||||||
|
|
||||||
if (!data || data.length === 0) {
|
if (!data || data.length === 0) {
|
||||||
return <Splash>{t('Could not find any teams')}</Splash>;
|
return <Splash>{t('Could not find any teams')}</Splash>;
|
||||||
|
@ -94,7 +94,7 @@ export const LiquidityTable = ({
|
|||||||
return `${addDecimalsFormatNumberQuantum(
|
return `${addDecimalsFormatNumberQuantum(
|
||||||
value,
|
value,
|
||||||
assetDecimalPlaces ?? 0,
|
assetDecimalPlaces ?? 0,
|
||||||
quantum ?? 0
|
quantum ?? 1
|
||||||
)}`;
|
)}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -165,7 +165,7 @@ export const LiquidityTable = ({
|
|||||||
return `${addDecimalsFormatNumberQuantum(
|
return `${addDecimalsFormatNumberQuantum(
|
||||||
newValue,
|
newValue,
|
||||||
assetDecimalPlaces ?? 0,
|
assetDecimalPlaces ?? 0,
|
||||||
quantum ?? 0
|
quantum ?? 1
|
||||||
)}`;
|
)}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -227,7 +227,7 @@ export const LiquidityTable = ({
|
|||||||
addDecimalsFormatNumberQuantum(
|
addDecimalsFormatNumberQuantum(
|
||||||
pendingCommitmentAmount,
|
pendingCommitmentAmount,
|
||||||
assetDecimalPlaces ?? 0,
|
assetDecimalPlaces ?? 0,
|
||||||
quantum ?? 0
|
quantum ?? 1
|
||||||
);
|
);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@ -238,7 +238,7 @@ export const LiquidityTable = ({
|
|||||||
addDecimalsFormatNumberQuantum(
|
addDecimalsFormatNumberQuantum(
|
||||||
currentCommitmentAmount,
|
currentCommitmentAmount,
|
||||||
assetDecimalPlaces ?? 0,
|
assetDecimalPlaces ?? 0,
|
||||||
quantum ?? 0
|
quantum ?? 1
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -286,7 +286,7 @@ export const LiquidityTable = ({
|
|||||||
addDecimalsFormatNumberQuantum(
|
addDecimalsFormatNumberQuantum(
|
||||||
pendingCommitmentAmount,
|
pendingCommitmentAmount,
|
||||||
assetDecimalPlaces ?? 0,
|
assetDecimalPlaces ?? 0,
|
||||||
quantum ?? 0
|
quantum ?? 1
|
||||||
);
|
);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@ -297,7 +297,7 @@ export const LiquidityTable = ({
|
|||||||
addDecimalsFormatNumberQuantum(
|
addDecimalsFormatNumberQuantum(
|
||||||
currentCommitmentAmount,
|
currentCommitmentAmount,
|
||||||
assetDecimalPlaces ?? 0,
|
assetDecimalPlaces ?? 0,
|
||||||
quantum ?? 0
|
quantum ?? 1
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { DepthChart } from 'pennant';
|
import { DepthChart } from 'pennant';
|
||||||
import throttle from 'lodash/throttle';
|
import throttle from 'lodash/throttle';
|
||||||
import { AsyncRenderer } from '@vegaprotocol/ui-toolkit';
|
import { AsyncRenderer } from '@vegaprotocol/ui-toolkit';
|
||||||
import { addDecimal, getNumberFormat } from '@vegaprotocol/utils';
|
import { addDecimal, formatNumber } from '@vegaprotocol/utils';
|
||||||
import { useThemeSwitcher } from '@vegaprotocol/react-helpers';
|
import { useThemeSwitcher } from '@vegaprotocol/react-helpers';
|
||||||
import { useDataProvider } from '@vegaprotocol/data-provider';
|
import { useDataProvider } from '@vegaprotocol/data-provider';
|
||||||
import { marketDepthProvider } from './market-depth-provider';
|
import { marketDepthProvider } from './market-depth-provider';
|
||||||
@ -216,13 +216,12 @@ export const DepthChartContainer = ({ marketId }: DepthChartManagerProps) => {
|
|||||||
|
|
||||||
const volumeFormat = useCallback(
|
const volumeFormat = useCallback(
|
||||||
(volume: number) =>
|
(volume: number) =>
|
||||||
getNumberFormat(market?.positionDecimalPlaces || 0).format(volume),
|
formatNumber(volume, market?.positionDecimalPlaces || 0),
|
||||||
[market?.positionDecimalPlaces]
|
[market?.positionDecimalPlaces]
|
||||||
);
|
);
|
||||||
|
|
||||||
const priceFormat = useCallback(
|
const priceFormat = useCallback(
|
||||||
(price: number) =>
|
(price: number) => formatNumber(price, market?.decimalPlaces || 0),
|
||||||
getNumberFormat(market?.decimalPlaces || 0).format(price),
|
|
||||||
[market?.decimalPlaces]
|
[market?.decimalPlaces]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -23,6 +23,7 @@ describe('number utils', () => {
|
|||||||
{ v: new BigNumber(123000), d: 1, o: '12,300.0' },
|
{ v: new BigNumber(123000), d: 1, o: '12,300.0' },
|
||||||
{ v: new BigNumber(123001), d: 2, o: '1,230.01' },
|
{ v: new BigNumber(123001), d: 2, o: '1,230.01' },
|
||||||
{ v: new BigNumber(123001000), d: 2, o: '1,230,010.00' },
|
{ v: new BigNumber(123001000), d: 2, o: '1,230,010.00' },
|
||||||
|
{ v: '100000000000000000001', d: 18, o: '100.000000000000000001' },
|
||||||
])(
|
])(
|
||||||
'formats with addDecimalsFormatNumber given number correctly',
|
'formats with addDecimalsFormatNumber given number correctly',
|
||||||
({ v, d, o }) => {
|
({ v, d, o }) => {
|
||||||
@ -31,27 +32,10 @@ describe('number utils', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
{ v: new BigNumber(123000), d: 5, o: '1.23', q: 0.1 },
|
{ v: '1234000000000000000', d: 18, q: '1000000000000000000', o: '1.23' }, //vega
|
||||||
{ v: new BigNumber(123000), d: 3, o: '123.00', q: 0.1 },
|
{ v: '1235000000000000000', d: 18, q: '1000000000000000000', o: '1.24' }, //vega
|
||||||
{ v: new BigNumber(123000), d: 1, o: '12,300.00', q: 0.1 },
|
{ v: '1230012', d: 6, q: '1000000', o: '1.23' }, // USDT
|
||||||
{ v: new BigNumber(123001000), d: 2, o: '1,230,010.00', q: 0.1 },
|
{ v: '1234560000000000000', d: 18, q: '500000000000000', o: '1.2346' }, // WEth
|
||||||
{ v: new BigNumber(123001), d: 2, o: '1,230.01', q: 100 },
|
|
||||||
{ v: new BigNumber(123001), d: 2, o: '1,230.01', q: 0.1 },
|
|
||||||
{ v: new BigNumber(123001), d: 2, o: '1,230.01', q: 1 },
|
|
||||||
{
|
|
||||||
v: BigNumber('123456789123456789'),
|
|
||||||
d: 10,
|
|
||||||
o: '12,345,678.91234568',
|
|
||||||
q: '0.00003846',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
v: BigNumber('123456789123456789'),
|
|
||||||
d: 10,
|
|
||||||
o: '12,345,678.91234568',
|
|
||||||
q: '1',
|
|
||||||
},
|
|
||||||
// USDT / USDC
|
|
||||||
{ v: new BigNumber(12345678), d: 6, o: '12.35', q: 1000000 },
|
|
||||||
])(
|
])(
|
||||||
'formats with addDecimalsFormatNumberQuantum given number correctly',
|
'formats with addDecimalsFormatNumberQuantum given number correctly',
|
||||||
({ v, d, o, q }) => {
|
({ v, d, o, q }) => {
|
||||||
|
@ -1,5 +1,4 @@
|
|||||||
import { BigNumber } from 'bignumber.js';
|
import { BigNumber } from 'bignumber.js';
|
||||||
import isNil from 'lodash/isNil';
|
|
||||||
import memoize from 'lodash/memoize';
|
import memoize from 'lodash/memoize';
|
||||||
|
|
||||||
import { getUserLocale } from '../get-user-locale';
|
import { getUserLocale } from '../get-user-locale';
|
||||||
@ -53,36 +52,36 @@ export function removeDecimal(
|
|||||||
return new BigNumber(value || 0).times(times).toFixed(0);
|
return new BigNumber(value || 0).times(times).toFixed(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat/NumberFormat
|
|
||||||
export const getNumberFormat = memoize((digits: number) => {
|
|
||||||
if (isNil(digits) || digits < 0) {
|
|
||||||
return new Intl.NumberFormat(getUserLocale());
|
|
||||||
}
|
|
||||||
return new Intl.NumberFormat(getUserLocale(), {
|
|
||||||
minimumFractionDigits: Math.min(Math.max(0, digits), MIN_FRACTION_DIGITS),
|
|
||||||
maximumFractionDigits: Math.min(Math.max(0, digits), MAX_FRACTION_DIGITS),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat/NumberFormat
|
|
||||||
export const getFixedNumberFormat = memoize((digits: number) => {
|
|
||||||
if (isNil(digits) || digits < 0) {
|
|
||||||
return new Intl.NumberFormat(getUserLocale());
|
|
||||||
}
|
|
||||||
return new Intl.NumberFormat(getUserLocale(), {
|
|
||||||
minimumFractionDigits: Math.min(Math.max(0, digits), MAX_FRACTION_DIGITS),
|
|
||||||
maximumFractionDigits: Math.min(Math.max(0, digits), MAX_FRACTION_DIGITS),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
export const getDecimalSeparator = memoize(
|
export const getDecimalSeparator = memoize(
|
||||||
() =>
|
() =>
|
||||||
getNumberFormat(1)
|
new Intl.NumberFormat(getUserLocale())
|
||||||
.formatToParts(1.1)
|
.formatToParts(1.1)
|
||||||
.find((part) => part.type === 'decimal')?.value
|
.find((part) => part.type === 'decimal')?.value ?? '.'
|
||||||
);
|
);
|
||||||
|
|
||||||
/** formatNumber will format the number with fixed decimals
|
export const getGroupFormat = memoize(() => {
|
||||||
|
const parts = new Intl.NumberFormat(getUserLocale()).formatToParts(
|
||||||
|
100000000000.1
|
||||||
|
);
|
||||||
|
const groupSeparator = parts.find((part) => part.type === 'group')?.value;
|
||||||
|
const groupSize =
|
||||||
|
(groupSeparator &&
|
||||||
|
parts.reverse().find((part) => part.type === 'integer')?.value.length) ||
|
||||||
|
0;
|
||||||
|
return {
|
||||||
|
groupSize,
|
||||||
|
groupSeparator,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const getFormat = memoize(() => ({
|
||||||
|
decimalSeparator: getDecimalSeparator(),
|
||||||
|
...getGroupFormat(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* formatNumber will format the number with maximum number of decimals
|
||||||
|
* trailing zeros are removed but min(MIN_FRACTION_DIGITS, formatDecimals) decimal places will be kept
|
||||||
* @param rawValue - should be a number that is not outside the safe range fail as in https://mikemcl.github.io/bignumber.js/#toN
|
* @param rawValue - should be a number that is not outside the safe range fail as in https://mikemcl.github.io/bignumber.js/#toN
|
||||||
* @param formatDecimals - number of decimals to use
|
* @param formatDecimals - number of decimals to use
|
||||||
*/
|
*/
|
||||||
@ -90,7 +89,23 @@ export const formatNumber = (
|
|||||||
rawValue: string | number | BigNumber,
|
rawValue: string | number | BigNumber,
|
||||||
formatDecimals = 0
|
formatDecimals = 0
|
||||||
) => {
|
) => {
|
||||||
return getNumberFormat(formatDecimals).format(Number(rawValue));
|
const decimalPlaces = Math.min(
|
||||||
|
Math.max(0, formatDecimals),
|
||||||
|
MAX_FRACTION_DIGITS
|
||||||
|
);
|
||||||
|
const format = getFormat();
|
||||||
|
const formatted = new BigNumber(rawValue).toFormat(decimalPlaces, format);
|
||||||
|
// if there are no decimal places just return formatted value
|
||||||
|
if (!decimalPlaces) {
|
||||||
|
return formatted;
|
||||||
|
}
|
||||||
|
// minimum number of decimal places to keep when removing trailing zeros
|
||||||
|
const minimumFractionDigits = Math.min(decimalPlaces, MIN_FRACTION_DIGITS);
|
||||||
|
const parts = formatted.split(format.decimalSeparator);
|
||||||
|
parts[1] = (parts[1] || '')
|
||||||
|
.replace(/0+$/, '')
|
||||||
|
.padEnd(minimumFractionDigits, '0');
|
||||||
|
return parts.join(format.decimalSeparator);
|
||||||
};
|
};
|
||||||
|
|
||||||
/** formatNumberFixed will format the number with fixed decimals
|
/** formatNumberFixed will format the number with fixed decimals
|
||||||
@ -101,7 +116,10 @@ export const formatNumberFixed = (
|
|||||||
rawValue: string | number | BigNumber,
|
rawValue: string | number | BigNumber,
|
||||||
formatDecimals = 0
|
formatDecimals = 0
|
||||||
) => {
|
) => {
|
||||||
return getFixedNumberFormat(formatDecimals).format(Number(rawValue));
|
return new BigNumber(rawValue).toFormat(
|
||||||
|
Math.min(Math.max(0, formatDecimals), MAX_FRACTION_DIGITS),
|
||||||
|
getFormat()
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const quantumDecimalPlaces = (
|
export const quantumDecimalPlaces = (
|
||||||
@ -131,9 +149,14 @@ export const addDecimalsFormatNumberQuantum = (
|
|||||||
if (isNaN(Number(quantum))) {
|
if (isNaN(Number(quantum))) {
|
||||||
return addDecimalsFormatNumber(rawValue, decimalPlaces);
|
return addDecimalsFormatNumber(rawValue, decimalPlaces);
|
||||||
}
|
}
|
||||||
const quantumValue = addDecimal(quantum, decimalPlaces);
|
const numberDP = Math.ceil(
|
||||||
const numberDP = Math.max(0, Math.log10(100 / Number(quantumValue)));
|
Math.abs(Math.log10(toBigNum(quantum, decimalPlaces).toNumber()))
|
||||||
return addDecimalsFormatNumber(rawValue, decimalPlaces, Math.ceil(numberDP));
|
);
|
||||||
|
return addDecimalsFormatNumber(
|
||||||
|
rawValue,
|
||||||
|
decimalPlaces,
|
||||||
|
Math.max(MIN_FRACTION_DIGITS, numberDP)
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const addDecimalsFormatNumber = (
|
export const addDecimalsFormatNumber = (
|
||||||
@ -141,9 +164,10 @@ export const addDecimalsFormatNumber = (
|
|||||||
decimalPlaces: number,
|
decimalPlaces: number,
|
||||||
formatDecimals: number = decimalPlaces
|
formatDecimals: number = decimalPlaces
|
||||||
) => {
|
) => {
|
||||||
const x = addDecimal(rawValue, decimalPlaces);
|
return formatNumber(
|
||||||
|
new BigNumber(rawValue || 0).dividedBy(Math.pow(10, decimalPlaces)),
|
||||||
return formatNumber(x, formatDecimals);
|
formatDecimals
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const addDecimalsFixedFormatNumber = (
|
export const addDecimalsFixedFormatNumber = (
|
||||||
|
@ -10,24 +10,24 @@ describe('formatValue', () => {
|
|||||||
{
|
{
|
||||||
v: '123456789123456789',
|
v: '123456789123456789',
|
||||||
d: 10,
|
d: 10,
|
||||||
o: '12,345,678.91234568',
|
o: '12,345,678.9123456789',
|
||||||
},
|
},
|
||||||
])('formats values correctly', ({ v, d, o }) => {
|
])('formats values correctly', ({ v, d, o }) => {
|
||||||
expect(formatValue(v, d)).toStrictEqual(o);
|
expect(formatValue(v, d)).toStrictEqual(o);
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
{ v: 123000, d: 5, o: '1.23', q: '0.1' },
|
{ v: 123000, d: 5, o: '1.23', q: '1' },
|
||||||
{ v: 123000, d: 3, o: '123.00', q: '0.1' },
|
{ v: 123000, d: 3, o: '123.00', q: '1' },
|
||||||
{ v: 123000, d: 1, o: '12,300.00', q: '0.1' },
|
{ v: 123000, d: 1, o: '12,300.00', q: '1' },
|
||||||
{ v: 123001000, d: 2, o: '1,230,010.00', q: '0.1' },
|
{ v: 123001000, d: 2, o: '1,230,010.00', q: '1' },
|
||||||
{ v: 123001, d: 2, o: '1,230.01', q: '100' },
|
{ v: 123001, d: 2, o: '1,230.01', q: '100' },
|
||||||
{ v: 123001, d: 2, o: '1,230.01', q: '0.1' },
|
{ v: 123001, d: 2, o: '1,230.01', q: '1' },
|
||||||
{
|
{
|
||||||
v: '123456789123456789',
|
v: '123456789123456789',
|
||||||
d: 10,
|
d: 10,
|
||||||
o: '12,345,678.91234568',
|
o: '12,345,678.91235',
|
||||||
q: '0.00003846',
|
q: '384600',
|
||||||
},
|
},
|
||||||
])(
|
])(
|
||||||
'formats with formatValue with quantum given number correctly',
|
'formats with formatValue with quantum given number correctly',
|
||||||
@ -42,15 +42,15 @@ describe('formatRange', () => {
|
|||||||
min: 123000,
|
min: 123000,
|
||||||
max: 12300011111,
|
max: 12300011111,
|
||||||
d: 5,
|
d: 5,
|
||||||
o: '1.23 - 123,000.11111',
|
o: '1.23 - 123,000.11',
|
||||||
q: '0.1',
|
q: '1000',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
min: 123000,
|
min: 123000,
|
||||||
max: 12300011111,
|
max: 12300011111,
|
||||||
d: 3,
|
d: 3,
|
||||||
o: '123.00 - 12,300,011.111',
|
o: '123.00 - 12,300,011.11',
|
||||||
q: '0.1',
|
q: '100',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
min: 123000,
|
min: 123000,
|
||||||
|
Loading…
Reference in New Issue
Block a user