diff --git a/src/components/ECharts/types.ts b/src/components/ECharts/types.ts index 1cd1737ed..45bc69e54 100644 --- a/src/components/ECharts/types.ts +++ b/src/components/ECharts/types.ts @@ -82,7 +82,7 @@ export interface ILineAndBarChartProps { } chartOptions?: { [key: string]: { - [key: string]: Value | Array | ((params: any) => string) + [key: string]: Value | Array | ((params: any) => string | number) } } height?: string diff --git a/src/containers/TokenPnl/ComparisonPanel.tsx b/src/containers/TokenPnl/ComparisonPanel.tsx new file mode 100644 index 000000000..399067d3f --- /dev/null +++ b/src/containers/TokenPnl/ComparisonPanel.tsx @@ -0,0 +1,40 @@ +import { formattedNum } from '~/utils' +import { formatPercent } from './format' +import type { ComparisonEntry } from './types' + +export const ComparisonPanel = ({ entries, activeId }: { entries: ComparisonEntry[]; activeId: string }) => { + if (!entries.length) return null + return ( +
+
+

Range Comparison

+ BTC · ETH · SOL +
+
+ {entries.map((entry) => { + const isPositive = entry.percentChange >= 0 + return ( +
+
+ {entry.image ? ( + {entry.name} + ) : null} + {entry.symbol} +
+ + {formatPercent(entry.percentChange)} + + {`${isPositive ? '+' : ''}$${formattedNum(entry.absoluteChange)}`} + {`$${formattedNum(entry.startPrice)} → $${formattedNum(entry.endPrice)}`} +
+ ) + })} +
+
+ ) +} diff --git a/src/containers/TokenPnl/DailyPnLGrid.tsx b/src/containers/TokenPnl/DailyPnLGrid.tsx new file mode 100644 index 000000000..14ab97023 --- /dev/null +++ b/src/containers/TokenPnl/DailyPnLGrid.tsx @@ -0,0 +1,46 @@ +import { Tooltip } from '~/components/Tooltip' +import { formatDateLabel, formatPercent } from './format' +import type { TimelinePoint } from './types' + +export const DailyPnLGrid = ({ timeline }: { timeline: TimelinePoint[] }) => { + if (!timeline.length) return null + const days = timeline.slice(1) + if (!days.length) return null + + const displayDays = days.slice(-90) + + return ( +
+
+

Daily Change Grid

+ {displayDays.length} days shown +
+
+ {displayDays.map((day) => { + const isPositive = day.percentChange > 0 + const isZero = day.percentChange === 0 + const alpha = Math.min(0.6, Math.max(0.22, Math.abs(day.percentChange) / 9)) + const backgroundColor = isZero + ? 'rgba(148, 163, 184, 0.22)' + : isPositive + ? `rgba(16, 185, 129, ${alpha})` // emerald-500 with controlled alpha + : `rgba(239, 68, 68, ${alpha})` // red-500 with controlled alpha + return ( + +
+ {`${formatPercent(day.percentChange)} on ${formatDateLabel(day.timestamp)}`} +
+
+ ) + })} +
+
+ ) +} diff --git a/src/containers/TokenPnl/DateInput.tsx b/src/containers/TokenPnl/DateInput.tsx new file mode 100644 index 000000000..9e158abdb --- /dev/null +++ b/src/containers/TokenPnl/DateInput.tsx @@ -0,0 +1,29 @@ +export const DateInput = ({ + label, + value, + onChange, + min, + max, + invalid +}: { + label: string + value: string + onChange: (value: string) => void + min?: string + max?: string + invalid?: boolean +}) => { + return ( + + ) +} diff --git a/src/containers/TokenPnl/StatsCard.tsx b/src/containers/TokenPnl/StatsCard.tsx new file mode 100644 index 000000000..4b44c05e3 --- /dev/null +++ b/src/containers/TokenPnl/StatsCard.tsx @@ -0,0 +1,24 @@ +export const StatsCard = ({ + label, + value, + subtle, + variant = 'default' +}: { + label: string + value: string + subtle?: string + variant?: 'default' | 'highlight' +}) => { + const base = 'flex flex-col rounded-md border p-3 transition-colors duration-200' + const containerClass = + variant === 'highlight' + ? `${base} border-(--cards-border) bg-gradient-to-b from-white/5 to-transparent backdrop-blur-sm` + : `${base} border-(--cards-border) bg-(--cards-bg)` + return ( +
+ {label} + {value} + {subtle ? {subtle} : null} +
+ ) +} diff --git a/src/containers/TokenPnl/format.ts b/src/containers/TokenPnl/format.ts new file mode 100644 index 000000000..9401a3517 --- /dev/null +++ b/src/containers/TokenPnl/format.ts @@ -0,0 +1,14 @@ +export const formatPercent = (value: number) => { + if (!Number.isFinite(value)) return '0%' + const formatted = value.toFixed(2) + const prefix = value > 0 ? '+' : value < 0 ? '' : '' + return `${prefix}${formatted}%` +} + +export const formatDateLabel = (timestamp: number) => { + return new Date(timestamp * 1000).toLocaleDateString(undefined, { + month: 'short', + day: 'numeric', + timeZone: 'UTC' + }) +} diff --git a/src/containers/TokenPnl/index.tsx b/src/containers/TokenPnl/index.tsx index d50efbdb2..0d0cc4457 100644 --- a/src/containers/TokenPnl/index.tsx +++ b/src/containers/TokenPnl/index.tsx @@ -1,8 +1,10 @@ -import { useCallback, useEffect, useMemo, useState } from 'react' +import { lazy, Suspense, useEffect, useMemo, useState } from 'react' import { useRouter } from 'next/router' import * as Ariakit from '@ariakit/react' -import { useQuery } from '@tanstack/react-query' +import { useQueries, useQuery } from '@tanstack/react-query' import { IResponseCGMarketsAPI } from '~/api/types' +import { ILineAndBarChartProps } from '~/components/ECharts/types' +import { formatTooltipChartDate, formatTooltipValue } from '~/components/ECharts/useDefaults' import { Icon } from '~/components/Icon' import { LocalLoader } from '~/components/Loaders' import { COINS_CHART_API } from '~/constants' @@ -10,33 +12,173 @@ import { CoinsPicker } from '~/containers/Correlations' import { useDateRangeValidation } from '~/hooks/useDateRangeValidation' import { formattedNum } from '~/utils' import { fetchJson } from '~/utils/async' +import { ComparisonPanel } from './ComparisonPanel' +import { DailyPnLGrid } from './DailyPnLGrid' +import { DateInput } from './DateInput' +import { formatDateLabel, formatPercent } from './format' +import { StatsCard } from './StatsCard' +import { TokenPriceChart } from './TokenPriceChart' +import type { ComparisonEntry, PricePoint, TimelinePoint } from './types' + +const LineAndBarChart = lazy(() => import('~/components/ECharts/LineAndBarChart')) + +const DAY_IN_SECONDS = 86_400 +const DEFAULT_COMPARISON_IDS = ['bitcoin', 'ethereum', 'solana'] as const + +type ChangeMode = 'percent' | 'absolute' + +type TokenPnlResult = { + coinInfo?: IResponseCGMarketsAPI + priceSeries: PricePoint[] + timeline: TimelinePoint[] + metrics: { + startPrice: number + endPrice: number + percentChange: number + absoluteChange: number + maxDrawdown: number + volatility: number + rangeHigh: number + rangeLow: number + } + currentPrice: number +} -const unixToDateString = (unixTimestamp) => { +const unixToDateString = (unixTimestamp?: number) => { if (!unixTimestamp) return '' const date = new Date(unixTimestamp * 1000) return date.toISOString().split('T')[0] } const dateStringToUnix = (dateString: string) => { + if (!dateString) return 0 return Math.floor(new Date(dateString).getTime() / 1000) } -const DateInput = ({ label, value, onChange, min, max, hasError = false }) => { - return ( - - ) +const buildDailyTimeline = (series: PricePoint[]): TimelinePoint[] => { + return series.map((point, index) => { + if (index === 0) { + return { ...point, change: 0, percentChange: 0 } + } + const prev = series[index - 1] + const delta = point.price - prev.price + const pct = prev.price ? (delta / prev.price) * 100 : 0 + return { ...point, change: delta, percentChange: pct } + }) +} + +const calculateMaxDrawdown = (series: PricePoint[]) => { + if (series.length === 0) return 0 + let peak = series[0].price + let maxDrawdown = 0 + for (const point of series) { + if (point.price > peak) { + peak = point.price + continue + } + if (peak === 0) continue + const drawdown = ((point.price - peak) / peak) * 100 + if (drawdown < maxDrawdown) { + maxDrawdown = drawdown + } + } + return Math.abs(maxDrawdown) +} + +const calculateAnnualizedVolatility = (series: PricePoint[]) => { + if (series.length < 2) return 0 + const returns: number[] = [] + for (let i = 1; i < series.length; i++) { + const prev = series[i - 1].price + const curr = series[i].price + if (!prev) continue + returns.push((curr - prev) / prev) + } + if (returns.length < 2) return 0 + const mean = returns.reduce((acc, value) => acc + value, 0) / returns.length + const variance = returns.reduce((acc, value) => acc + Math.pow(value - mean, 2), 0) / (returns.length - 1 || 1) + const dailyVol = Math.sqrt(variance) + return dailyVol * Math.sqrt(365) * 100 +} + +type RawPriceEntry = { + timestamp?: number + price?: number +} + +const fetchPriceSeries = async (tokenId: string, start: number, end: number) => { + if (!tokenId || !start || !end || end <= start) return [] as PricePoint[] + const key = `coingecko:${tokenId}` + const spanInDays = Math.max(1, Math.ceil((end - start) / DAY_IN_SECONDS)) + const url = `${COINS_CHART_API}/${key}?start=${start}&span=${spanInDays}&searchWidth=600` + const response = await fetchJson(url) + const raw: RawPriceEntry[] = response?.coins?.[key]?.prices ?? [] + return raw + .filter( + (entry): entry is { timestamp: number; price: number } => + typeof entry?.price === 'number' && typeof entry?.timestamp === 'number' + ) + .map((entry) => ({ + timestamp: entry.timestamp, + price: entry.price + })) + .sort((a: PricePoint, b: PricePoint) => a.timestamp - b.timestamp) +} + +const computeTokenPnl = async (params: { + id: string + start: number + end: number + coinInfo?: IResponseCGMarketsAPI +}): Promise => { + const { id, start, end, coinInfo } = params + if (!id || !start || !end || end <= start) return null + + const series = await fetchPriceSeries(id, start, end) + + if (!series.length) { + return { + coinInfo, + priceSeries: [], + timeline: [], + metrics: { + startPrice: 0, + endPrice: 0, + percentChange: 0, + absoluteChange: 0, + maxDrawdown: 0, + volatility: 0, + rangeHigh: 0, + rangeLow: 0 + }, + currentPrice: coinInfo?.current_price ?? 0 + } + } + + const startPrice = series[0].price + const endPrice = series[series.length - 1].price + const percentChange = startPrice ? ((endPrice - startPrice) / startPrice) * 100 : 0 + const absoluteChange = endPrice - startPrice + const timeline = buildDailyTimeline(series) + const rangeHigh = Math.max(...series.map((point) => point.price)) + const rangeLow = Math.min(...series.map((point) => point.price)) + + return { + coinInfo, + priceSeries: series, + timeline, + metrics: { + startPrice, + endPrice, + percentChange, + absoluteChange, + maxDrawdown: calculateMaxDrawdown(series), + volatility: calculateAnnualizedVolatility(series), + rangeHigh, + rangeLow + }, + currentPrice: coinInfo?.current_price ?? endPrice + } } const isValidDate = (dateString: string | string[] | undefined): boolean => { @@ -45,11 +187,11 @@ const isValidDate = (dateString: string | string[] | undefined): boolean => { return !isNaN(date.getTime()) } -export function TokenPnl({ coinsData }) { +export function TokenPnl({ coinsData }: { coinsData: IResponseCGMarketsAPI[] }) { const router = useRouter() - const now = Math.floor(Date.now() / 1000) - 1000 + const now = Math.floor(Date.now() / 1000) - 60 - const [isModalOpen, setModalOpen] = useState(0) + const coinInfoMap = useMemo(() => new Map(coinsData.map((coin) => [coin.id, coin])), [coinsData]) const { startDate, endDate, dateError, handleStartDateChange, handleEndDateChange, validateDateRange } = useDateRangeValidation({ @@ -71,258 +213,388 @@ export function TokenPnl({ coinsData }) { } }, [router.isReady, router.query.start, router.query.end]) - const { selectedCoins, coins } = useMemo(() => { - const queryCoins = router.query?.coin || (['bitcoin'] as Array) - const coins = Array.isArray(queryCoins) ? queryCoins : [queryCoins] + const [quantityInput, setQuantityInput] = useState('') + const [mode, setMode] = useState('percent') + const [focusedPoint, setFocusedPoint] = useState<{ timestamp: number; price: number } | null>(null) + const { selectedCoins, selectedCoinId, selectedCoinInfo } = useMemo(() => { + const queryCoins = router.query?.coin || ['bitcoin'] + const coins = Array.isArray(queryCoins) ? queryCoins : [queryCoins] return { - selectedCoins: - (queryCoins && coins.map((coin) => coinsData.find((c) => c.id === coin))) || ([] as IResponseCGMarketsAPI[]), - coins + selectedCoins: coins, + selectedCoinId: coins[0], + selectedCoinInfo: coins[0] ? coinInfoMap.get(coins[0]) : null } - }, [router.query, coinsData]) - - const id = useMemo(() => { - return coins.length > 0 ? coins[0] : '' - }, [coins]) + }, [router.query, coinInfoMap]) const start = dateStringToUnix(startDate) const end = dateStringToUnix(endDate) - const fetchPnlData = useCallback(async () => { - if (!id) return null - const key = `coingecko:${id}` - const spanInDays = Math.ceil((end - start) / (24 * 60 * 60)) - const chartRes = await fetchJson(`${COINS_CHART_API}/${key}?start=${start}&span=${spanInDays}&searchWidth=600`) - - const selectedCoin = coinsData.find((coin) => coin.id === id) - - if (!chartRes.coins[key] || chartRes.coins[key].prices.length < 2) { - return { - pnl: 'No data', - coinInfo: selectedCoin, - startPrice: null, - endPrice: null - } - } - - const prices = chartRes.coins[key].prices - const startPrice = prices[0].price - const endPrice = prices[prices.length - 1].price - const pnlValue = ((endPrice - startPrice) / startPrice) * 100 - - return { - pnl: `${pnlValue.toFixed(2)}%`, - coinInfo: selectedCoin, - startPrice, - endPrice - } - }, [id, start, end, coinsData]) - const { data: pnlData, isLoading, isError, error, - refetch + refetch, + isFetching } = useQuery({ - queryKey: ['pnlData', id, start, end], - queryFn: fetchPnlData, - staleTime: 10 * 60 * 1000, - enabled: !!id, + queryKey: ['token-pnl', selectedCoinId, start, end], + queryFn: () => computeTokenPnl({ id: selectedCoinId, start, end, coinInfo: selectedCoinInfo }), + enabled: router.isReady && Boolean(selectedCoinId && start && end && end > start) ? true : false, + staleTime: 60 * 60 * 1000, refetchOnWindowFocus: false }) - const updateDateAndFetchPnl = (newDate, isStart) => { - const unixTimestamp = dateStringToUnix(newDate) - const currentStartDate = isStart ? newDate : startDate - const currentEndDate = isStart ? endDate : newDate + const chartData = useMemo(() => { + const priceSeries = pnlData?.priceSeries ?? [] + const startPrice = priceSeries[0]?.price ?? 0 + const endPrice = priceSeries[priceSeries.length - 1]?.price ?? 0 + const series: Array<[number, number, number]> = priceSeries.map((point, index) => [ + +point.timestamp * 1000, + point.price, + index === 0 ? null : startPrice ? ((point.price - startPrice) / startPrice) * 100 : 0 + ]) + const isPositive = endPrice >= startPrice + + const primaryColor = isPositive ? '#10b981' : '#ef4444' - if (validateDateRange(currentStartDate, currentEndDate)) { - if (isStart) { - handleStartDateChange(newDate) - } else { - handleEndDateChange(newDate) + return { + 'Token Price': { + name: 'Token Price', + stack: 'Token Price', + color: primaryColor, + type: 'line' as const, + data: series as any } + } as ILineAndBarChartProps['charts'] + }, [pnlData?.priceSeries]) + + const comparisonQueries = useQueries({ + queries: DEFAULT_COMPARISON_IDS.map((tokenId) => ({ + queryKey: ['token-pnl-comparison-item', tokenId, start, end], + queryFn: () => + fetchPriceSeries(tokenId, start, end).then((series) => { + if (!series.length) return null + const startPrice = series[0].price + const endPrice = series[series.length - 1].price + const percentChange = startPrice ? ((endPrice - startPrice) / startPrice) * 100 : 0 + const absoluteChange = endPrice - startPrice + const coin = coinInfoMap.get(tokenId) + return { + id: tokenId, + name: coin?.name ?? tokenId, + symbol: coin?.symbol ?? tokenId, + image: coin?.image, + percentChange, + absoluteChange, + startPrice, + endPrice + } as ComparisonEntry + }), + enabled: router.isReady && Boolean(start && end && end > start) ? true : false, + staleTime: 60 * 60 * 1000, + refetchOnWindowFocus: false + })) + }) - router.push( - { - pathname: router.pathname, - query: { - ...router.query, - [isStart ? 'start' : 'end']: unixTimestamp - } - }, - undefined, - { shallow: true } - ) + const comparisonData = useMemo( + () => comparisonQueries.map((q) => q.data).filter(Boolean) as ComparisonEntry[], + [comparisonQueries] + ) + + const quantity = useMemo(() => { + const parsed = parseFloat(quantityInput) + return Number.isFinite(parsed) && parsed > 0 ? parsed : 0 + }, [quantityInput]) + + const displayMetrics = useMemo(() => { + if (!pnlData?.metrics) return null + + const { metrics } = pnlData + const quantityValue = quantity ? metrics.absoluteChange * quantity : metrics.absoluteChange + const summaryValue = mode === 'percent' ? metrics.percentChange : quantity ? quantityValue : metrics.absoluteChange + const isProfit = summaryValue >= 0 + const quantityLabel = quantity + ? `${formattedNum(quantity, false)} tokens → ${quantityValue >= 0 ? '+' : ''}$${formattedNum(quantityValue, false)}` + : `$${formattedNum(metrics.absoluteChange, false)} per token` + + const holdingPeriodDays = Math.max(1, Math.round((end - start) / DAY_IN_SECONDS)) + const annualizedReturn = + holdingPeriodDays > 0 ? (Math.pow(1 + metrics.percentChange / 100, 365 / holdingPeriodDays) - 1) * 100 : 0 + + return { + summaryValue, + isProfit, + quantityLabel, + holdingPeriodDays, + annualizedReturn } - } + }, [pnlData, quantity, mode, start, end]) const dialogStore = Ariakit.useDialogStore() - return ( - <> -
-

Token Holder Profit and Loss

-
- updateDateAndFetchPnl(e.target.value, true)} - min={unixToDateString(0)} - max={unixToDateString(now)} - /> + const handleDateChange = (value: string, isStart: boolean) => { + if (!value) return + const nextStart = isStart ? value : startDate + const nextEnd = isStart ? endDate : value + if (!nextStart || !nextEnd) return + if (!validateDateRange(nextStart, nextEnd)) return + if (isStart) { + handleStartDateChange(value) + } else { + handleEndDateChange(value) + } + const unixValue = dateStringToUnix(value) + router.push( + { + pathname: router.pathname, + query: { + ...router.query, + [isStart ? 'start' : 'end']: unixValue + } + }, + undefined, + { shallow: true } + ) + } - updateDateAndFetchPnl(e.target.value, false)} - min={startDate} - max={new Date().toISOString().split('T')[0]} - hasError={!!dateError} - /> + const updateCoin = (coinId: string) => { + const newCoins = [coinId] + router.push( + { + pathname: router.pathname, + query: { ...router.query, coin: newCoins } + }, + undefined, + { shallow: true } + ) + dialogStore.toggle() + } - {dateError &&

{dateError}

} - - - - <> - {coins.length === 1 && ( -
- {isLoading ? ( -
- -
- ) : isError ? ( -
-

Error

-

{error instanceof Error ? error.message : 'An error occurred'}

- -
- ) : ( - pnlData && ( -
-

- {pnlData.pnl === 'No data' ? ( - No data - ) : ( - <> - = 0 ? 'green' : 'red' }}> - {parseFloat(pnlData.pnl) >= 0 ? 'Profit' : 'Loss'} - - = 0 ? 'green' : 'red' }}>{pnlData.pnl} - - )} -

- - {pnlData.coinInfo && ( -
-

- Start Price: - - {pnlData.startPrice ? `$${formattedNum(pnlData.startPrice)}` : 'N/A'} - -

-

- End Price: - - {pnlData.endPrice ? `$${formattedNum(pnlData.endPrice)}` : 'N/A'} - -

-

- Current Price: - - ${formattedNum(pnlData.coinInfo.current_price)} - -

- -

- 24h Change: - = 0 ? 'green' : 'red' }} - > - {pnlData.coinInfo.price_change_percentage_24h.toFixed(2)}% - -

-

- All-Time High: - ${formattedNum(pnlData.coinInfo.ath)} -

-
- )} -
- ) - )} -
- )} - - - { - const newCoins = coins.slice() - newCoins[isModalOpen - 1] = coin.id - router.push( - { - pathname: router.pathname, - query: { - ...router.query, - coin: newCoins - } - }, - undefined, - { shallow: true } - ) - setModalOpen(0) - dialogStore.toggle() - }} + const renderContent = () => { + if (!router.isReady || isLoading || isFetching) { + return ( +
+ +
+ ) + } + if (isError) { + return ( +
+ Failed to load data + + {error instanceof Error ? error.message : 'Something went wrong fetching price data.'} + + +
+ ) + } + if (!pnlData || !pnlData.priceSeries.length || !displayMetrics) { + return ( +
+ No historical data available for this range. + Try a different date range or another token. +
+ ) + } + + const { metrics, timeline, coinInfo, currentPrice } = pnlData + const { summaryValue, isProfit, quantityLabel, holdingPeriodDays, annualizedReturn } = displayMetrics + + return ( +
+
+
+ + {mode === 'percent' ? 'Return' : isProfit ? 'Profit' : 'Loss'} + +
+ {mode === 'percent' + ? formatPercent(summaryValue) + : `${summaryValue >= 0 ? '+' : ''}$${formattedNum(summaryValue, false)}`} +
+ {quantityLabel} +
+
+ Holding Period +
+ {holdingPeriodDays} days +
+ + {formatDateLabel(start)} → {formatDateLabel(end)} + +
+
+ + Annualised Return + +
= 0 ? 'text-emerald-500' : 'text-red-500'}`}> + {formatPercent(annualizedReturn)} +
+ + {annualizedReturn >= 0 ? '↑' : '↓'} Based on {holdingPeriodDays}d period + +
+
+ +
+
+

Price Over Time

+
+ {formatDateLabel(start)} + + {formatDateLabel(end)} +
+
+
}> + + +
+ +
+ + + + = 0 ? '+' : ''}$${formattedNum(coinInfo.price_change_24h)}` + : undefined + } + /> + + +
+ + + +
- + ) + } + + return ( +
+

Token Holder Profit and Loss

+
+
+
+ handleDateChange(value, true)} + min={unixToDateString(0)} + max={unixToDateString(now)} + /> + handleDateChange(value, false)} + min={startDate} + max={unixToDateString(now)} + /> +
+
+ Token + + updateCoin(coin.id)} + /> +
+
+ +
+
+
{renderContent()}
+
+
) } + +const chartOptions = { + yAxis: { + min: function (value) { + const range = value.max - value.min + return value.min - range * 0.2 + }, + max: function (value) { + const range = value.max - value.min + return value.max + range * 0.2 + }, + scale: false + }, + tooltip: { + formatter: function (params) { + let chartdate = formatTooltipChartDate(params[0].value[0], 'daily') + let vals = '' + vals += `
  • $${formattedNum(params[0].value[1])}
  • ` + + if (params[0].value[2] !== null) { + vals += `
  • ${formatTooltipValue(params[0].value[2], '%')} from start
  • ` + } + return chartdate + vals + } + } +} diff --git a/src/containers/TokenPnl/types.ts b/src/containers/TokenPnl/types.ts new file mode 100644 index 000000000..4c1a63fe8 --- /dev/null +++ b/src/containers/TokenPnl/types.ts @@ -0,0 +1,20 @@ +export type PricePoint = { + timestamp: number + price: number +} + +export type TimelinePoint = PricePoint & { + change: number + percentChange: number +} + +export type ComparisonEntry = { + id: string + name: string + symbol: string + image?: string + percentChange: number + absoluteChange: number + startPrice: number + endPrice: number +}