mirror of
https://github.com/Tomato6966/investment-portfolio-simulator.git
synced 2025-04-18 23:51:16 +02:00
add improvements for speed and responsiveness
This commit is contained in:
parent
292fc08b6c
commit
304471c314
14 changed files with 821 additions and 355 deletions
|
@ -1,4 +1,4 @@
|
||||||
import { format } from "date-fns";
|
import { format, differenceInDays } from "date-fns";
|
||||||
import { Maximize2, RefreshCcw } from "lucide-react";
|
import { Maximize2, RefreshCcw } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
CartesianGrid, Legend, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis
|
CartesianGrid, Legend, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis
|
||||||
|
@ -11,10 +11,8 @@ import { ChartLegend } from "./ChartLegend";
|
||||||
interface ChartContentProps {
|
interface ChartContentProps {
|
||||||
dateRange: DateRange;
|
dateRange: DateRange;
|
||||||
handleUpdateDateRange: (range: DateRange) => void;
|
handleUpdateDateRange: (range: DateRange) => void;
|
||||||
handleReRender: () => void;
|
|
||||||
isFullscreen: boolean;
|
isFullscreen: boolean;
|
||||||
setIsFullscreen: (value: boolean) => void;
|
setIsFullscreen: (value: boolean) => void;
|
||||||
renderKey: number;
|
|
||||||
isDarkMode: boolean;
|
isDarkMode: boolean;
|
||||||
hideAssets: boolean;
|
hideAssets: boolean;
|
||||||
hiddenAssets: Set<string>;
|
hiddenAssets: Set<string>;
|
||||||
|
@ -23,16 +21,14 @@ interface ChartContentProps {
|
||||||
assetColors: Record<string, string>;
|
assetColors: Record<string, string>;
|
||||||
toggleAsset: (assetId: string) => void;
|
toggleAsset: (assetId: string) => void;
|
||||||
toggleAllAssets: () => void;
|
toggleAllAssets: () => void;
|
||||||
removeAsset?: (assetId: string) => void;
|
isMobile: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ChartContent = ({
|
export const ChartContent = ({
|
||||||
dateRange,
|
dateRange,
|
||||||
handleUpdateDateRange,
|
handleUpdateDateRange,
|
||||||
handleReRender,
|
|
||||||
isFullscreen,
|
isFullscreen,
|
||||||
setIsFullscreen,
|
setIsFullscreen,
|
||||||
renderKey,
|
|
||||||
isDarkMode,
|
isDarkMode,
|
||||||
hideAssets,
|
hideAssets,
|
||||||
hiddenAssets,
|
hiddenAssets,
|
||||||
|
@ -41,147 +37,230 @@ export const ChartContent = ({
|
||||||
assetColors,
|
assetColors,
|
||||||
toggleAsset,
|
toggleAsset,
|
||||||
toggleAllAssets,
|
toggleAllAssets,
|
||||||
removeAsset
|
isMobile,
|
||||||
}: ChartContentProps) => (
|
}: ChartContentProps) => {
|
||||||
<>
|
// Calculate tick interval dynamically to prevent overlapping
|
||||||
<div className="flex justify-between items-center mb-4 p-5">
|
const getXAxisInterval = () => {
|
||||||
<DateRangePicker
|
const width = window.innerWidth;
|
||||||
startDate={dateRange.startDate}
|
const dayDifference = differenceInDays(dateRange.endDate, dateRange.startDate);
|
||||||
endDate={dateRange.endDate}
|
|
||||||
onStartDateChange={(date) => handleUpdateDateRange({ ...dateRange, startDate: date })}
|
|
||||||
onEndDateChange={(date) => handleUpdateDateRange({ ...dateRange, endDate: date })}
|
|
||||||
/>
|
|
||||||
<div className="flex items-center">
|
|
||||||
<button
|
|
||||||
onClick={handleReRender}
|
|
||||||
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded ml-2 hover:text-blue-500"
|
|
||||||
>
|
|
||||||
<RefreshCcw className="w-5 h-5" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setIsFullscreen(!isFullscreen)}
|
|
||||||
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded hover:text-blue-500"
|
|
||||||
>
|
|
||||||
<Maximize2 className="w-5 h-5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className={isFullscreen ? "h-[80vh]" : "h-[400px]"} key={renderKey}>
|
|
||||||
<ResponsiveContainer>
|
|
||||||
<LineChart data={processedData} className="p-3">
|
|
||||||
<CartesianGrid strokeDasharray="3 3" className="dark:stroke-slate-600" />
|
|
||||||
<XAxis
|
|
||||||
tick={{ fill: isDarkMode ? '#D8D8D8' : '#4E4E4E' }}
|
|
||||||
dataKey="date"
|
|
||||||
tickFormatter={(date) => format(new Date(date), 'dd.MM.yyyy')}
|
|
||||||
/>
|
|
||||||
<YAxis
|
|
||||||
tick={{ fill: isDarkMode ? '#D8D8D8' : '#4E4E4E' }}
|
|
||||||
yAxisId="left"
|
|
||||||
tickFormatter={(value) => `${value.toFixed(2)}€`}
|
|
||||||
/>
|
|
||||||
<YAxis
|
|
||||||
tick={{ fill: isDarkMode ? '#D8D8D8' : '#4E4E4E' }}
|
|
||||||
yAxisId="right"
|
|
||||||
orientation="right"
|
|
||||||
tickFormatter={(value) => `${value.toFixed(2)}%`}
|
|
||||||
/>
|
|
||||||
<Tooltip
|
|
||||||
contentStyle={{
|
|
||||||
backgroundColor: isDarkMode ? '#1e293b' : '#fff',
|
|
||||||
border: 'none',
|
|
||||||
color: isDarkMode ? '#d1d5d1' : '#000000',
|
|
||||||
boxShadow: '0 0 10px 0 rgba(0, 0, 0, 0.5)',
|
|
||||||
}}
|
|
||||||
formatter={(value: number, name: string, item) => {
|
|
||||||
const assetKey = name.split('_')[0] as keyof typeof assets;
|
|
||||||
const processedKey = `${assets.find(a => a.name === name.replace(" (%)", ""))?.id}_price`;
|
|
||||||
|
|
||||||
if (name === "avg. Portfolio % gain")
|
if (width < 480) {
|
||||||
return [`${value.toFixed(2)}%`, name];
|
if (dayDifference > 90) return Math.floor(dayDifference / 4);
|
||||||
|
return "preserveStartEnd";
|
||||||
|
}
|
||||||
|
if (width < 768) {
|
||||||
|
if (dayDifference > 180) return Math.floor(dayDifference / 6);
|
||||||
|
return "preserveStartEnd";
|
||||||
|
}
|
||||||
|
return "equidistantPreserveStart";
|
||||||
|
};
|
||||||
|
|
||||||
if (name === "TTWOR")
|
return (
|
||||||
return [`${value.toLocaleString()}€ (${item.payload["ttwor_percent"].toFixed(2)}%)`, name];
|
<>
|
||||||
|
{!isFullscreen && (
|
||||||
if (name === "Portfolio-Value" || name === "Invested Capital")
|
<div className="flex flex-col sm:flex-row justify-between items-start mb-4 p-3 sm:p-5 gap-2">
|
||||||
return [`${value.toLocaleString()}€`, name];
|
<div className="w-full">
|
||||||
|
<DateRangePicker
|
||||||
if (name.includes("(%)"))
|
startDate={dateRange.startDate}
|
||||||
return [`${Number(item.payload[processedKey]).toFixed(2)}€ ${value.toFixed(2)}%`, name.replace(" (%)", "")];
|
endDate={dateRange.endDate}
|
||||||
|
onDateRangeChange={handleUpdateDateRange}
|
||||||
return [`${value.toLocaleString()}€ (${((value - Number(assets[assetKey])) / Number(assets[assetKey]) * 100).toFixed(2)}%)`, name];
|
|
||||||
}}
|
|
||||||
labelFormatter={(date) => format(new Date(date), 'dd.MM.yyyy')}
|
|
||||||
/>
|
|
||||||
<Legend content={<ChartLegend
|
|
||||||
payload={assets}
|
|
||||||
hideAssets={hideAssets}
|
|
||||||
hiddenAssets={hiddenAssets}
|
|
||||||
toggleAsset={toggleAsset}
|
|
||||||
toggleAllAssets={toggleAllAssets}
|
|
||||||
removeAsset={removeAsset}
|
|
||||||
/>} />
|
|
||||||
<Line
|
|
||||||
type="monotone"
|
|
||||||
dataKey="total"
|
|
||||||
name="Portfolio-Value"
|
|
||||||
hide={hideAssets || hiddenAssets.has("total")}
|
|
||||||
stroke="#000"
|
|
||||||
strokeWidth={2}
|
|
||||||
dot={false}
|
|
||||||
yAxisId="left"
|
|
||||||
/>
|
|
||||||
<Line
|
|
||||||
type="monotone"
|
|
||||||
dataKey="invested"
|
|
||||||
name="Invested Capital"
|
|
||||||
hide={hideAssets || hiddenAssets.has("invested")}
|
|
||||||
stroke="#666"
|
|
||||||
strokeDasharray="5 5"
|
|
||||||
dot={false}
|
|
||||||
yAxisId="left"
|
|
||||||
/>
|
|
||||||
<Line
|
|
||||||
type="monotone"
|
|
||||||
dataKey="ttwor"
|
|
||||||
name="TTWOR"
|
|
||||||
strokeDasharray="5 5"
|
|
||||||
stroke="#a64c79"
|
|
||||||
hide={hideAssets || hiddenAssets.has("ttwor")}
|
|
||||||
dot={false}
|
|
||||||
yAxisId="left"
|
|
||||||
/>
|
|
||||||
{assets.map((asset) => (
|
|
||||||
<Line
|
|
||||||
key={asset.id}
|
|
||||||
type="basis"
|
|
||||||
hide={hideAssets || hiddenAssets.has(asset.id)}
|
|
||||||
dataKey={`${asset.id}_percent`}
|
|
||||||
name={`${asset.name} (%)`}
|
|
||||||
stroke={assetColors[asset.id] || "red"}
|
|
||||||
dot={false}
|
|
||||||
yAxisId="right"
|
|
||||||
connectNulls={true}
|
|
||||||
/>
|
/>
|
||||||
))}
|
</div>
|
||||||
<Line
|
<div className="absolute right-0 top-0">
|
||||||
type="monotone"
|
<button
|
||||||
dataKey="percentageChange"
|
onClick={() => setIsFullscreen(!isFullscreen)}
|
||||||
hide={hideAssets || hiddenAssets.has("percentageChange")}
|
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded hover:text-blue-500"
|
||||||
dot={false}
|
>
|
||||||
name="avg. Portfolio % gain"
|
<Maximize2 className="w-4 h-4 sm:w-5 sm:h-5" />
|
||||||
stroke="#a0a0a0"
|
</button>
|
||||||
yAxisId="right"
|
</div>
|
||||||
/>
|
</div>
|
||||||
</LineChart>
|
)}
|
||||||
</ResponsiveContainer>
|
<div className={isFullscreen ? "h-[calc(100vh-40px)]" : "h-[300px] sm:h-[350px] md:h-[400px]"}>
|
||||||
</div>
|
<ResponsiveContainer>
|
||||||
<i className="text-xs text-gray-500">
|
<LineChart
|
||||||
*Note: The YAxis on the left shows the value of your portfolio (black line) and invested capital (dotted line),
|
data={processedData}
|
||||||
all other assets are scaled by their % gain/loss and thus scaled to the right YAxis.
|
margin={{
|
||||||
</i>
|
top: 5,
|
||||||
<p className="text-xs mt-2 text-gray-500 italic">
|
right: window.innerWidth < 768 ? 15 : 30,
|
||||||
**Note: The % is based on daily weighted average data, thus the percentages might alter slightly.
|
left: window.innerWidth < 768 ? 5 : 20,
|
||||||
</p>
|
bottom: 5
|
||||||
</>
|
}}
|
||||||
);
|
>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" className="dark:stroke-slate-600" />
|
||||||
|
<XAxis
|
||||||
|
dataKey="date"
|
||||||
|
tickFormatter={(date) => {
|
||||||
|
const width = window.innerWidth;
|
||||||
|
const dayDifference = differenceInDays(dateRange.endDate, dateRange.startDate);
|
||||||
|
|
||||||
|
if (width < 480) {
|
||||||
|
// For very small screens
|
||||||
|
return format(new Date(date), dayDifference > 365 ? 'MM/yy' : 'MM/dd');
|
||||||
|
}
|
||||||
|
if (width < 768) {
|
||||||
|
// For mobile
|
||||||
|
return format(new Date(date), dayDifference > 365 ? 'MM/yyyy' : 'MM/dd/yy');
|
||||||
|
}
|
||||||
|
// For larger screens
|
||||||
|
return format(new Date(date), dayDifference > 365 ? 'MMM yyyy' : 'dd.MM.yyyy');
|
||||||
|
}}
|
||||||
|
tick={{
|
||||||
|
fontSize: window.innerWidth < 768 ? 9 : 11,
|
||||||
|
textAnchor: 'middle',
|
||||||
|
dy: 5
|
||||||
|
}}
|
||||||
|
interval={getXAxisInterval()}
|
||||||
|
padding={{ left: 10, right: 10 }}
|
||||||
|
minTickGap={window.innerWidth < 768 ? 15 : 30}
|
||||||
|
allowDuplicatedCategory={false}
|
||||||
|
allowDecimals={false}
|
||||||
|
axisLine={{ stroke: isDarkMode ? '#4b5563' : '#d1d5db' }}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
yAxisId="left"
|
||||||
|
tickFormatter={(value) => {
|
||||||
|
const width = window.innerWidth;
|
||||||
|
if (width < 480) return `${(value/1000).toFixed(0)}k`;
|
||||||
|
return `${value.toLocaleString()}€`;
|
||||||
|
}}
|
||||||
|
tick={{ fontSize: window.innerWidth < 768 ? 9 : 12 }}
|
||||||
|
width={window.innerWidth < 480 ? 35 : 45}
|
||||||
|
tickCount={window.innerWidth < 768 ? 5 : 8}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
yAxisId="right"
|
||||||
|
orientation="right"
|
||||||
|
tickFormatter={(value) => `${value.toFixed(0)}%`}
|
||||||
|
tick={{ fontSize: window.innerWidth < 768 ? 9 : 12 }}
|
||||||
|
width={window.innerWidth < 480 ? 25 : 35}
|
||||||
|
tickCount={window.innerWidth < 768 ? 5 : 8}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
|
backgroundColor: isDarkMode ? '#1e293b' : '#fff',
|
||||||
|
border: 'none',
|
||||||
|
color: isDarkMode ? '#d1d5d1' : '#000000',
|
||||||
|
boxShadow: '0 0 10px 0 rgba(0, 0, 0, 0.5)',
|
||||||
|
fontSize: window.innerWidth < 768 ? '0.7rem' : '0.875rem',
|
||||||
|
padding: window.innerWidth < 768 ? '4px 6px' : '8px',
|
||||||
|
borderRadius: '6px',
|
||||||
|
maxWidth: window.innerWidth < 768 ? '220px' : '300px',
|
||||||
|
}}
|
||||||
|
formatter={(value: number, name: string, item) => {
|
||||||
|
// Simplify names on mobile
|
||||||
|
if (name === "avg. Portfolio % gain")
|
||||||
|
return [`${value.toFixed(2)}%`, isMobile ? "Avg. Portfolio" : name];
|
||||||
|
|
||||||
|
if (name === "TTWOR") {
|
||||||
|
const ttworValue = item.payload["ttwor_percent"] || 0;
|
||||||
|
return [
|
||||||
|
`${isMobile ? '' : (value.toLocaleString() + '€ ')}(${ttworValue.toFixed(2)}%)`,
|
||||||
|
"TTWOR"
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name === "Portfolio-Value")
|
||||||
|
return [`${value.toLocaleString()}€`, isMobile ? "Portfolio" : name];
|
||||||
|
|
||||||
|
if (name === "Invested Capital")
|
||||||
|
return [`${value.toLocaleString()}€`, isMobile ? "Invested" : name];
|
||||||
|
|
||||||
|
if (name.includes("(%)")) {
|
||||||
|
const shortName = isMobile ?
|
||||||
|
name.replace(" (%)", "").substring(0, 8) + (name.replace(" (%)", "").length > 8 ? "..." : "") :
|
||||||
|
name.replace(" (%)", "");
|
||||||
|
return [`${value.toFixed(2)}%`, shortName];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [`${value.toLocaleString()}€`, isMobile ? name.substring(0, 8) + "..." : name];
|
||||||
|
}}
|
||||||
|
labelFormatter={(date) => format(new Date(date), window.innerWidth < 768 ? 'MM/dd/yy' : 'dd.MM.yyyy')}
|
||||||
|
wrapperStyle={{ zIndex: 1000, touchAction: "none" }}
|
||||||
|
/>
|
||||||
|
{!isFullscreen && (
|
||||||
|
<Legend content={<ChartLegend
|
||||||
|
payload={processedData}
|
||||||
|
hideAssets={hideAssets}
|
||||||
|
hiddenAssets={hiddenAssets}
|
||||||
|
toggleAsset={toggleAsset}
|
||||||
|
toggleAllAssets={toggleAllAssets}
|
||||||
|
assetColors={assetColors}
|
||||||
|
assets={assets}
|
||||||
|
isCompact={window.innerWidth < 768}
|
||||||
|
/>} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Lines remain mostly the same, but with adjusted stroke width for mobile */}
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="total"
|
||||||
|
name="Portfolio-Value"
|
||||||
|
hide={hideAssets || hiddenAssets.has("total")}
|
||||||
|
stroke="#000"
|
||||||
|
strokeWidth={window.innerWidth < 768 ? 1.5 : 2}
|
||||||
|
dot={false}
|
||||||
|
yAxisId="left"
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="invested"
|
||||||
|
name="Invested Capital"
|
||||||
|
hide={hideAssets || hiddenAssets.has("invested")}
|
||||||
|
stroke="#666"
|
||||||
|
strokeDasharray="5 5"
|
||||||
|
strokeWidth={window.innerWidth < 768 ? 1 : 1.5}
|
||||||
|
dot={false}
|
||||||
|
yAxisId="left"
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="ttwor"
|
||||||
|
name="TTWOR"
|
||||||
|
strokeDasharray="5 5"
|
||||||
|
stroke="#a64c79"
|
||||||
|
strokeWidth={window.innerWidth < 768 ? 1 : 1.5}
|
||||||
|
hide={hideAssets || hiddenAssets.has("ttwor")}
|
||||||
|
dot={false}
|
||||||
|
yAxisId="left"
|
||||||
|
/>
|
||||||
|
{assets.map((asset) => (
|
||||||
|
<Line
|
||||||
|
key={asset.id}
|
||||||
|
type="basis"
|
||||||
|
hide={hideAssets || hiddenAssets.has(asset.id)}
|
||||||
|
dataKey={`${asset.id}_percent`}
|
||||||
|
name={`${asset.name} (%)`}
|
||||||
|
stroke={assetColors[asset.id] || "red"}
|
||||||
|
strokeWidth={window.innerWidth < 768 ? 1 : 1.5}
|
||||||
|
dot={false}
|
||||||
|
yAxisId="right"
|
||||||
|
connectNulls={true}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="percentageChange"
|
||||||
|
hide={hideAssets || hiddenAssets.has("percentageChange")}
|
||||||
|
dot={false}
|
||||||
|
name="avg. Portfolio % gain"
|
||||||
|
stroke="#a0a0a0"
|
||||||
|
strokeWidth={window.innerWidth < 768 ? 1 : 1.5}
|
||||||
|
yAxisId="right"
|
||||||
|
/>
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
{!isFullscreen && (
|
||||||
|
<div className="mt-2 px-2">
|
||||||
|
<p className="text-[10px] sm:text-xs text-gray-500">
|
||||||
|
*Note: Left axis shows portfolio value/invested capital, right axis shows percentage gains/losses.
|
||||||
|
</p>
|
||||||
|
<p className="text-[10px] sm:text-xs mt-1 text-gray-500 italic">
|
||||||
|
**Percentages based on daily weighted average data.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import { BarChart2, Eye, EyeOff, Trash2 } from "lucide-react";
|
import { BarChart2, Eye, EyeOff } from "lucide-react";
|
||||||
import { memo } from "react";
|
import { memo } from "react";
|
||||||
|
import { Asset } from "../../types";
|
||||||
|
|
||||||
interface ChartLegendProps {
|
interface ChartLegendProps {
|
||||||
payload: any[];
|
payload: any[];
|
||||||
|
@ -7,12 +8,61 @@ interface ChartLegendProps {
|
||||||
hiddenAssets: Set<string>;
|
hiddenAssets: Set<string>;
|
||||||
toggleAsset: (assetId: string) => void;
|
toggleAsset: (assetId: string) => void;
|
||||||
toggleAllAssets: () => void;
|
toggleAllAssets: () => void;
|
||||||
removeAsset?: (assetId: string) => void;
|
isCompact?: boolean;
|
||||||
|
assetColors?: Record<string, string>;
|
||||||
|
assets?: Asset[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ChartLegend = memo(({ payload, hideAssets, hiddenAssets, toggleAsset, toggleAllAssets, removeAsset }: ChartLegendProps) => {
|
export const ChartLegend = memo(({
|
||||||
|
payload,
|
||||||
|
hideAssets,
|
||||||
|
hiddenAssets,
|
||||||
|
toggleAsset,
|
||||||
|
toggleAllAssets,
|
||||||
|
isCompact = false,
|
||||||
|
assetColors,
|
||||||
|
assets
|
||||||
|
}: ChartLegendProps) => {
|
||||||
|
// Determine which data source to use
|
||||||
|
let legendItems: any[] = [];
|
||||||
|
|
||||||
|
// If we have a valid recharts payload, use that
|
||||||
|
if (payload && payload.length > 0 && payload[0].dataKey) {
|
||||||
|
legendItems = payload;
|
||||||
|
|
||||||
|
const hasInvestments = assets && assets.some(asset => asset.investments && asset.investments.length > 0);
|
||||||
|
|
||||||
|
if(!hasInvestments && legendItems.some(item => item.dataKey === "ttwor" )) {
|
||||||
|
const investmentKeys = [
|
||||||
|
"total",
|
||||||
|
"invested",
|
||||||
|
"ttwor",
|
||||||
|
"percentageChange"
|
||||||
|
];
|
||||||
|
legendItems = legendItems.filter(item => !investmentKeys.includes(item.dataKey));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Otherwise, if we have assets and assetColors, create our own items
|
||||||
|
else if (assets && assets.length > 0 && assetColors) {
|
||||||
|
// Add asset items
|
||||||
|
legendItems = assets.map(asset => ({
|
||||||
|
dataKey: `${asset.id}_percent`,
|
||||||
|
value: `${asset.name} (%)`,
|
||||||
|
color: assetColors[asset.id] || '#000'
|
||||||
|
}));
|
||||||
|
const hasInvestments = assets.some(asset => asset.investments && asset.investments.length > 0);
|
||||||
|
// Add special items
|
||||||
|
legendItems = [
|
||||||
|
...legendItems,
|
||||||
|
hasInvestments && { dataKey: "total", value: "Portfolio-Value", color: "#000" },
|
||||||
|
hasInvestments && { dataKey: "invested", value: "Invested Capital", color: "#666" },
|
||||||
|
hasInvestments && { dataKey: "ttwor", value: "TTWOR", color: "#a64c79" },
|
||||||
|
hasInvestments && { dataKey: "percentageChange", value: "avg. Portfolio % gain", color: "#a0a0a0" }
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-2 p-4 rounded-lg shadow-md dark:shadow-black/60">
|
<div className={`flex flex-col gap-2 ${isCompact ? 'p-2' : 'p-4'} rounded-lg ${isCompact ? '' : 'shadow-md dark:shadow-black/60'}`}>
|
||||||
<div className="flex items-center justify-between gap-2 pb-2 border-b">
|
<div className="flex items-center justify-between gap-2 pb-2 border-b">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<BarChart2 className="w-4 h-4 text-gray-500" />
|
<BarChart2 className="w-4 h-4 text-gray-500" />
|
||||||
|
@ -25,55 +75,52 @@ export const ChartLegend = memo(({ payload, hideAssets, hiddenAssets, toggleAsse
|
||||||
{hideAssets ? (
|
{hideAssets ? (
|
||||||
<>
|
<>
|
||||||
<Eye className="w-4 h-4" />
|
<Eye className="w-4 h-4" />
|
||||||
Show All
|
{!isCompact && "Show All"}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<EyeOff className="w-4 h-4" />
|
<EyeOff className="w-4 h-4" />
|
||||||
Hide All
|
{!isCompact && "Hide All"}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-4">
|
<div className={`flex ${isCompact ? 'flex-col' : 'flex-wrap'} ${isCompact ? 'gap-1' : 'gap-4'}`}>
|
||||||
{payload.map((entry: any, index: number) => {
|
{legendItems.map((entry: any, index: number) => {
|
||||||
|
if (!entry.dataKey) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const assetId = entry.dataKey.split('_')[0];
|
const assetId = entry.dataKey.split('_')[0];
|
||||||
const isHidden = hideAssets || hiddenAssets.has(assetId);
|
const isHidden = hideAssets || hiddenAssets.has(assetId);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={`asset-${index}`} className="flex items-center">
|
<div key={`asset-${index}`} className={`flex items-center ${isCompact ? 'w-full' : ''}`}>
|
||||||
<button
|
<button
|
||||||
onClick={() => toggleAsset(assetId)}
|
onClick={() => toggleAsset(assetId)}
|
||||||
className={`flex items-center gap-2 px-2 py-1 rounded transition-opacity duration-200 ${
|
className={`flex items-center ${isCompact ? 'px-1 py-0.5 text-xs w-full' : 'px-2 py-1'} rounded transition-opacity duration-200 ${
|
||||||
isHidden ? 'opacity-40' : ''
|
isHidden ? 'opacity-40' : ''
|
||||||
} hover:bg-gray-100 dark:hover:bg-gray-800`}
|
} bg-gray-200/20 dark:bg-gray-700/20 border border-gray-200 dark:border-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center w-full justify-between">
|
||||||
<div
|
<div className="flex items-center space-x-2 pr-2">
|
||||||
className="w-8 h-[3px]"
|
<div
|
||||||
style={{ backgroundColor: entry.color }}
|
className={`w-4 h-2 rounded-full`}
|
||||||
/>
|
style={{ backgroundColor: entry.color }}
|
||||||
<span className="text-sm">{entry.value.replace(' (%)', '')}</span>
|
/>
|
||||||
{isHidden ? (
|
<span className={isCompact ? 'text-xs' : 'text-sm'}>
|
||||||
<Eye className="w-3 h-3 text-gray-400 dark:text-gray-600" />
|
{entry.value.replace(' (%)', '')}
|
||||||
) : (
|
</span>
|
||||||
<EyeOff className="w-3 h-3 text-gray-400 dark:text-gray-600" />
|
</div>
|
||||||
)}
|
<div>
|
||||||
|
{isHidden ? (
|
||||||
|
<Eye className={`${isCompact ? 'w-2 h-2' : 'w-3 h-3'} text-gray-400 dark:text-gray-600`} />
|
||||||
|
) : (
|
||||||
|
<EyeOff className={`${isCompact ? 'w-2 h-2' : 'w-3 h-3'} text-gray-400 dark:text-gray-600`} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{removeAsset && !['total', 'invested', 'percentageChange', 'ttwor'].includes(assetId) && (
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
if (confirm(`Are you sure you want to remove ${entry.value.replace(' (%)', '')}?`)) {
|
|
||||||
removeAsset(assetId);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="p-1 ml-1 text-red-500 hover:bg-red-100 dark:hover:bg-red-900/30 rounded"
|
|
||||||
title="Remove asset"
|
|
||||||
>
|
|
||||||
<Trash2 className="w-3 h-3" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { BarChart2, Heart, Moon, Plus, Sun } from "lucide-react";
|
import { BarChart2, CircleChevronDown, CircleChevronUp, Heart, Menu, Moon, Plus, Sun, X } from "lucide-react";
|
||||||
import React from "react";
|
import React, { useState } from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
import { useDarkMode } from "../../hooks/useDarkMode";
|
import { useDarkMode } from "../../hooks/useDarkMode";
|
||||||
|
@ -11,14 +11,16 @@ interface AppShellProps {
|
||||||
|
|
||||||
export const AppShell = ({ children, onAddAsset }: AppShellProps) => {
|
export const AppShell = ({ children, onAddAsset }: AppShellProps) => {
|
||||||
const { isDarkMode, toggleDarkMode } = useDarkMode();
|
const { isDarkMode, toggleDarkMode } = useDarkMode();
|
||||||
|
const [mobileMenuOpen, setMobileMenuOpen] = useState(true);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`app ${isDarkMode ? 'dark' : ''}`}>
|
<div className={`app ${isDarkMode ? 'dark' : ''}`}>
|
||||||
<div className="min-h-screen bg-gray-100 dark:bg-gray-900 p-8 transition-colors relative">
|
<div className="min-h-screen bg-gray-100 dark:bg-gray-900 p-4 sm:p-6 md:p-8 transition-colors relative">
|
||||||
<div className="max-w-7xl mx-auto">
|
<div className="max-w-7xl mx-auto">
|
||||||
<div className="flex justify-between items-center mb-8">
|
{/* Desktop Header */}
|
||||||
<h1 className="text-2xl font-bold dark:text-white">Portfolio Simulator</h1>
|
<div className="hidden md:flex justify-between items-center mb-6 md:mb-8">
|
||||||
<div className="flex gap-4">
|
<h1 className="text-xl sm:text-2xl font-bold dark:text-white">Portfolio Simulator</h1>
|
||||||
|
<div className="flex gap-2 md:gap-4">
|
||||||
<button
|
<button
|
||||||
onClick={toggleDarkMode}
|
onClick={toggleDarkMode}
|
||||||
className="p-2 rounded-full hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
|
className="p-2 rounded-full hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
|
||||||
|
@ -32,20 +34,69 @@ export const AppShell = ({ children, onAddAsset }: AppShellProps) => {
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={onAddAsset}
|
onClick={onAddAsset}
|
||||||
className={`flex items-center gap-2 bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700`}
|
className="flex items-center gap-1 md:gap-2 bg-blue-600 text-white px-3 py-2 md:px-4 md:py-2 rounded text-sm md:text-base hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4 md:w-5 md:h-5" />
|
||||||
|
Add Asset
|
||||||
|
</button>
|
||||||
|
<Link
|
||||||
|
to="/explore"
|
||||||
|
className="flex items-center gap-1 md:gap-2 px-3 py-2 md:px-4 md:py-2 text-sm md:text-base text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-800 rounded"
|
||||||
|
>
|
||||||
|
<BarChart2 className="w-4 h-4 md:w-5 md:h-5" />
|
||||||
|
Stock Explorer
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile Header */}
|
||||||
|
<div className="md:hidden flex justify-between items-center mb-4">
|
||||||
|
<h1 className="text-xl font-bold dark:text-white">Portfolio Simulator</h1>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={toggleDarkMode}
|
||||||
|
className="p-2 rounded-full hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
|
||||||
|
aria-label="Toggle dark mode"
|
||||||
|
>
|
||||||
|
{isDarkMode ? (
|
||||||
|
<Sun className="w-5 h-5 text-yellow-500" />
|
||||||
|
) : (
|
||||||
|
<Moon className="w-5 h-5 text-gray-600" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
|
||||||
|
className="p-2 rounded-full hover:bg-gray-200 dark:hover:bg-gray-700"
|
||||||
|
>
|
||||||
|
{mobileMenuOpen ? (
|
||||||
|
<CircleChevronUp className="w-5 h-5 dark:text-gray-500" />
|
||||||
|
) : (
|
||||||
|
<CircleChevronDown className="w-5 h-5 dark:text-white" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile Menu */}
|
||||||
|
{mobileMenuOpen && (
|
||||||
|
<div className="md:hidden bg-white dark:bg-gray-800 rounded-lg shadow-lg p-4 mb-4 flex flex-col gap-3">
|
||||||
|
<button
|
||||||
|
onClick={onAddAsset}
|
||||||
|
className="flex items-center justify-center gap-2 bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 w-full"
|
||||||
>
|
>
|
||||||
<Plus className="w-5 h-5" />
|
<Plus className="w-5 h-5" />
|
||||||
Add Asset
|
Add Asset
|
||||||
</button>
|
</button>
|
||||||
<Link
|
<Link
|
||||||
to="/explore"
|
to="/explore"
|
||||||
className="flex items-center gap-2 px-4 py-2 text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-800 rounded"
|
className="flex items-center justify-center gap-2 px-4 py-2 text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 rounded w-full border border-gray-200 dark:border-gray-700"
|
||||||
>
|
>
|
||||||
<BarChart2 className="w-5 h-5" />
|
<BarChart2 className="w-5 h-5" />
|
||||||
Stock Explorer
|
Stock Explorer
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -53,9 +104,9 @@ export const AppShell = ({ children, onAddAsset }: AppShellProps) => {
|
||||||
href="https://github.com/Tomato6966/investment-portfolio-simulator"
|
href="https://github.com/Tomato6966/investment-portfolio-simulator"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="fixed bottom-4 left-4 text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 flex items-center gap-1 transition-colors"
|
className="fixed bottom-4 left-4 text-xs sm:text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 flex items-center gap-1 transition-colors"
|
||||||
>
|
>
|
||||||
Built with <Heart className="w-4 h-4 text-red-500 inline animate-pulse" /> by Tomato6966
|
Built with <Heart className="w-3 h-3 sm:w-4 sm:h-4 text-red-500 inline animate-pulse" /> by Tomato6966
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -6,6 +6,7 @@ import { useDebouncedCallback } from "use-debounce";
|
||||||
import { usePortfolioSelector } from "../../hooks/usePortfolio";
|
import { usePortfolioSelector } from "../../hooks/usePortfolio";
|
||||||
import { EQUITY_TYPES, getHistoricalData, searchAssets } from "../../services/yahooFinanceService";
|
import { EQUITY_TYPES, getHistoricalData, searchAssets } from "../../services/yahooFinanceService";
|
||||||
import { Asset } from "../../types";
|
import { Asset } from "../../types";
|
||||||
|
import { intervalBasedOnDateRange } from "../../utils/calculations/intervalBasedOnDateRange";
|
||||||
|
|
||||||
export default function AddAssetModal({ onClose }: { onClose: () => void }) {
|
export default function AddAssetModal({ onClose }: { onClose: () => void }) {
|
||||||
const [ search, setSearch ] = useState('');
|
const [ search, setSearch ] = useState('');
|
||||||
|
@ -35,14 +36,15 @@ export default function AddAssetModal({ onClose }: { onClose: () => void }) {
|
||||||
|
|
||||||
const debouncedSearch = useDebouncedCallback(handleSearch, 750);
|
const debouncedSearch = useDebouncedCallback(handleSearch, 750);
|
||||||
|
|
||||||
const handleAssetSelect = (asset: Asset) => {
|
const handleAssetSelect = (asset: Asset, keepOpen: boolean = false) => {
|
||||||
setLoading("adding");
|
setLoading("adding");
|
||||||
setTimeout(async () => {
|
setTimeout(async () => {
|
||||||
try {
|
try {
|
||||||
const { historicalData, longName } = await getHistoricalData(
|
const { historicalData, longName } = await getHistoricalData(
|
||||||
asset.symbol,
|
asset.symbol,
|
||||||
dateRange.startDate,
|
dateRange.startDate,
|
||||||
dateRange.endDate
|
dateRange.endDate,
|
||||||
|
intervalBasedOnDateRange(dateRange),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (historicalData.size === 0) {
|
if (historicalData.size === 0) {
|
||||||
|
@ -58,7 +60,12 @@ export default function AddAssetModal({ onClose }: { onClose: () => void }) {
|
||||||
|
|
||||||
addAsset(assetWithHistory);
|
addAsset(assetWithHistory);
|
||||||
toast.success(`Successfully added ${assetWithHistory.name}`);
|
toast.success(`Successfully added ${assetWithHistory.name}`);
|
||||||
onClose();
|
if (!keepOpen) {
|
||||||
|
onClose();
|
||||||
|
} else {
|
||||||
|
setSearch("");
|
||||||
|
setSearchResults([]);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching historical data:', error);
|
console.error('Error fetching historical data:', error);
|
||||||
toast.error(`Failed to add ${asset.name}. Please try again.`);
|
toast.error(`Failed to add ${asset.name}. Please try again.`);
|
||||||
|
@ -112,10 +119,9 @@ export default function AddAssetModal({ onClose }: { onClose: () => void }) {
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
searchResults.map((result) => (
|
searchResults.map((result) => (
|
||||||
<button
|
<div
|
||||||
key={result.symbol}
|
key={result.symbol}
|
||||||
className="w-full text-left p-3 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-slate-900 rounded border-b dark:border-slate-700 border-gray-300"
|
className="w-full text-left p-3 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-slate-900 rounded border-b dark:border-slate-700 border-gray-300"
|
||||||
onClick={() => handleAssetSelect(result)}
|
|
||||||
>
|
>
|
||||||
<div className="font-medium flex justify-between">
|
<div className="font-medium flex justify-between">
|
||||||
<span>{result.name}</span>
|
<span>{result.name}</span>
|
||||||
|
@ -129,7 +135,23 @@ export default function AddAssetModal({ onClose }: { onClose: () => void }) {
|
||||||
<div className="text-sm text-gray-600">
|
<div className="text-sm text-gray-600">
|
||||||
Ticker-Symbol: {result.symbol} | Type: {result.quoteType?.toUpperCase() || "Unknown"} | Rank: #{result.rank || "-"}
|
Ticker-Symbol: {result.symbol} | Type: {result.quoteType?.toUpperCase() || "Unknown"} | Rank: #{result.rank || "-"}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
<div className="mt-2 flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => handleAssetSelect(result, false)}
|
||||||
|
disabled={loading === "adding"}
|
||||||
|
className="px-3 py-1 bg-blue-600 text-white rounded hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleAssetSelect(result, true)}
|
||||||
|
disabled={loading === "adding"}
|
||||||
|
className="px-3 py-1 bg-green-600 text-white rounded hover:bg-green-700"
|
||||||
|
>
|
||||||
|
Add & Add Another
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { X } from "lucide-react";
|
import { X, ChevronDown, Loader2 } from "lucide-react";
|
||||||
import { useCallback, useMemo, useState } from "react";
|
import { useCallback, useMemo, useState } from "react";
|
||||||
import { useDebouncedCallback } from "use-debounce";
|
|
||||||
|
|
||||||
import { useDarkMode } from "../hooks/useDarkMode";
|
import { useDarkMode } from "../hooks/useDarkMode";
|
||||||
import { usePortfolioSelector } from "../hooks/usePortfolio";
|
import { usePortfolioSelector } from "../hooks/usePortfolio";
|
||||||
|
@ -10,35 +9,38 @@ import { DateRange } from "../types";
|
||||||
import { calculatePortfolioValue } from "../utils/calculations/portfolioValue";
|
import { calculatePortfolioValue } from "../utils/calculations/portfolioValue";
|
||||||
import { getHexColor } from "../utils/formatters";
|
import { getHexColor } from "../utils/formatters";
|
||||||
import { ChartContent } from "./Chart/ChartContent";
|
import { ChartContent } from "./Chart/ChartContent";
|
||||||
|
import { DateRangePicker } from "./utils/DateRangePicker";
|
||||||
|
import { ChartLegend } from "./Chart/ChartLegend";
|
||||||
|
import { useIsMobile } from "./utils/IsMobile";
|
||||||
|
import { intervalBasedOnDateRange } from "../utils/calculations/intervalBasedOnDateRange";
|
||||||
|
|
||||||
export default function PortfolioChart() {
|
export default function PortfolioChart() {
|
||||||
const [ isFullscreen, setIsFullscreen ] = useState(false);
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||||
const [ hideAssets, setHideAssets ] = useState(false);
|
const [hideAssets, setHideAssets] = useState(false);
|
||||||
const [ hiddenAssets, setHiddenAssets ] = useState<Set<string>>(new Set());
|
const [hiddenAssets, setHiddenAssets] = useState<Set<string>>(new Set());
|
||||||
|
const [showLegendAndDateRange, setShowLegendAndDateRange] = useState(false);
|
||||||
|
const [showControls, setShowControls] = useState(false);
|
||||||
|
const [isHistoricalLoading, setIsHistoricalLoading] = useState(false);
|
||||||
const { isDarkMode } = useDarkMode();
|
const { isDarkMode } = useDarkMode();
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
const { assets, dateRange, updateDateRange, updateAssetHistoricalData, removeAsset } = usePortfolioSelector((state) => ({
|
const { assets, dateRange, updateDateRange, updateAssetHistoricalData } = usePortfolioSelector((state) => ({
|
||||||
assets: state.assets,
|
assets: state.assets,
|
||||||
dateRange: state.dateRange,
|
dateRange: state.dateRange,
|
||||||
updateDateRange: state.updateDateRange,
|
updateDateRange: state.updateDateRange,
|
||||||
updateAssetHistoricalData: state.updateAssetHistoricalData,
|
updateAssetHistoricalData: state.updateAssetHistoricalData,
|
||||||
removeAsset: state.removeAsset,
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const fetchHistoricalData = useCallback(
|
const fetchHistoricalData = useCallback(
|
||||||
async (startDate: Date, endDate: Date) => {
|
async (startDate: Date, endDate: Date) => {
|
||||||
for (const asset of assets) {
|
for (const asset of assets) {
|
||||||
const { historicalData, longName } = await getHistoricalData(asset.symbol, startDate, endDate);
|
const { historicalData, longName } = await getHistoricalData(asset.symbol, startDate, endDate, intervalBasedOnDateRange({ startDate, endDate }));
|
||||||
updateAssetHistoricalData(asset.id, historicalData, longName);
|
updateAssetHistoricalData(asset.id, historicalData, longName);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[assets, updateAssetHistoricalData]
|
[assets, updateAssetHistoricalData]
|
||||||
);
|
);
|
||||||
|
|
||||||
const debouncedFetchHistoricalData = useDebouncedCallback(fetchHistoricalData, 1500, {
|
|
||||||
maxWait: 5000,
|
|
||||||
});
|
|
||||||
|
|
||||||
const assetColors: Record<string, string> = useMemo(() => {
|
const assetColors: Record<string, string> = useMemo(() => {
|
||||||
const usedColors = new Set<string>();
|
const usedColors = new Set<string>();
|
||||||
return assets.reduce((colors, asset) => {
|
return assets.reduce((colors, asset) => {
|
||||||
|
@ -120,60 +122,167 @@ export default function PortfolioChart() {
|
||||||
}, [hideAssets]);
|
}, [hideAssets]);
|
||||||
|
|
||||||
const handleUpdateDateRange = useCallback((newRange: DateRange) => {
|
const handleUpdateDateRange = useCallback((newRange: DateRange) => {
|
||||||
|
setIsHistoricalLoading(true);
|
||||||
updateDateRange(newRange);
|
updateDateRange(newRange);
|
||||||
debouncedFetchHistoricalData(newRange.startDate, newRange.endDate);
|
fetchHistoricalData(newRange.startDate, newRange.endDate)
|
||||||
}, [updateDateRange, debouncedFetchHistoricalData]);
|
.catch((err) => {
|
||||||
|
console.error("Error fetching historical data:", err);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setIsHistoricalLoading(false);
|
||||||
|
});
|
||||||
|
}, [updateDateRange, fetchHistoricalData]);
|
||||||
|
|
||||||
const [renderKey, setRenderKey] = useState(0);
|
|
||||||
|
|
||||||
const handleReRender = useCallback(() => {
|
|
||||||
setRenderKey(prevKey => prevKey + 1);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
console.log(processedData);
|
|
||||||
console.log("TEST")
|
|
||||||
if (isFullscreen) {
|
if (isFullscreen) {
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-white dark:bg-slate-800 z-50 overflow-y-auto">
|
<div className="fixed inset-0 bg-white dark:bg-slate-800 z-50 overflow-hidden flex flex-col">
|
||||||
<div className="flex justify-between items-center mb-4 p-5">
|
<div className="flex justify-between items-center p-2 border-b dark:border-slate-700">
|
||||||
<h2 className="text-xl font-bold dark:text-gray-300">Portfolio Chart</h2>
|
<h2 className="text-lg font-bold dark:text-gray-300">Portfolio Chart</h2>
|
||||||
<button
|
<div className="flex items-center gap-2">
|
||||||
onClick={() => setIsFullscreen(false)}
|
{isMobile && (
|
||||||
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded"
|
<button
|
||||||
>
|
onClick={() => setShowLegendAndDateRange(!showLegendAndDateRange)}
|
||||||
<X className="w-6 h-6 dark:text-gray-300" />
|
className="p-1 hover:bg-gray-100 dark:hover:bg-gray-800 rounded flex items-center gap-1 border border-gray-300 dark:border-slate-700"
|
||||||
</button>
|
title="Exit Fullscreen"
|
||||||
|
>
|
||||||
|
<ChevronDown className="w-2 h-2 dark:text-gray-300" /> <span className="text-xs">Legend & Date-Range</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => setIsFullscreen(false)}
|
||||||
|
className="p-1 hover:bg-gray-100 dark:hover:bg-gray-800 rounded"
|
||||||
|
title="Exit Fullscreen"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4 dark:text-gray-300" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ChartContent
|
{(showLegendAndDateRange && isMobile) && (
|
||||||
dateRange={dateRange}
|
<>
|
||||||
handleUpdateDateRange={handleUpdateDateRange}
|
{/* Legend and Date-Range as a full-screen modal */}
|
||||||
handleReRender={handleReRender}
|
<div className="fixed inset-0 bg-white dark:bg-slate-800 z-50 overflow-hidden flex flex-col">
|
||||||
isFullscreen={isFullscreen}
|
<div className="flex justify-between items-center p-2 border-b dark:border-slate-700">
|
||||||
setIsFullscreen={setIsFullscreen}
|
<h2 className="text-lg font-bold dark:text-gray-300">Legend & Date-Range</h2>
|
||||||
renderKey={renderKey}
|
<button
|
||||||
isDarkMode={isDarkMode}
|
onClick={() => setShowLegendAndDateRange(false)}
|
||||||
hideAssets={hideAssets}
|
className="p-1 hover:bg-gray-100 dark:hover:bg-gray-800 rounded"
|
||||||
hiddenAssets={hiddenAssets}
|
title="Exit Fullscreen"
|
||||||
processedData={processedData}
|
>
|
||||||
assets={assets}
|
<X className="w-4 h-4 dark:text-gray-300" />
|
||||||
assetColors={assetColors}
|
</button>
|
||||||
toggleAsset={toggleAsset}
|
</div>
|
||||||
toggleAllAssets={toggleAllAssets}
|
<div className="md:w-[15%] p-2 overflow-y-auto border-t md:border-t-0 md:border-l dark:border-slate-700">
|
||||||
removeAsset={removeAsset}
|
<div className="mb-4">
|
||||||
/>
|
<DateRangePicker
|
||||||
|
startDate={dateRange.startDate}
|
||||||
|
endDate={dateRange.endDate}
|
||||||
|
onDateRangeChange={handleUpdateDateRange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<ChartLegend
|
||||||
|
payload={assets}
|
||||||
|
assets={assets}
|
||||||
|
hideAssets={hideAssets}
|
||||||
|
hiddenAssets={hiddenAssets}
|
||||||
|
toggleAsset={toggleAsset}
|
||||||
|
toggleAllAssets={toggleAllAssets}
|
||||||
|
isCompact={true}
|
||||||
|
assetColors={assetColors}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-col md:flex-row h-[calc(100vh-40px)]">
|
||||||
|
<div className="md:w-[85%] h-full overflow-hidden">
|
||||||
|
<ChartContent
|
||||||
|
dateRange={dateRange}
|
||||||
|
handleUpdateDateRange={handleUpdateDateRange}
|
||||||
|
isFullscreen={isFullscreen}
|
||||||
|
setIsFullscreen={setIsFullscreen}
|
||||||
|
isDarkMode={isDarkMode}
|
||||||
|
hideAssets={hideAssets}
|
||||||
|
hiddenAssets={hiddenAssets}
|
||||||
|
processedData={processedData}
|
||||||
|
assets={assets}
|
||||||
|
assetColors={assetColors}
|
||||||
|
toggleAsset={toggleAsset}
|
||||||
|
toggleAllAssets={toggleAllAssets}
|
||||||
|
isMobile={isMobile}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{!isMobile && (
|
||||||
|
<div className="md:w-[15%] p-2 overflow-y-auto border-t md:border-t-0 md:border-l dark:border-slate-700">
|
||||||
|
<div className="mb-4">
|
||||||
|
<DateRangePicker
|
||||||
|
startDate={dateRange.startDate}
|
||||||
|
endDate={dateRange.endDate}
|
||||||
|
onDateRangeChange={handleUpdateDateRange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<ChartLegend
|
||||||
|
payload={assets}
|
||||||
|
assets={assets}
|
||||||
|
hideAssets={hideAssets}
|
||||||
|
hiddenAssets={hiddenAssets}
|
||||||
|
toggleAsset={toggleAsset}
|
||||||
|
toggleAllAssets={toggleAllAssets}
|
||||||
|
isCompact={true}
|
||||||
|
assetColors={assetColors}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{showControls && (
|
||||||
|
<div className="fixed inset-0 bg-white dark:bg-slate-800 z-30 overflow-auto p-4">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<h2 className="text-lg font-bold dark:text-gray-300">Chart Controls</h2>
|
||||||
|
<button onClick={() => setShowControls(false)}>
|
||||||
|
<X className="w-6 h-6 dark:text-gray-300" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 space-y-4">
|
||||||
|
<DateRangePicker
|
||||||
|
startDate={dateRange.startDate}
|
||||||
|
endDate={dateRange.endDate}
|
||||||
|
onDateRangeChange={handleUpdateDateRange}
|
||||||
|
/>
|
||||||
|
<ChartLegend
|
||||||
|
payload={[]}
|
||||||
|
hideAssets={hideAssets}
|
||||||
|
hiddenAssets={hiddenAssets}
|
||||||
|
toggleAsset={(id: string) => {
|
||||||
|
const newHidden = new Set(hiddenAssets);
|
||||||
|
newHidden.has(id) ? newHidden.delete(id) : newHidden.add(id);
|
||||||
|
setHiddenAssets(newHidden);
|
||||||
|
}}
|
||||||
|
toggleAllAssets={() => {
|
||||||
|
setHideAssets(!hideAssets);
|
||||||
|
setHiddenAssets(new Set());
|
||||||
|
}}
|
||||||
|
isCompact={true}
|
||||||
|
assetColors={assetColors}
|
||||||
|
assets={assets}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isHistoricalLoading && (
|
||||||
|
<div className="fixed inset-0 flex items-center justify-center z-[100] bg-black bg-opacity-30">
|
||||||
|
<Loader2 className="animate-spin w-12 h-12 text-white" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full bg-white dark:bg-slate-800 p-4 rounded-lg shadow dark:shadow-black/60">
|
<div className="w-full bg-white dark:bg-slate-800 p-4 rounded-lg shadow dark:shadow-black/60 relative">
|
||||||
<ChartContent
|
<ChartContent
|
||||||
dateRange={dateRange}
|
dateRange={dateRange}
|
||||||
handleUpdateDateRange={handleUpdateDateRange}
|
handleUpdateDateRange={handleUpdateDateRange}
|
||||||
handleReRender={handleReRender}
|
|
||||||
isFullscreen={isFullscreen}
|
isFullscreen={isFullscreen}
|
||||||
setIsFullscreen={setIsFullscreen}
|
setIsFullscreen={setIsFullscreen}
|
||||||
renderKey={renderKey}
|
|
||||||
isDarkMode={isDarkMode}
|
isDarkMode={isDarkMode}
|
||||||
hideAssets={hideAssets}
|
hideAssets={hideAssets}
|
||||||
hiddenAssets={hiddenAssets}
|
hiddenAssets={hiddenAssets}
|
||||||
|
@ -182,8 +291,13 @@ export default function PortfolioChart() {
|
||||||
assetColors={assetColors}
|
assetColors={assetColors}
|
||||||
toggleAsset={toggleAsset}
|
toggleAsset={toggleAsset}
|
||||||
toggleAllAssets={toggleAllAssets}
|
toggleAllAssets={toggleAllAssets}
|
||||||
removeAsset={removeAsset}
|
isMobile={isMobile}
|
||||||
/>
|
/>
|
||||||
|
{isHistoricalLoading && (
|
||||||
|
<div className="fixed inset-0 flex items-center justify-center z-[100] bg-black bg-opacity-30">
|
||||||
|
<Loader2 className="animate-spin w-12 h-12 text-white" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -28,10 +28,11 @@ interface SavingsPlanPerformance {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default memo(function PortfolioTable() {
|
export default memo(function PortfolioTable() {
|
||||||
const { assets, removeInvestment, clearInvestments } = usePortfolioSelector((state) => ({
|
const { assets, removeInvestment, clearInvestments, removeAsset } = usePortfolioSelector((state) => ({
|
||||||
assets: state.assets,
|
assets: state.assets,
|
||||||
removeInvestment: state.removeInvestment,
|
removeInvestment: state.removeInvestment,
|
||||||
clearInvestments: state.clearInvestments,
|
clearInvestments: state.clearInvestments,
|
||||||
|
removeAsset: state.removeAsset,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const [editingInvestment, setEditingInvestment] = useState<{
|
const [editingInvestment, setEditingInvestment] = useState<{
|
||||||
|
@ -240,7 +241,14 @@ export default memo(function PortfolioTable() {
|
||||||
key={asset.id}
|
key={asset.id}
|
||||||
className="p-4 rounded-lg border border-gray-200 dark:border-gray-700 hover:shadow-lg transition-shadow"
|
className="p-4 rounded-lg border border-gray-200 dark:border-gray-700 hover:shadow-lg transition-shadow"
|
||||||
>
|
>
|
||||||
<h3 className="text-lg font-bold mb-2 text-nowrap">{asset.name}</h3>
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="text-lg font-bold mb-2 text-nowrap">{asset.name}</h3>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button className="p-1 hover:bg-gray-100 dark:hover:bg-slate-700 rounded transition-colors" onClick={() => removeAsset(asset.id)}>
|
||||||
|
<Trash2 className="w-4 h-4 text-red-500" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<table className="w-full">
|
<table className="w-full">
|
||||||
<thead>
|
<thead>
|
||||||
|
@ -598,6 +606,76 @@ export default memo(function PortfolioTable() {
|
||||||
onClose={() => setShowPortfolioPerformance(false)}
|
onClose={() => setShowPortfolioPerformance(false)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<div className="overflow-x-auto dark:text-gray-300 p-4 border-gray-300 dark:border-slate-800 rounded-lg bg-white dark:bg-slate-800 shadow-lg dark:shadow-black/60 mt-6">
|
||||||
|
<div className="flex justify-between items-center mb-4">
|
||||||
|
<h3 className="text-lg font-bold">Assets Performance Overview</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||||
|
{assets.map(asset => {
|
||||||
|
const assetPerformance = calculateInvestmentPerformance([asset]);
|
||||||
|
const totalInvested = assetPerformance.summary.totalInvested;
|
||||||
|
const currentValue = assetPerformance.summary.currentValue;
|
||||||
|
const performancePercent = assetPerformance.summary.performancePercentage;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={asset.id}
|
||||||
|
className="border dark:border-slate-700 rounded-lg p-4 flex flex-col h-full"
|
||||||
|
>
|
||||||
|
<div className="flex justify-between items-start mb-2">
|
||||||
|
<h4 className="font-bold text-md truncate max-w-[80%]" title={asset.name}>
|
||||||
|
{asset.name}
|
||||||
|
</h4>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (confirm(`Are you sure you want to remove ${asset.name} including all its investments?`)) {
|
||||||
|
removeInvestment(asset.id, asset.investments[0].id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="p-1 text-red-500 hover:bg-red-100 dark:hover:bg-red-900/30 rounded"
|
||||||
|
title="Remove asset including all its investments"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-2 mb-2 flex-grow">
|
||||||
|
<div className="text-sm">
|
||||||
|
<div className="text-gray-500 dark:text-gray-400">Invested</div>
|
||||||
|
<div>€{totalInvested.toFixed(2)}</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm">
|
||||||
|
<div className="text-gray-500 dark:text-gray-400">Current</div>
|
||||||
|
<div>€{currentValue.toFixed(2)}</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm">
|
||||||
|
<div className="text-gray-500 dark:text-gray-400">Performance</div>
|
||||||
|
<div className={performancePercent >= 0 ? 'text-green-500' : 'text-red-500'}>
|
||||||
|
{performancePercent.toFixed(2)}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm">
|
||||||
|
<div className="text-gray-500 dark:text-gray-400">Positions</div>
|
||||||
|
<div>{asset.investments.length}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedAsset({
|
||||||
|
name: asset.name,
|
||||||
|
performances: assetPerformance.summary.annualPerformancesPerAsset.get(asset.id) || []
|
||||||
|
})}
|
||||||
|
className="w-full mt-2 px-3 py-1 bg-blue-500 hover:bg-blue-600 text-white rounded text-sm"
|
||||||
|
>
|
||||||
|
View Performance
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,93 +1,103 @@
|
||||||
import { useRef } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { useDebouncedCallback } from "use-debounce";
|
import { format } from "date-fns";
|
||||||
|
import { Check } from "lucide-react";
|
||||||
|
|
||||||
import { useLocaleDateFormat } from "../../hooks/useLocalDateFormat";
|
import { useLocaleDateFormat } from "../../hooks/useLocalDateFormat";
|
||||||
import { formatDateToISO, isValidDate } from "../../utils/formatters";
|
import { DateRange } from "../../types";
|
||||||
|
import { intervalBasedOnDateRange } from "../../utils/calculations/intervalBasedOnDateRange";
|
||||||
|
|
||||||
interface DateRangePickerProps {
|
interface DateRangePickerProps {
|
||||||
startDate: Date;
|
startDate: Date;
|
||||||
endDate: Date;
|
endDate: Date;
|
||||||
onStartDateChange: (date: Date) => void;
|
onDateRangeChange: (dateRange: DateRange) => void;
|
||||||
onEndDateChange: (date: Date) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DateRangePicker = ({
|
export const DateRangePicker = ({ startDate, endDate, onDateRangeChange }: DateRangePickerProps) => {
|
||||||
startDate,
|
const [localStartDate, setLocalStartDate] = useState<Date>(startDate);
|
||||||
endDate,
|
const [localEndDate, setLocalEndDate] = useState<Date>(endDate);
|
||||||
onStartDateChange,
|
const [hasChanges, setHasChanges] = useState(false);
|
||||||
onEndDateChange,
|
const [startDateText, setStartDateText] = useState(format(startDate, 'yyyy-MM-dd'));
|
||||||
}: DateRangePickerProps) => {
|
const [endDateText, setEndDateText] = useState(format(endDate, 'yyyy-MM-dd'));
|
||||||
const startDateRef = useRef<HTMLInputElement>(null);
|
|
||||||
const endDateRef = useRef<HTMLInputElement>(null);
|
|
||||||
const localeDateFormat = useLocaleDateFormat();
|
const localeDateFormat = useLocaleDateFormat();
|
||||||
|
|
||||||
const debouncedStartDateChange = useDebouncedCallback(
|
// Update local state when props change
|
||||||
(dateString: string) => {
|
useEffect(() => {
|
||||||
if (isValidDate(dateString)) {
|
setLocalStartDate(startDate);
|
||||||
const newDate = new Date(dateString);
|
setLocalEndDate(endDate);
|
||||||
|
setStartDateText(format(startDate, 'yyyy-MM-dd'));
|
||||||
|
setEndDateText(format(endDate, 'yyyy-MM-dd'));
|
||||||
|
setHasChanges(false);
|
||||||
|
}, [startDate, endDate]);
|
||||||
|
|
||||||
if (newDate.getTime() !== startDate.getTime()) {
|
const handleLocalStartDateChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
onStartDateChange(newDate);
|
const dateValue = e.target.value;
|
||||||
}
|
setStartDateText(dateValue);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const newDate = new Date(dateValue);
|
||||||
|
if (!isNaN(newDate.getTime())) {
|
||||||
|
setLocalStartDate(newDate);
|
||||||
|
setHasChanges(true);
|
||||||
}
|
}
|
||||||
},
|
} catch (error) {
|
||||||
750
|
console.error("Invalid date format", error);
|
||||||
);
|
|
||||||
|
|
||||||
const debouncedEndDateChange = useDebouncedCallback(
|
|
||||||
(dateString: string) => {
|
|
||||||
if (isValidDate(dateString)) {
|
|
||||||
const newDate = new Date(dateString);
|
|
||||||
|
|
||||||
if (newDate.getTime() !== endDate.getTime()) {
|
|
||||||
onEndDateChange(newDate);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
750
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleStartDateChange = () => {
|
|
||||||
if (startDateRef.current) {
|
|
||||||
debouncedStartDateChange(startDateRef.current.value);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEndDateChange = () => {
|
const handleLocalEndDateChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
if (endDateRef.current) {
|
const dateValue = e.target.value;
|
||||||
debouncedEndDateChange(endDateRef.current.value);
|
setEndDateText(dateValue);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const newDate = new Date(dateValue);
|
||||||
|
if (!isNaN(newDate.getTime())) {
|
||||||
|
setLocalEndDate(newDate);
|
||||||
|
setHasChanges(true);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Invalid date format", error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleApplyChanges = () => {
|
||||||
|
setHasChanges(false);
|
||||||
|
// Update the date range
|
||||||
|
onDateRangeChange({ startDate: localStartDate, endDate: localEndDate });
|
||||||
|
};
|
||||||
return (
|
return (
|
||||||
<div className="flex gap-4 items-center mb-4 dark:text-gray-300">
|
<div className="flex flex-wrap items-end gap-2">
|
||||||
<div>
|
<div className="flex-1">
|
||||||
<label className="block text-sm font-medium mb-1">
|
<label className="block text-sm text-gray-600 dark:text-gray-400 mb-1">
|
||||||
From {localeDateFormat && <span className="text-xs text-gray-500">({localeDateFormat})</span>}
|
Start Date <p className="text-xs text-gray-500">({localeDateFormat})</p>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
ref={startDateRef}
|
|
||||||
type="date"
|
type="date"
|
||||||
defaultValue={formatDateToISO(startDate)}
|
value={startDateText}
|
||||||
onChange={handleStartDateChange}
|
onChange={handleLocalStartDateChange}
|
||||||
max={formatDateToISO(endDate)}
|
className="border p-2 rounded dark:bg-slate-700 dark:text-white dark:border-slate-600 w-full"
|
||||||
className="w-full p-2 border rounded dark:bg-slate-800 dark:border-slate-700 dark:outline-none dark:text-gray-300 [&::-webkit-calendar-picker-indicator]:dark:invert"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="flex-1">
|
||||||
<label className="block text-sm font-medium mb-1">
|
<label className="block text-sm text-gray-600 dark:text-gray-400 mb-1">
|
||||||
To {localeDateFormat && <span className="text-xs text-gray-500">({localeDateFormat})</span>}
|
End Date <p className="text-xs text-gray-500">({localeDateFormat}) <span className="text-red-500/30 italic text-[10px]">Data-Fetching-Interval ({intervalBasedOnDateRange({ startDate: localStartDate, endDate: localEndDate })})</span></p>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
ref={endDateRef}
|
|
||||||
type="date"
|
type="date"
|
||||||
defaultValue={formatDateToISO(endDate)}
|
value={endDateText}
|
||||||
onChange={handleEndDateChange}
|
onChange={handleLocalEndDateChange}
|
||||||
min={formatDateToISO(startDate)}
|
max={format(new Date(), 'yyyy-MM-dd')}
|
||||||
max={formatDateToISO(new Date())}
|
className="border p-2 rounded dark:bg-slate-700 dark:text-white dark:border-slate-600 w-full"
|
||||||
className="w-full p-2 border rounded dark:bg-slate-800 dark:border-slate-700 dark:outline-none dark:text-gray-300 [&::-webkit-calendar-picker-indicator]:dark:invert"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleApplyChanges}
|
||||||
|
disabled={!hasChanges}
|
||||||
|
title="Apply date range"
|
||||||
|
className="h-10 flex items-center justify-center p-1 bg-blue-500 text-white rounded hover:bg-blue-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
<Check className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
22
src/components/utils/IsMobile.tsx
Normal file
22
src/components/utils/IsMobile.tsx
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
export function useIsMobile() {
|
||||||
|
const [isMobile, setIsMobile] = useState(window.innerWidth < 768);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleResize = () => {
|
||||||
|
setIsMobile(window.innerWidth < 768);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add event listener
|
||||||
|
window.addEventListener('resize', handleResize);
|
||||||
|
|
||||||
|
// Call handler right away to set initial state
|
||||||
|
handleResize();
|
||||||
|
|
||||||
|
// Clean up on unmount
|
||||||
|
return () => window.removeEventListener('resize', handleResize);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return isMobile;
|
||||||
|
}
|
|
@ -9,7 +9,7 @@ import { DarkModeProvider } from "./providers/DarkModeProvider.tsx";
|
||||||
|
|
||||||
// Let App handle the route definitions
|
// Let App handle the route definitions
|
||||||
const router = createBrowserRouter(App, {
|
const router = createBrowserRouter(App, {
|
||||||
basename: "/investment-portfolio-simulator"
|
basename: "/"
|
||||||
});
|
});
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
|
|
|
@ -11,7 +11,7 @@ import { useDarkMode } from "../hooks/useDarkMode";
|
||||||
import { EQUITY_TYPES, getHistoricalData, searchAssets } from "../services/yahooFinanceService";
|
import { EQUITY_TYPES, getHistoricalData, searchAssets } from "../services/yahooFinanceService";
|
||||||
import { Asset } from "../types";
|
import { Asset } from "../types";
|
||||||
import { getHexColor } from "../utils/formatters";
|
import { getHexColor } from "../utils/formatters";
|
||||||
|
import { intervalBasedOnDateRange } from "../utils/calculations/intervalBasedOnDateRange";
|
||||||
// Time period options
|
// Time period options
|
||||||
const TIME_PERIODS = {
|
const TIME_PERIODS = {
|
||||||
YTD: "Year to Date",
|
YTD: "Year to Date",
|
||||||
|
@ -119,10 +119,16 @@ const StockExplorer = () => {
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
|
// Use a more efficient date range for initial load
|
||||||
|
const optimizedStartDate = timePeriod === "MAX" || timePeriod === "20Y" || timePeriod === "15Y" || timePeriod === "10Y"
|
||||||
|
? new Date(dateRange.startDate.getTime() + (365 * 24 * 60 * 60 * 1000)) // Skip first year for very long ranges
|
||||||
|
: dateRange.startDate;
|
||||||
|
|
||||||
const { historicalData, longName } = await getHistoricalData(
|
const { historicalData, longName } = await getHistoricalData(
|
||||||
stock.symbol,
|
stock.symbol,
|
||||||
dateRange.startDate,
|
optimizedStartDate,
|
||||||
dateRange.endDate
|
dateRange.endDate,
|
||||||
|
intervalBasedOnDateRange({ startDate: optimizedStartDate, endDate: dateRange.endDate })
|
||||||
);
|
);
|
||||||
|
|
||||||
if (historicalData.size === 0) {
|
if (historicalData.size === 0) {
|
||||||
|
@ -148,7 +154,6 @@ const StockExplorer = () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
// Don't clear results, so users can add multiple stocks
|
// Don't clear results, so users can add multiple stocks
|
||||||
// Just clear the specific stock that was added
|
|
||||||
setSearchResults(prev => prev.filter(result => result.symbol !== stock.symbol));
|
setSearchResults(prev => prev.filter(result => result.symbol !== stock.symbol));
|
||||||
|
|
||||||
toast.success(`Added ${stockWithHistory.name} to comparison`);
|
toast.success(`Added ${stockWithHistory.name} to comparison`);
|
||||||
|
@ -158,7 +163,7 @@ const StockExplorer = () => {
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [dateRange, isDarkMode, selectedStocks]);
|
}, [dateRange, isDarkMode, selectedStocks, timePeriod]);
|
||||||
|
|
||||||
// Remove stock from comparison
|
// Remove stock from comparison
|
||||||
const removeStock = useCallback((stockId: string) => {
|
const removeStock = useCallback((stockId: string) => {
|
||||||
|
@ -262,37 +267,49 @@ const StockExplorer = () => {
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
// Fetch updated data for each stock
|
// Process in batches for better performance
|
||||||
const updatedStocks = await Promise.all(
|
const batchSize = 3;
|
||||||
selectedStocks.map(async stock => {
|
const batches = [];
|
||||||
const { historicalData, longName } = await getHistoricalData(
|
|
||||||
stock.symbol,
|
|
||||||
dateRange.startDate,
|
|
||||||
dateRange.endDate
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
for (let i = 0; i < selectedStocks.length; i += batchSize) {
|
||||||
...stock,
|
batches.push(selectedStocks.slice(i, i + batchSize));
|
||||||
name: longName || stock.name,
|
}
|
||||||
historicalData
|
|
||||||
};
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
// Update chart data
|
const updatedStocks = [...selectedStocks];
|
||||||
setStockData(processStockData(updatedStocks));
|
|
||||||
|
for (const batch of batches) {
|
||||||
|
await Promise.all(batch.map(async (stock, index) => {
|
||||||
|
try {
|
||||||
|
const { historicalData, longName } = await getHistoricalData(
|
||||||
|
stock.symbol,
|
||||||
|
dateRange.startDate,
|
||||||
|
dateRange.endDate,
|
||||||
|
intervalBasedOnDateRange({ startDate: dateRange.startDate, endDate: dateRange.endDate })
|
||||||
|
);
|
||||||
|
|
||||||
|
if (historicalData.size > 0) {
|
||||||
|
updatedStocks[selectedStocks.indexOf(stock)] = {
|
||||||
|
...stock,
|
||||||
|
name: longName || stock.name,
|
||||||
|
historicalData
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error refreshing ${stock.name}:`, error);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
// Unconditionally update selectedStocks so the table refreshes
|
|
||||||
setSelectedStocks(updatedStocks);
|
setSelectedStocks(updatedStocks);
|
||||||
|
processStockData(updatedStocks);
|
||||||
|
|
||||||
toast.success("Stock data refreshed");
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error refreshing data:", error);
|
console.error("Error refreshing data:", error);
|
||||||
toast.error("Failed to refresh stock data");
|
toast.error("Failed to refresh stock data");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [dateRange, processStockData]);
|
}, [dateRange, selectedStocks, timePeriod, processStockData]);
|
||||||
|
|
||||||
// Calculate performance metrics for each stock with best/worst year
|
// Calculate performance metrics for each stock with best/worst year
|
||||||
const calculatePerformanceMetrics = useCallback((stock: Asset) => {
|
const calculatePerformanceMetrics = useCallback((stock: Asset) => {
|
||||||
|
@ -673,6 +690,11 @@ const StockExplorer = () => {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Made with Love Badge */}
|
||||||
|
<div className="text-center py-4 text-gray-500 dark:text-gray-400 text-sm">
|
||||||
|
Made with ❤️ by <a href="https://github.com/0xroko" className="text-blue-500 hover:underline">0xroko</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -88,7 +88,7 @@ export const searchAssets = async (query: string, equityType: string): Promise<A
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getHistoricalData = async (symbol: string, startDate: Date, endDate: Date) => {
|
export const getHistoricalData = async (symbol: string, startDate: Date, endDate: Date, interval: string = "1d") => {
|
||||||
try {
|
try {
|
||||||
const start = Math.floor(startDate.getTime() / 1000);
|
const start = Math.floor(startDate.getTime() / 1000);
|
||||||
const end = Math.floor(endDate.getTime() / 1000);
|
const end = Math.floor(endDate.getTime() / 1000);
|
||||||
|
@ -96,19 +96,21 @@ export const getHistoricalData = async (symbol: string, startDate: Date, endDate
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
period1: start.toString(),
|
period1: start.toString(),
|
||||||
period2: end.toString(),
|
period2: end.toString(),
|
||||||
interval: '1d',
|
interval: interval,
|
||||||
});
|
});
|
||||||
|
|
||||||
const url = `${API_BASE}/v8/finance/chart/${symbol}${!isDev ? encodeURIComponent(`?${params}`) : `?${params}`}`;
|
const url = `${API_BASE}/v8/finance/chart/${symbol}${!isDev ? encodeURIComponent(`?${params}`) : `?${params}`}`;
|
||||||
const response = await fetch(url);
|
const response = await fetch(url);
|
||||||
if (!response.ok) throw new Error('Network response was not ok');
|
if (!response.ok) throw new Error(`Network response was not ok (${response.status} - ${response.statusText} - ${await response.text().catch(() => 'No text')})`);
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
const { timestamp, indicators, meta } = data.chart.result[0] as YahooChartResult;
|
const { timestamp, indicators, meta } = data.chart.result[0] as YahooChartResult;
|
||||||
const quotes = indicators.quote[0];
|
const quotes = indicators.quote[0];
|
||||||
|
|
||||||
|
const lessThenADay = ["60m", "1h", "90m", "45m", "30m", "15m", "5m", "2m", "1m"].includes(interval);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
historicalData: new Map(timestamp.map((time: number, index: number) => [formatDateToISO(new Date(time * 1000)), quotes.close[index]])),
|
historicalData: new Map(timestamp.map((time: number, index: number) => [formatDateToISO(new Date(time * 1000), lessThenADay), quotes.close[index]])),
|
||||||
longName: meta.longName
|
longName: meta.longName
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
19
src/utils/calculations/intervalBasedOnDateRange.ts
Normal file
19
src/utils/calculations/intervalBasedOnDateRange.ts
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
import { DateRange } from "../../types";
|
||||||
|
|
||||||
|
// const validIntervals = [ "1m", "2m", "5m", "15m", "30m", "60m", "90m", "1h", "1d", "5d", "1wk", "1mo", "3mo" ];
|
||||||
|
|
||||||
|
export const intervalBasedOnDateRange = (dateRange: DateRange, withSubDays: boolean = false) => {
|
||||||
|
const { startDate, endDate } = dateRange;
|
||||||
|
const diffTime = Math.abs(endDate.getTime() - startDate.getTime());
|
||||||
|
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
||||||
|
// if diffDays is sub 1 year, it should be 1d, if it's 1-2 years it should be 2d, if it's 2-3 years it should be 3days, and so on
|
||||||
|
const oneYear = 360;
|
||||||
|
if(withSubDays && diffDays <= 60) return "60m";
|
||||||
|
if(withSubDays && diffDays > 60 && diffDays < 100) return "1h";
|
||||||
|
if(diffDays < oneYear * 2.5) return "1d";
|
||||||
|
if(diffDays < oneYear * 6 && diffDays >= oneYear * 2.5) return "5d";
|
||||||
|
if(diffDays < oneYear * 15 && diffDays >= oneYear * 6) return "1wk";
|
||||||
|
if(diffDays >= oneYear * 30) return "1mo";
|
||||||
|
return "1d";
|
||||||
|
}
|
||||||
|
|
|
@ -90,7 +90,7 @@ export const calculatePortfolioValue = (assets: Asset[], dateRange: DateRange) =
|
||||||
(dayData) => {
|
(dayData) => {
|
||||||
const vals = Object.values(dayData.assets);
|
const vals = Object.values(dayData.assets);
|
||||||
// Keep days where at least one asset has data
|
// Keep days where at least one asset has data
|
||||||
return vals.length > 0 && vals.some(value => value > 0);
|
return vals.some(value => value > 0);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -37,5 +37,5 @@ export const getHexColor = (usedColors: Set<string>, isDarkMode: boolean): strin
|
||||||
return `#${Math.floor(Math.random() * 16777215).toString(16)}`;
|
return `#${Math.floor(Math.random() * 16777215).toString(16)}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const formatDateToISO = (date: Date) => formatDate(date, 'yyyy-MM-dd');
|
export const formatDateToISO = (date: Date, lessThenADay: boolean = false) => lessThenADay ? formatDate(date, 'yyyy-MM-dd_HH:mm') : formatDate(date, 'yyyy-MM-dd');
|
||||||
export const isValidDate = (dateString: string) => isValid(parseISO(dateString));
|
export const isValidDate = (dateString: string) => isValid(parseISO(dateString));
|
Loading…
Add table
Reference in a new issue