Skip to content

Commit

Permalink
feat: add initial portfolio page
Browse files Browse the repository at this point in the history
  • Loading branch information
JoseRFelix committed Dec 3, 2024
1 parent b919106 commit 5925071
Show file tree
Hide file tree
Showing 10 changed files with 978 additions and 74 deletions.
2 changes: 2 additions & 0 deletions packages/mobile/.env.sample
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Debug
EXPO_PUBLIC_OSMOSIS_ADDRESS=
1 change: 1 addition & 0 deletions packages/mobile/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ yarn-error.*

# local env files
.env*.local
.env

# typescript
*.tsbuildinfo
Expand Down
146 changes: 144 additions & 2 deletions packages/mobile/app/(tabs)/index.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<SafeAreaView>
<Text>Portfolio!</Text>
<View
style={{
paddingHorizontal: 24,
paddingVertical: 24,
flexDirection: "row",
alignItems: "center",
gap: 16,
}}
>
<View
style={{
alignSelf: "flex-start",
backgroundColor: Colors["osmoverse"][700],
borderRadius: 8,
overflow: "hidden",
}}
>
<ProfileWoz style={{ flexShrink: 0 }} width={48} height={48} />
</View>

<Text type="title">Portfolio</Text>
</View>

<PortfolioValue
allocation={allocation}
isLoadingAllocation={isLoadingAllocation}
/>
</SafeAreaView>
);
}

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 (
<View
style={{
paddingHorizontal: 24,
flexDirection: "row",
alignItems: "center",
gap: 8,
}}
>
<Skeleton
style={isLoadingAllocation ? { width: 100, height: 48 } : undefined}
isLoaded={!isLoadingAllocation}
>
<Text type="title">{allocation?.totalCap?.toString()}</Text>
</Skeleton>
<Skeleton
style={
!isPortfolioOverTimeDataIsFetched
? { width: 100, height: 48 }
: undefined
}
isLoaded={isPortfolioOverTimeDataIsFetched}
>
<Text
type="subtitle"
style={{
color: getChangeColor(selectedPercentageRatePretty.toDec()),
}}
>
{selectedPercentageRatePretty
.maxDecimals(1)
.inequalitySymbol(false)
.toString()}
</Text>
</Skeleton>
</View>
);
};
706 changes: 706 additions & 0 deletions packages/mobile/components/icons/profile-woz.tsx

Large diffs are not rendered by default.

22 changes: 17 additions & 5 deletions packages/mobile/components/ui/skeleton.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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<SkeletonProps> = ({ style, ...props }) => {
const Skeleton: React.FC<SkeletonProps> = ({ style, isLoaded, ...props }) => {
const opacity = useSharedValue(0.5);

opacity.value = withRepeat(
Expand All @@ -30,15 +33,24 @@ const Skeleton: React.FC<SkeletonProps> = ({ style, ...props }) => {
};
});

if (isLoaded) {
return <View style={style} {...props} />;
}

return (
<Animated.View style={[styles.skeleton, animatedStyle, style]} {...props} />
<Animated.View style={[styles.skeleton, animatedStyle, style]} {...props}>
<View style={[styles.invisible, { opacity: 0 }]}>{props.children}</View>
</Animated.View>
);
};

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,
},
});

Expand Down
11 changes: 11 additions & 0 deletions packages/mobile/hooks/use-cosmos-wallet.ts
Original file line number Diff line number Diff line change
@@ -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,
};
};
3 changes: 2 additions & 1 deletion packages/mobile/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
68 changes: 68 additions & 0 deletions packages/server/src/queries/complex/portfolio/over-time.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -47,3 +50,68 @@ export async function getPortfolioOverTime({
},
});
}

type Nominal<T, Name extends string> = T & {
/** The 'name' or species of the nominal. */
[Symbol.species]: Name;
};

export const calculatePortfolioPerformance = (
data: ChartPortfolioOverTimeResponse[] | undefined,
dataPoint: {
value?: number;
time?:
| Nominal<number, "UTCTimestamp">
| {
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,
};
};
21 changes: 21 additions & 0 deletions packages/utils/src/date.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
);
};
Loading

0 comments on commit 5925071

Please sign in to comment.