Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion apps/backend/src/portfolio/dto/portfolio-snapshot.dto.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
import { IsNumber, IsOptional, Min } from 'class-validator';
import { IsEnum, IsNumber, IsOptional, Min } from 'class-validator';
import { Type } from 'class-transformer';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';

export enum PortfolioHistoryRange {
'1D' = '1D',
'1W' = '1W',
'1M' = '1M',
'ALL' = 'ALL',
}

export class AssetBalanceDto {
@ApiProperty({ description: 'Asset code', example: 'XLM' })
assetCode: string;
Expand Down Expand Up @@ -98,6 +105,15 @@ export class GetPortfolioHistoryDto {
@IsNumber()
@Min(1)
limit?: number = 10;

@ApiPropertyOptional({
description: 'Time range for history',
enum: PortfolioHistoryRange,
example: PortfolioHistoryRange['1D'],
})
@IsOptional()
@IsEnum(PortfolioHistoryRange)
range?: PortfolioHistoryRange;
}

export class PortfolioHistoryResponseDto {
Expand Down
1 change: 1 addition & 0 deletions apps/backend/src/portfolio/portfolio.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ export class PortfolioController {
userId,
query.page,
query.limit,
query.range,
);
}

Expand Down
22 changes: 19 additions & 3 deletions apps/backend/src/portfolio/portfolio.service.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Between, MoreThanOrEqual, Repository } from 'typeorm';

Check failure on line 3 in apps/backend/src/portfolio/portfolio.service.ts

View workflow job for this annotation

GitHub Actions / backend-checks

'Between' is defined but never used
import { Cron, CronExpression } from '@nestjs/schedule';
import { PortfolioSnapshot } from './entities/portfolio-snapshot.entity';
import { PortfolioAsset } from './portfolio-asset.entity';
Expand All @@ -9,6 +9,7 @@
import { StellarService } from '../stellar/stellar.service';
import { PriceService } from '../price/price.service';
import {
PortfolioHistoryRange,
PortfolioHistoryResponseDto,
PortfolioSnapshotDto,
PortfolioSummaryResponseDto,
Expand Down Expand Up @@ -125,17 +126,32 @@
}

