From 592507173a55e62898012fcd8737398ff62c0807 Mon Sep 17 00:00:00 2001 From: Jose Felix Date: Tue, 3 Dec 2024 03:29:25 -0400 Subject: [PATCH] feat: add initial portfolio page --- packages/mobile/.env.sample | 2 + packages/mobile/.gitignore | 1 + packages/mobile/app/(tabs)/index.tsx | 146 +++- .../mobile/components/icons/profile-woz.tsx | 706 ++++++++++++++++++ packages/mobile/components/ui/skeleton.tsx | 22 +- packages/mobile/hooks/use-cosmos-wallet.ts | 11 + packages/mobile/package.json | 3 +- .../queries/complex/portfolio/over-time.ts | 68 ++ packages/utils/src/date.ts | 21 + .../complex/portfolio/assets-overview.tsx | 72 +- 10 files changed, 978 insertions(+), 74 deletions(-) create mode 100644 packages/mobile/.env.sample create mode 100644 packages/mobile/components/icons/profile-woz.tsx create mode 100644 packages/mobile/hooks/use-cosmos-wallet.ts diff --git a/packages/mobile/.env.sample b/packages/mobile/.env.sample new file mode 100644 index 0000000000..1e6ae1f1ac --- /dev/null +++ b/packages/mobile/.env.sample @@ -0,0 +1,2 @@ +# Debug +EXPO_PUBLIC_OSMOSIS_ADDRESS= \ No newline at end of file diff --git a/packages/mobile/.gitignore b/packages/mobile/.gitignore index 22ba8a384d..f7602d4827 100644 --- a/packages/mobile/.gitignore +++ b/packages/mobile/.gitignore @@ -32,6 +32,7 @@ yarn-error.* # local env files .env*.local +.env # typescript *.tsbuildinfo diff --git a/packages/mobile/app/(tabs)/index.tsx b/packages/mobile/app/(tabs)/index.tsx index 63c188e582..992e001022 100644 --- a/packages/mobile/app/(tabs)/index.tsx +++ b/packages/mobile/app/(tabs)/index.tsx @@ -1,10 +1,152 @@ -import { Text } from "react-native"; +import { calculatePortfolioPerformance } from "@osmosis-labs/server"; +import { timeToLocal } from "@osmosis-labs/utils"; +import dayjs from "dayjs"; +import { useState } from "react"; +import { View } from "react-native"; import { SafeAreaView } from "react-native-safe-area-context"; +import { ProfileWoz } from "~/components/icons/profile-woz"; +import { Skeleton } from "~/components/ui/skeleton"; +import { Text } from "~/components/ui/text"; +import { Colors } from "~/constants/theme-colors"; +import { useCosmosWallet } from "~/hooks/use-cosmos-wallet"; +import { getChangeColor } from "~/utils/price"; +import { api, RouterInputs, RouterOutputs } from "~/utils/trpc"; + export default function HomeScreen() { + const { address } = useCosmosWallet(); + + const { + data: allocation, + isLoading: isLoadingAllocation, + isFetched: isFetchedAllocation, + } = api.local.portfolio.getPortfolioAssets.useQuery( + { + address: address ?? "", + }, + { + enabled: Boolean(address), + } + ); + return ( - Portfolio! + + + + + + Portfolio + + + ); } + +const PortfolioValue = ({ + allocation, + isLoadingAllocation, +}: { + allocation: + | RouterOutputs["local"]["portfolio"]["getPortfolioAssets"] + | undefined; + isLoadingAllocation: boolean; +}) => { + const { address } = useCosmosWallet(); + const [range, setRange] = + useState< + RouterInputs["local"]["portfolio"]["getPortfolioOverTime"]["range"] + >("1d"); + const [dataPoint, setDataPoint] = useState<{ + time: number; + value: number | undefined; + }>({ + time: dayjs().unix(), + value: undefined, + }); + + const { + data: portfolioOverTimeData, + isFetched: isPortfolioOverTimeDataIsFetched, + error, + } = api.local.portfolio.getPortfolioOverTime.useQuery( + { + address: address!, + range, + }, + { + enabled: Boolean(address), + onSuccess: (data) => { + if (data && data.length > 0) { + const lastDataPoint = data[data.length - 1]; + setDataPoint({ + time: timeToLocal(lastDataPoint.time), + value: lastDataPoint.value, + }); + } + }, + } + ); + + const { selectedPercentageRatePretty } = calculatePortfolioPerformance( + portfolioOverTimeData, + dataPoint + ); + + return ( + + + {allocation?.totalCap?.toString()} + + + + {selectedPercentageRatePretty + .maxDecimals(1) + .inequalitySymbol(false) + .toString()} + + + + ); +}; diff --git a/packages/mobile/components/icons/profile-woz.tsx b/packages/mobile/components/icons/profile-woz.tsx new file mode 100644 index 0000000000..608a30d6bd --- /dev/null +++ b/packages/mobile/components/icons/profile-woz.tsx @@ -0,0 +1,706 @@ +import * as React from "react"; +import { ViewStyle } from "react-native"; +import Svg, { + ClipPath, + Defs, + G, + LinearGradient, + Path, + Stop, +} from "react-native-svg"; + +export const ProfileWoz = ({ + width = 32, + height = 32, + style, +}: { + width?: number; + height?: number; + style?: ViewStyle; +}) => ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +); diff --git a/packages/mobile/components/ui/skeleton.tsx b/packages/mobile/components/ui/skeleton.tsx index afed08413c..a637d8c17e 100644 --- a/packages/mobile/components/ui/skeleton.tsx +++ b/packages/mobile/components/ui/skeleton.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { StyleSheet, ViewProps } from "react-native"; +import { StyleSheet, View, ViewProps } from "react-native"; import Animated, { Easing, useAnimatedStyle, @@ -8,11 +8,14 @@ import Animated, { withTiming, } from "react-native-reanimated"; +import { Colors } from "~/constants/theme-colors"; + interface SkeletonProps extends ViewProps { className?: string; + isLoaded?: boolean; } -const Skeleton: React.FC = ({ style, ...props }) => { +const Skeleton: React.FC = ({ style, isLoaded, ...props }) => { const opacity = useSharedValue(0.5); opacity.value = withRepeat( @@ -30,15 +33,24 @@ const Skeleton: React.FC = ({ style, ...props }) => { }; }); + if (isLoaded) { + return ; + } + return ( - + + {props.children} + ); }; const styles = StyleSheet.create({ skeleton: { - backgroundColor: "#1F2937", // Equivalent to bg-osmoverse-700 - borderRadius: 4, // Equivalent to rounded-md + backgroundColor: Colors["osmoverse"][825], + borderRadius: 4, + }, + invisible: { + opacity: 0, }, }); diff --git a/packages/mobile/hooks/use-cosmos-wallet.ts b/packages/mobile/hooks/use-cosmos-wallet.ts new file mode 100644 index 0000000000..d935e31a36 --- /dev/null +++ b/packages/mobile/hooks/use-cosmos-wallet.ts @@ -0,0 +1,11 @@ +export const useCosmosWallet = () => { + if (process.env.EXPO_PUBLIC_OSMOSIS_ADDRESS) { + return { + address: process.env.EXPO_PUBLIC_OSMOSIS_ADDRESS, + }; + } + + return { + address: undefined, + }; +}; diff --git a/packages/mobile/package.json b/packages/mobile/package.json index a344535d21..ca4a7ec125 100644 --- a/packages/mobile/package.json +++ b/packages/mobile/package.json @@ -67,7 +67,8 @@ "react-native-web": "~0.19.13", "react-native-webview": "13.12.4", "zeego": "^2.0.4", - "zustand": "^4.5.5" + "zustand": "^4.5.5", + "dayjs": "^1.10.7" }, "devDependencies": { "@babel/core": "^7.25.2", diff --git a/packages/server/src/queries/complex/portfolio/over-time.ts b/packages/server/src/queries/complex/portfolio/over-time.ts index 82948d420e..67d2d9a48b 100644 --- a/packages/server/src/queries/complex/portfolio/over-time.ts +++ b/packages/server/src/queries/complex/portfolio/over-time.ts @@ -1,8 +1,11 @@ +import { PricePretty, RatePretty } from "@osmosis-labs/unit"; +import { Dec } from "@osmosis-labs/unit"; import cachified, { CacheEntry } from "cachified"; import { LRUCache } from "lru-cache"; import { DEFAULT_LRU_OPTIONS } from "../../../utils/cache"; import { queryPortfolioOverTime } from "../../data-services"; +import { DEFAULT_VS_CURRENCY } from ".."; export type Range = "1d" | "7d" | "1mo" | "1y" | "all"; @@ -47,3 +50,68 @@ export async function getPortfolioOverTime({ }, }); } + +type Nominal = T & { + /** The 'name' or species of the nominal. */ + [Symbol.species]: Name; +}; + +export const calculatePortfolioPerformance = ( + data: ChartPortfolioOverTimeResponse[] | undefined, + dataPoint: { + value?: number; + time?: + | Nominal + | { + year: number; + month: number; + day: number; + } + | string + | number; + } +): { + selectedPercentageRatePretty: RatePretty; + selectedDifferencePricePretty: PricePretty; + totalPriceChange: number; +} => { + // Check if all values are 0, for instance if a user created a new wallet and has no transactions + const hasAllZeroValues = data?.every((point) => point.value === 0); + if ( + hasAllZeroValues && + (dataPoint?.value === 0 || dataPoint?.value === undefined) + ) { + return { + selectedPercentageRatePretty: new RatePretty(new Dec(0)), + selectedDifferencePricePretty: new PricePretty( + DEFAULT_VS_CURRENCY, + new Dec(0) + ), + totalPriceChange: 0, + }; + } + + const openingPrice = data?.[0]?.value; + const openingPriceWithFallback = !openingPrice ? 1 : openingPrice; // handle first value being 0 or undefined + const selectedDifference = (dataPoint?.value ?? 0) - openingPriceWithFallback; + const selectedPercentage = selectedDifference / openingPriceWithFallback; + const selectedPercentageRatePretty = new RatePretty( + new Dec(selectedPercentage) + ); + + const selectedDifferencePricePretty = new PricePretty( + DEFAULT_VS_CURRENCY, + new Dec(selectedDifference) + ); + + const closingPrice = data?.[data.length - 1]?.value; + const closingPriceWithFallback = !closingPrice ? 1 : closingPrice; // handle last value being 0 or undefined + + const totalPriceChange = closingPriceWithFallback - openingPriceWithFallback; + + return { + selectedPercentageRatePretty, + selectedDifferencePricePretty, + totalPriceChange, + }; +}; diff --git a/packages/utils/src/date.ts b/packages/utils/src/date.ts index 42c75c1133..acf0223f80 100644 --- a/packages/utils/src/date.ts +++ b/packages/utils/src/date.ts @@ -19,3 +19,24 @@ export function unixNanoSecondsToSeconds( ): number { return Number(unixNanoSeconds) / 1000000000; } + +/** + * Converts a Unix timestamp in seconds to a local time Unix timestamp in seconds. + * This is achieved by creating a Date object from the input value, and then using Date.UTC to get the local time. + * @param originalTime The original Unix timestamp in seconds. + * @returns The local time Unix timestamp in seconds. + */ +export const timeToLocal = (originalTime: number): number => { + const d = new Date(originalTime * 1000); + return ( + Date.UTC( + d.getFullYear(), + d.getMonth(), + d.getDate(), + d.getHours(), + d.getMinutes(), + d.getSeconds(), + d.getMilliseconds() + ) / 1000 + ); +}; diff --git a/packages/web/components/complex/portfolio/assets-overview.tsx b/packages/web/components/complex/portfolio/assets-overview.tsx index 73091b5d43..c9eef9e7a1 100644 --- a/packages/web/components/complex/portfolio/assets-overview.tsx +++ b/packages/web/components/complex/portfolio/assets-overview.tsx @@ -3,9 +3,13 @@ import type { ChartPortfolioOverTimeResponse, Range, } from "@osmosis-labs/server"; -import { DEFAULT_VS_CURRENCY } from "@osmosis-labs/server"; +import { + calculatePortfolioPerformance, + DEFAULT_VS_CURRENCY, +} from "@osmosis-labs/server"; import { PricePretty } from "@osmosis-labs/unit"; -import { Dec, RatePretty } from "@osmosis-labs/unit"; +import { Dec } from "@osmosis-labs/unit"; +import { timeToLocal } from "@osmosis-labs/utils"; import classNames from "classnames"; import dayjs from "dayjs"; import { AreaData, Time } from "lightweight-charts"; @@ -38,70 +42,6 @@ import { api } from "~/utils/trpc"; const CHART_CONTAINER_HEIGHT = 468; -const calculatePortfolioPerformance = ( - data: ChartPortfolioOverTimeResponse[] | undefined, - dataPoint: DataPoint -): { - selectedPercentageRatePretty: RatePretty; - selectedDifferencePricePretty: PricePretty; - totalPriceChange: number; -} => { - // Check if all values are 0, for instance if a user created a new wallet and has no transactions - const hasAllZeroValues = data?.every((point) => point.value === 0); - if ( - hasAllZeroValues && - (dataPoint?.value === 0 || dataPoint?.value === undefined) - ) { - return { - selectedPercentageRatePretty: new RatePretty(new Dec(0)), - selectedDifferencePricePretty: new PricePretty( - DEFAULT_VS_CURRENCY, - new Dec(0) - ), - totalPriceChange: 0, - }; - } - - const openingPrice = data?.[0]?.value; - const openingPriceWithFallback = !openingPrice ? 1 : openingPrice; // handle first value being 0 or undefined - const selectedDifference = (dataPoint?.value ?? 0) - openingPriceWithFallback; - const selectedPercentage = selectedDifference / openingPriceWithFallback; - const selectedPercentageRatePretty = new RatePretty( - new Dec(selectedPercentage) - ); - - const selectedDifferencePricePretty = new PricePretty( - DEFAULT_VS_CURRENCY, - new Dec(selectedDifference) - ); - - const closingPrice = data?.[data.length - 1]?.value; - const closingPriceWithFallback = !closingPrice ? 1 : closingPrice; // handle last value being 0 or undefined - - const totalPriceChange = closingPriceWithFallback - openingPriceWithFallback; - - return { - selectedPercentageRatePretty, - selectedDifferencePricePretty, - totalPriceChange, - }; -}; - -const timeToLocal = (originalTime: number) => { - const d = new Date(originalTime * 1000); - return ( - Date.UTC( - d.getFullYear(), - d.getMonth(), - d.getDate(), - d.getHours(), - d.getMinutes(), - d.getSeconds(), - d.getMilliseconds() - ) / 1000 - ); -}; - const getLocalizedPortfolioOverTimeData = ( portfolioOverTimeData: ChartPortfolioOverTimeResponse[] | undefined ) => {