/**
* Get portfolio history for a user with pagination
* Get portfolio history for a user with pagination and optional range
*/
async getPortfolioHistory(
userId: string,
page: number = 1,
limit: number = 10,
range?: PortfolioHistoryRange,
): Promise<PortfolioHistoryResponseDto> {
const skip = (page - 1) * limit;
const now = new Date();
const where: any = { userId };

if (range && range !== PortfolioHistoryRange.ALL) {
const startDate = new Date();
if (range === PortfolioHistoryRange['1D']) {
startDate.setHours(now.getHours() - 24);
} else if (range === PortfolioHistoryRange['1W']) {
startDate.setDate(now.getDate() - 7);
} else if (range === PortfolioHistoryRange['1M']) {
startDate.setMonth(now.getMonth() - 1);
}
where.createdAt = MoreThanOrEqual(startDate);

Check failure on line 150 in apps/backend/src/portfolio/portfolio.service.ts

View workflow job for this annotation

GitHub Actions / backend-checks

Unsafe member access .createdAt on an `any` value
}

const [snapshots, total] = await this.snapshotRepository.findAndCount({
where: { userId },
where,

Check failure on line 154 in apps/backend/src/portfolio/portfolio.service.ts

View workflow job for this annotation

GitHub Actions / backend-checks

Unsafe assignment of an `any` value
order: { createdAt: 'DESC' },
skip,
take: limit,
Expand Down
1 change: 1 addition & 0 deletions apps/mobile/app/_layout.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { Stack } from 'expo-router';
import { AuthProvider } from '../contexts/AuthContext';
import { ThemeProvider } from '../contexts/ThemeContext';
Expand Down
194 changes: 194 additions & 0 deletions apps/mobile/components/PortfolioChart.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
import React, { useEffect, useState } from 'react';
import {
View,
Text,
StyleSheet,
TouchableOpacity,
ActivityIndicator,
Dimensions,
} from 'react-native';
import { LineChart } from 'react-native-wagmi-charts';
import * as Haptics from 'expo-haptics';
import { portfolioApi, PortfolioHistorySnapshot } from '../lib/api';
import { useTheme } from '../contexts/ThemeContext';

const { width: SCREEN_WIDTH } = Dimensions.get('window');

type Range = '1D' | '1W' | '1M' | 'ALL';

interface ChartPoint {
timestamp: number;
value: number;
}

export default function PortfolioChart() {
const { colors } = useTheme();
const [range, setRange] = useState<Range>('1M');
const [data, setData] = useState<ChartPoint[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);

const fetchHistory = async (selectedRange: Range) => {
setIsLoading(true);
setError(null);
try {
const response = await portfolioApi.getHistory(selectedRange);
if (response.success && response.data) {
const snapshots = response.data.snapshots;
if (snapshots.length > 0) {
// Wagmi-charts expects timestamp as number and value as number
// Snapshots are returned DESC, we need ASC for the chart
const formattedData = [...snapshots]
.reverse()
.map((s: PortfolioHistorySnapshot) => ({
timestamp: new Date(s.createdAt).getTime(),
value: parseFloat(s.totalValueUsd),
}));
setData(formattedData);
} else {
setData([]);
}
} else {
setError('Failed to load chart data');
}
} catch (err) {
setError('An error occurred while fetching history');
} finally {
setIsLoading(false);
}
};

useEffect(() => {
fetchHistory(range);
}, [range]);

const handleRangeChange = (newRange: Range) => {
if (newRange !== range) {
setRange(newRange);
void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
}
};

const renderRangeSelector = () => (
<View style={styles.rangeSelector}>
{(['1D', '1W', '1M', 'ALL'] as Range[]).map((r) => (
<TouchableOpacity
key={r}
onPress={() => handleRangeChange(r)}
style={[
styles.rangeButton,
range === r && { backgroundColor: `${colors.accent}22` },
]}
>
<Text
style={[
styles.rangeText,
{ color: range === r ? colors.accent : colors.textSecondary },
range === r && { fontWeight: '700' },
]}
>
{r}
</Text>
</TouchableOpacity>
))}
</View>
);

if (isLoading && data.length === 0) {
return (
<View style={[styles.container, styles.center]}>
<ActivityIndicator color={colors.accent} />
</View>
);
}

if (error && data.length === 0) {
return (
<View style={[styles.container, styles.center]}>
<Text style={{ color: colors.danger }}>{error}</Text>
<TouchableOpacity onPress={() => fetchHistory(range)} style={styles.retryButton}>
<Text style={{ color: colors.accent }}>Retry</Text>
</TouchableOpacity>
</View>
);
}

return (
<View style={styles.container}>
<View style={{ opacity: isLoading ? 0.6 : 1 }}>
{data.length > 0 ? (
<LineChart.Provider data={data}>
<LineChart width={SCREEN_WIDTH - 32} height={200}>
<LineChart.Path color={colors.accent}>
<LineChart.Gradient color={colors.accent} />
</LineChart.Path>
<LineChart.CursorCrosshair color={colors.accent}>
<LineChart.Tooltip />
</LineChart.CursorCrosshair>
</LineChart>
</LineChart.Provider>
) : (
<View style={styles.emptyContainer}>
<Text style={{ color: colors.textSecondary }}>No historical data available</Text>
</View>
)}
</View>

{isLoading && data.length > 0 && (
<View style={styles.loadingOverlay} pointerEvents="none">
<ActivityIndicator color={colors.accent} />
<Text style={{ color: colors.textSecondary, marginTop: 6 }}>Loading chart...</Text>
</View>
)}

{renderRangeSelector()}
</View>
);
}

const styles = StyleSheet.create({
container: {
marginHorizontal: 16,
marginBottom: 24,
minHeight: 260,
position: 'relative',
},
center: {
justifyContent: 'center',
alignItems: 'center',
},
emptyContainer: {
height: 200,
justifyContent: 'center',
alignItems: 'center',
},
rangeSelector: {
flexDirection: 'row',
justifyContent: 'space-between',
marginTop: 16,
backgroundColor: 'rgba(0,0,0,0.03)',
borderRadius: 12,
padding: 4,
},
rangeButton: {
flex: 1,
paddingVertical: 8,
alignItems: 'center',
borderRadius: 8,
},
rangeText: {
fontSize: 12,
fontWeight: '600',
},
retryButton: {
marginTop: 8,
padding: 8,
},
loadingOverlay: {
...StyleSheet.absoluteFillObject,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: 'rgba(0,0,0,0.12)',
borderRadius: 12,
},
});
21 changes: 21 additions & 0 deletions apps/mobile/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,20 @@ export interface PortfolioSummary {
hasLinkedAccount: boolean;
}

export interface PortfolioHistorySnapshot {
id: string;
createdAt: string;
totalValueUsd: string;
}

export interface PortfolioHistoryResponse {
snapshots: PortfolioHistorySnapshot[];
total: number;
page: number;
limit: number;
totalPages: number;
}

export interface SnapshotResponse {
success: boolean;
snapshot: {
Expand Down Expand Up @@ -152,6 +166,13 @@ export const portfolioApi = {
async createSnapshot(): Promise<ApiResponse<SnapshotResponse>> {
return apiClient.post<SnapshotResponse>('/portfolio/snapshot');
},

/**
* Get portfolio history for charting
*/
async getHistory(range: string = '1D'): Promise<ApiResponse<PortfolioHistoryResponse>> {
return apiClient.get<PortfolioHistoryResponse>(`/portfolio/history?range=${range}&limit=100`);
},
};

/**
Expand Down
Loading
Loading