mirror of
https://github.com/Tomato6966/investment-portfolio-simulator.git
synced 2025-04-07 12:00:35 +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 {
|
||||
CartesianGrid, Legend, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis
|
||||
|
@ -11,10 +11,8 @@ import { ChartLegend } from "./ChartLegend";
|
|||
interface ChartContentProps {
|
||||
dateRange: DateRange;
|
||||
handleUpdateDateRange: (range: DateRange) => void;
|
||||
handleReRender: () => void;
|
||||
isFullscreen: boolean;
|
||||
setIsFullscreen: (value: boolean) => void;
|
||||
renderKey: number;
|
||||
isDarkMode: boolean;
|
||||
hideAssets: boolean;
|
||||
hiddenAssets: Set<string>;
|
||||
|
@ -23,16 +21,14 @@ interface ChartContentProps {
|
|||
assetColors: Record<string, string>;
|
||||
toggleAsset: (assetId: string) => void;
|
||||
toggleAllAssets: () => void;
|
||||
removeAsset?: (assetId: string) => void;
|
||||
isMobile: boolean;
|
||||
}
|
||||
|
||||
export const ChartContent = ({
|
||||
dateRange,
|
||||
handleUpdateDateRange,
|
||||
handleReRender,
|
||||
isFullscreen,
|
||||
setIsFullscreen,
|
||||
renderKey,
|
||||
isDarkMode,
|
||||
hideAssets,
|
||||
hiddenAssets,
|
||||
|
@ -41,147 +37,230 @@ export const ChartContent = ({
|
|||
assetColors,
|
||||
toggleAsset,
|
||||
toggleAllAssets,
|
||||
removeAsset
|
||||
}: ChartContentProps) => (
|
||||
<>
|
||||
<div className="flex justify-between items-center mb-4 p-5">
|
||||
<DateRangePicker
|
||||
startDate={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`;
|
||||
isMobile,
|
||||
}: ChartContentProps) => {
|
||||
// Calculate tick interval dynamically to prevent overlapping
|
||||
const getXAxisInterval = () => {
|
||||
const width = window.innerWidth;
|
||||
const dayDifference = differenceInDays(dateRange.endDate, dateRange.startDate);
|
||||
|
||||
if (width < 480) {
|
||||
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 === "avg. Portfolio % gain")
|
||||
return [`${value.toFixed(2)}%`, name];
|
||||
|
||||
if (name === "TTWOR")
|
||||
return [`${value.toLocaleString()}€ (${item.payload["ttwor_percent"].toFixed(2)}%)`, name];
|
||||
|
||||
if (name === "Portfolio-Value" || name === "Invested Capital")
|
||||
return [`${value.toLocaleString()}€`, name];
|
||||
|
||||
if (name.includes("(%)"))
|
||||
return [`${Number(item.payload[processedKey]).toFixed(2)}€ ${value.toFixed(2)}%`, name.replace(" (%)", "")];
|
||||
|
||||
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}
|
||||
return (
|
||||
<>
|
||||
{!isFullscreen && (
|
||||
<div className="flex flex-col sm:flex-row justify-between items-start mb-4 p-3 sm:p-5 gap-2">
|
||||
<div className="w-full">
|
||||
<DateRangePicker
|
||||
startDate={dateRange.startDate}
|
||||
endDate={dateRange.endDate}
|
||||
onDateRangeChange={handleUpdateDateRange}
|
||||
/>
|
||||
))}
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="percentageChange"
|
||||
hide={hideAssets || hiddenAssets.has("percentageChange")}
|
||||
dot={false}
|
||||
name="avg. Portfolio % gain"
|
||||
stroke="#a0a0a0"
|
||||
yAxisId="right"
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<i className="text-xs text-gray-500">
|
||||
*Note: The YAxis on the left shows the value of your portfolio (black line) and invested capital (dotted line),
|
||||
all other assets are scaled by their % gain/loss and thus scaled to the right YAxis.
|
||||
</i>
|
||||
<p className="text-xs mt-2 text-gray-500 italic">
|
||||
**Note: The % is based on daily weighted average data, thus the percentages might alter slightly.
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
</div>
|
||||
<div className="absolute right-0 top-0">
|
||||
<button
|
||||
onClick={() => setIsFullscreen(!isFullscreen)}
|
||||
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded hover:text-blue-500"
|
||||
>
|
||||
<Maximize2 className="w-4 h-4 sm:w-5 sm:h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className={isFullscreen ? "h-[calc(100vh-40px)]" : "h-[300px] sm:h-[350px] md:h-[400px]"}>
|
||||
<ResponsiveContainer>
|
||||
<LineChart
|
||||
data={processedData}
|
||||
margin={{
|
||||
top: 5,
|
||||
right: window.innerWidth < 768 ? 15 : 30,
|
||||
left: window.innerWidth < 768 ? 5 : 20,
|
||||
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 { Asset } from "../../types";
|
||||
|
||||
interface ChartLegendProps {
|
||||
payload: any[];
|
||||
|
@ -7,12 +8,61 @@ interface ChartLegendProps {
|
|||
hiddenAssets: Set<string>;
|
||||
toggleAsset: (assetId: string) => 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 (
|
||||
<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 gap-1">
|
||||
<BarChart2 className="w-4 h-4 text-gray-500" />
|
||||
|
@ -25,55 +75,52 @@ export const ChartLegend = memo(({ payload, hideAssets, hiddenAssets, toggleAsse
|
|||
{hideAssets ? (
|
||||
<>
|
||||
<Eye className="w-4 h-4" />
|
||||
Show All
|
||||
{!isCompact && "Show All"}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<EyeOff className="w-4 h-4" />
|
||||
Hide All
|
||||
{!isCompact && "Hide All"}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
{payload.map((entry: any, index: number) => {
|
||||
<div className={`flex ${isCompact ? 'flex-col' : 'flex-wrap'} ${isCompact ? 'gap-1' : 'gap-4'}`}>
|
||||
{legendItems.map((entry: any, index: number) => {
|
||||
if (!entry.dataKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const assetId = entry.dataKey.split('_')[0];
|
||||
const isHidden = hideAssets || hiddenAssets.has(assetId);
|
||||
|
||||
return (
|
||||
<div key={`asset-${index}`} className="flex items-center">
|
||||
<div key={`asset-${index}`} className={`flex items-center ${isCompact ? 'w-full' : ''}`}>
|
||||
<button
|
||||
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' : ''
|
||||
} 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="w-8 h-[3px]"
|
||||
style={{ backgroundColor: entry.color }}
|
||||
/>
|
||||
<span className="text-sm">{entry.value.replace(' (%)', '')}</span>
|
||||
{isHidden ? (
|
||||
<Eye className="w-3 h-3 text-gray-400 dark:text-gray-600" />
|
||||
) : (
|
||||
<EyeOff className="w-3 h-3 text-gray-400 dark:text-gray-600" />
|
||||
)}
|
||||
<div className="flex items-center w-full justify-between">
|
||||
<div className="flex items-center space-x-2 pr-2">
|
||||
<div
|
||||
className={`w-4 h-2 rounded-full`}
|
||||
style={{ backgroundColor: entry.color }}
|
||||
/>
|
||||
<span className={isCompact ? 'text-xs' : 'text-sm'}>
|
||||
{entry.value.replace(' (%)', '')}
|
||||
</span>
|
||||
</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>
|
||||
</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>
|
||||
);
|
||||
})}
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { BarChart2, Heart, Moon, Plus, Sun } from "lucide-react";
|
||||
import React from "react";
|
||||
import { BarChart2, CircleChevronDown, CircleChevronUp, Heart, Menu, Moon, Plus, Sun, X } from "lucide-react";
|
||||
import React, { useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
import { useDarkMode } from "../../hooks/useDarkMode";
|
||||
|
@ -11,14 +11,16 @@ interface AppShellProps {
|
|||
|
||||
export const AppShell = ({ children, onAddAsset }: AppShellProps) => {
|
||||
const { isDarkMode, toggleDarkMode } = useDarkMode();
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(true);
|
||||
|
||||
return (
|
||||
<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="flex justify-between items-center mb-8">
|
||||
<h1 className="text-2xl font-bold dark:text-white">Portfolio Simulator</h1>
|
||||
<div className="flex gap-4">
|
||||
{/* Desktop Header */}
|
||||
<div className="hidden md:flex justify-between items-center mb-6 md:mb-8">
|
||||
<h1 className="text-xl sm:text-2xl font-bold dark:text-white">Portfolio Simulator</h1>
|
||||
<div className="flex gap-2 md:gap-4">
|
||||
<button
|
||||
onClick={toggleDarkMode}
|
||||
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
|
||||
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" />
|
||||
Add Asset
|
||||
</button>
|
||||
<Link
|
||||
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" />
|
||||
Stock Explorer
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{children}
|
||||
</div>
|
||||
|
||||
|
@ -53,9 +104,9 @@ export const AppShell = ({ children, onAddAsset }: AppShellProps) => {
|
|||
href="https://github.com/Tomato6966/investment-portfolio-simulator"
|
||||
target="_blank"
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -6,6 +6,7 @@ import { useDebouncedCallback } from "use-debounce";
|
|||
import { usePortfolioSelector } from "../../hooks/usePortfolio";
|
||||
import { EQUITY_TYPES, getHistoricalData, searchAssets } from "../../services/yahooFinanceService";
|
||||
import { Asset } from "../../types";
|
||||
import { intervalBasedOnDateRange } from "../../utils/calculations/intervalBasedOnDateRange";
|
||||
|
||||
export default function AddAssetModal({ onClose }: { onClose: () => void }) {
|
||||
const [ search, setSearch ] = useState('');
|
||||
|
@ -35,14 +36,15 @@ export default function AddAssetModal({ onClose }: { onClose: () => void }) {
|
|||
|
||||
const debouncedSearch = useDebouncedCallback(handleSearch, 750);
|
||||
|
||||
const handleAssetSelect = (asset: Asset) => {
|
||||
const handleAssetSelect = (asset: Asset, keepOpen: boolean = false) => {
|
||||
setLoading("adding");
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
const { historicalData, longName } = await getHistoricalData(
|
||||
asset.symbol,
|
||||
dateRange.startDate,
|
||||
dateRange.endDate
|
||||
dateRange.endDate,
|
||||
intervalBasedOnDateRange(dateRange),
|
||||
);
|
||||
|
||||
if (historicalData.size === 0) {
|
||||
|
@ -58,7 +60,12 @@ export default function AddAssetModal({ onClose }: { onClose: () => void }) {
|
|||
|
||||
addAsset(assetWithHistory);
|
||||
toast.success(`Successfully added ${assetWithHistory.name}`);
|
||||
onClose();
|
||||
if (!keepOpen) {
|
||||
onClose();
|
||||
} else {
|
||||
setSearch("");
|
||||
setSearchResults([]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching historical data:', error);
|
||||
toast.error(`Failed to add ${asset.name}. Please try again.`);
|
||||
|
@ -112,10 +119,9 @@ export default function AddAssetModal({ onClose }: { onClose: () => void }) {
|
|||
</div>
|
||||
) : (
|
||||
searchResults.map((result) => (
|
||||
<button
|
||||
<div
|
||||
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"
|
||||
onClick={() => handleAssetSelect(result)}
|
||||
>
|
||||
<div className="font-medium flex justify-between">
|
||||
<span>{result.name}</span>
|
||||
|
@ -129,7 +135,23 @@ export default function AddAssetModal({ onClose }: { onClose: () => void }) {
|
|||
<div className="text-sm text-gray-600">
|
||||
Ticker-Symbol: {result.symbol} | Type: {result.quoteType?.toUpperCase() || "Unknown"} | Rank: #{result.rank || "-"}
|
||||
</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>
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
import { format } from "date-fns";
|
||||
import { X } from "lucide-react";
|
||||
import { X, ChevronDown, Loader2 } from "lucide-react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useDebouncedCallback } from "use-debounce";
|
||||
|
||||
import { useDarkMode } from "../hooks/useDarkMode";
|
||||
import { usePortfolioSelector } from "../hooks/usePortfolio";
|
||||
|
@ -10,35 +9,38 @@ import { DateRange } from "../types";
|
|||
import { calculatePortfolioValue } from "../utils/calculations/portfolioValue";
|
||||
import { getHexColor } from "../utils/formatters";
|
||||
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() {
|
||||
const [ isFullscreen, setIsFullscreen ] = useState(false);
|
||||
const [ hideAssets, setHideAssets ] = useState(false);
|
||||
const [ hiddenAssets, setHiddenAssets ] = useState<Set<string>>(new Set());
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const [hideAssets, setHideAssets] = useState(false);
|
||||
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 isMobile = useIsMobile();
|
||||
|
||||
const { assets, dateRange, updateDateRange, updateAssetHistoricalData, removeAsset } = usePortfolioSelector((state) => ({
|
||||
const { assets, dateRange, updateDateRange, updateAssetHistoricalData } = usePortfolioSelector((state) => ({
|
||||
assets: state.assets,
|
||||
dateRange: state.dateRange,
|
||||
updateDateRange: state.updateDateRange,
|
||||
updateAssetHistoricalData: state.updateAssetHistoricalData,
|
||||
removeAsset: state.removeAsset,
|
||||
}));
|
||||
|
||||
const fetchHistoricalData = useCallback(
|
||||
async (startDate: Date, endDate: Date) => {
|
||||
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);
|
||||
}
|
||||
},
|
||||
[assets, updateAssetHistoricalData]
|
||||
);
|
||||
|
||||
const debouncedFetchHistoricalData = useDebouncedCallback(fetchHistoricalData, 1500, {
|
||||
maxWait: 5000,
|
||||
});
|
||||
|
||||
const assetColors: Record<string, string> = useMemo(() => {
|
||||
const usedColors = new Set<string>();
|
||||
return assets.reduce((colors, asset) => {
|
||||
|
@ -120,60 +122,167 @@ export default function PortfolioChart() {
|
|||
}, [hideAssets]);
|
||||
|
||||
const handleUpdateDateRange = useCallback((newRange: DateRange) => {
|
||||
setIsHistoricalLoading(true);
|
||||
updateDateRange(newRange);
|
||||
debouncedFetchHistoricalData(newRange.startDate, newRange.endDate);
|
||||
}, [updateDateRange, debouncedFetchHistoricalData]);
|
||||
fetchHistoricalData(newRange.startDate, newRange.endDate)
|
||||
.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) {
|
||||
return (
|
||||
<div className="fixed inset-0 bg-white dark:bg-slate-800 z-50 overflow-y-auto">
|
||||
<div className="flex justify-between items-center mb-4 p-5">
|
||||
<h2 className="text-xl font-bold dark:text-gray-300">Portfolio Chart</h2>
|
||||
<button
|
||||
onClick={() => setIsFullscreen(false)}
|
||||
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded"
|
||||
>
|
||||
<X className="w-6 h-6 dark:text-gray-300" />
|
||||
</button>
|
||||
<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 p-2 border-b dark:border-slate-700">
|
||||
<h2 className="text-lg font-bold dark:text-gray-300">Portfolio Chart</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
{isMobile && (
|
||||
<button
|
||||
onClick={() => setShowLegendAndDateRange(!showLegendAndDateRange)}
|
||||
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"
|
||||
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>
|
||||
<ChartContent
|
||||
dateRange={dateRange}
|
||||
handleUpdateDateRange={handleUpdateDateRange}
|
||||
handleReRender={handleReRender}
|
||||
isFullscreen={isFullscreen}
|
||||
setIsFullscreen={setIsFullscreen}
|
||||
renderKey={renderKey}
|
||||
isDarkMode={isDarkMode}
|
||||
hideAssets={hideAssets}
|
||||
hiddenAssets={hiddenAssets}
|
||||
processedData={processedData}
|
||||
assets={assets}
|
||||
assetColors={assetColors}
|
||||
toggleAsset={toggleAsset}
|
||||
toggleAllAssets={toggleAllAssets}
|
||||
removeAsset={removeAsset}
|
||||
/>
|
||||
{(showLegendAndDateRange && isMobile) && (
|
||||
<>
|
||||
{/* Legend and Date-Range as a full-screen modal */}
|
||||
<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 p-2 border-b dark:border-slate-700">
|
||||
<h2 className="text-lg font-bold dark:text-gray-300">Legend & Date-Range</h2>
|
||||
<button
|
||||
onClick={() => setShowLegendAndDateRange(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 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>
|
||||
</>
|
||||
)}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
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
|
||||
dateRange={dateRange}
|
||||
handleUpdateDateRange={handleUpdateDateRange}
|
||||
handleReRender={handleReRender}
|
||||
isFullscreen={isFullscreen}
|
||||
setIsFullscreen={setIsFullscreen}
|
||||
renderKey={renderKey}
|
||||
isDarkMode={isDarkMode}
|
||||
hideAssets={hideAssets}
|
||||
hiddenAssets={hiddenAssets}
|
||||
|
@ -182,8 +291,13 @@ export default function PortfolioChart() {
|
|||
assetColors={assetColors}
|
||||
toggleAsset={toggleAsset}
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -28,10 +28,11 @@ interface SavingsPlanPerformance {
|
|||
}
|
||||
|
||||
export default memo(function PortfolioTable() {
|
||||
const { assets, removeInvestment, clearInvestments } = usePortfolioSelector((state) => ({
|
||||
const { assets, removeInvestment, clearInvestments, removeAsset } = usePortfolioSelector((state) => ({
|
||||
assets: state.assets,
|
||||
removeInvestment: state.removeInvestment,
|
||||
clearInvestments: state.clearInvestments,
|
||||
removeAsset: state.removeAsset,
|
||||
}));
|
||||
|
||||
const [editingInvestment, setEditingInvestment] = useState<{
|
||||
|
@ -240,7 +241,14 @@ export default memo(function PortfolioTable() {
|
|||
key={asset.id}
|
||||
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">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
|
@ -598,6 +606,76 @@ export default memo(function PortfolioTable() {
|
|||
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>
|
||||
);
|
||||
});
|
||||
|
|
|
@ -1,93 +1,103 @@
|
|||
import { useRef } from "react";
|
||||
import { useDebouncedCallback } from "use-debounce";
|
||||
import { useState, useEffect } from "react";
|
||||
import { format } from "date-fns";
|
||||
import { Check } from "lucide-react";
|
||||
|
||||
import { useLocaleDateFormat } from "../../hooks/useLocalDateFormat";
|
||||
import { formatDateToISO, isValidDate } from "../../utils/formatters";
|
||||
import { DateRange } from "../../types";
|
||||
import { intervalBasedOnDateRange } from "../../utils/calculations/intervalBasedOnDateRange";
|
||||
|
||||
interface DateRangePickerProps {
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
onStartDateChange: (date: Date) => void;
|
||||
onEndDateChange: (date: Date) => void;
|
||||
onDateRangeChange: (dateRange: DateRange) => void;
|
||||
}
|
||||
|
||||
export const DateRangePicker = ({
|
||||
startDate,
|
||||
endDate,
|
||||
onStartDateChange,
|
||||
onEndDateChange,
|
||||
}: DateRangePickerProps) => {
|
||||
const startDateRef = useRef<HTMLInputElement>(null);
|
||||
const endDateRef = useRef<HTMLInputElement>(null);
|
||||
export const DateRangePicker = ({ startDate, endDate, onDateRangeChange }: DateRangePickerProps) => {
|
||||
const [localStartDate, setLocalStartDate] = useState<Date>(startDate);
|
||||
const [localEndDate, setLocalEndDate] = useState<Date>(endDate);
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
const [startDateText, setStartDateText] = useState(format(startDate, 'yyyy-MM-dd'));
|
||||
const [endDateText, setEndDateText] = useState(format(endDate, 'yyyy-MM-dd'));
|
||||
|
||||
const localeDateFormat = useLocaleDateFormat();
|
||||
|
||||
// Update local state when props change
|
||||
useEffect(() => {
|
||||
setLocalStartDate(startDate);
|
||||
setLocalEndDate(endDate);
|
||||
setStartDateText(format(startDate, 'yyyy-MM-dd'));
|
||||
setEndDateText(format(endDate, 'yyyy-MM-dd'));
|
||||
setHasChanges(false);
|
||||
}, [startDate, endDate]);
|
||||
|
||||
const debouncedStartDateChange = useDebouncedCallback(
|
||||
(dateString: string) => {
|
||||
if (isValidDate(dateString)) {
|
||||
const newDate = new Date(dateString);
|
||||
|
||||
if (newDate.getTime() !== startDate.getTime()) {
|
||||
onStartDateChange(newDate);
|
||||
}
|
||||
const handleLocalStartDateChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const dateValue = e.target.value;
|
||||
setStartDateText(dateValue);
|
||||
|
||||
try {
|
||||
const newDate = new Date(dateValue);
|
||||
if (!isNaN(newDate.getTime())) {
|
||||
setLocalStartDate(newDate);
|
||||
setHasChanges(true);
|
||||
}
|
||||
},
|
||||
750
|
||||
);
|
||||
|
||||
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);
|
||||
} catch (error) {
|
||||
console.error("Invalid date format", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEndDateChange = () => {
|
||||
if (endDateRef.current) {
|
||||
debouncedEndDateChange(endDateRef.current.value);
|
||||
const handleLocalEndDateChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const dateValue = e.target.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 (
|
||||
<div className="flex gap-4 items-center mb-4 dark:text-gray-300">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">
|
||||
From {localeDateFormat && <span className="text-xs text-gray-500">({localeDateFormat})</span>}
|
||||
<div className="flex flex-wrap items-end gap-2">
|
||||
<div className="flex-1">
|
||||
<label className="block text-sm text-gray-600 dark:text-gray-400 mb-1">
|
||||
Start Date <p className="text-xs text-gray-500">({localeDateFormat})</p>
|
||||
</label>
|
||||
<input
|
||||
ref={startDateRef}
|
||||
type="date"
|
||||
defaultValue={formatDateToISO(startDate)}
|
||||
onChange={handleStartDateChange}
|
||||
max={formatDateToISO(endDate)}
|
||||
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"
|
||||
value={startDateText}
|
||||
onChange={handleLocalStartDateChange}
|
||||
className="border p-2 rounded dark:bg-slate-700 dark:text-white dark:border-slate-600 w-full"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">
|
||||
To {localeDateFormat && <span className="text-xs text-gray-500">({localeDateFormat})</span>}
|
||||
<div className="flex-1">
|
||||
<label className="block text-sm text-gray-600 dark:text-gray-400 mb-1">
|
||||
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>
|
||||
<input
|
||||
ref={endDateRef}
|
||||
type="date"
|
||||
defaultValue={formatDateToISO(endDate)}
|
||||
onChange={handleEndDateChange}
|
||||
min={formatDateToISO(startDate)}
|
||||
max={formatDateToISO(new Date())}
|
||||
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"
|
||||
value={endDateText}
|
||||
onChange={handleLocalEndDateChange}
|
||||
max={format(new Date(), 'yyyy-MM-dd')}
|
||||
className="border p-2 rounded dark:bg-slate-700 dark:text-white dark:border-slate-600 w-full"
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
|
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
|
||||
const router = createBrowserRouter(App, {
|
||||
basename: "/investment-portfolio-simulator"
|
||||
basename: "/"
|
||||
});
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
|
|
|
@ -11,7 +11,7 @@ import { useDarkMode } from "../hooks/useDarkMode";
|
|||
import { EQUITY_TYPES, getHistoricalData, searchAssets } from "../services/yahooFinanceService";
|
||||
import { Asset } from "../types";
|
||||
import { getHexColor } from "../utils/formatters";
|
||||
|
||||
import { intervalBasedOnDateRange } from "../utils/calculations/intervalBasedOnDateRange";
|
||||
// Time period options
|
||||
const TIME_PERIODS = {
|
||||
YTD: "Year to Date",
|
||||
|
@ -119,10 +119,16 @@ const StockExplorer = () => {
|
|||
|
||||
setLoading(true);
|
||||
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(
|
||||
stock.symbol,
|
||||
dateRange.startDate,
|
||||
dateRange.endDate
|
||||
optimizedStartDate,
|
||||
dateRange.endDate,
|
||||
intervalBasedOnDateRange({ startDate: optimizedStartDate, endDate: dateRange.endDate })
|
||||
);
|
||||
|
||||
if (historicalData.size === 0) {
|
||||
|
@ -148,7 +154,6 @@ const StockExplorer = () => {
|
|||
});
|
||||
|
||||
// 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));
|
||||
|
||||
toast.success(`Added ${stockWithHistory.name} to comparison`);
|
||||
|
@ -158,7 +163,7 @@ const StockExplorer = () => {
|
|||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [dateRange, isDarkMode, selectedStocks]);
|
||||
}, [dateRange, isDarkMode, selectedStocks, timePeriod]);
|
||||
|
||||
// Remove stock from comparison
|
||||
const removeStock = useCallback((stockId: string) => {
|
||||
|
@ -259,40 +264,52 @@ const StockExplorer = () => {
|
|||
// Refresh stock data when stocks or date range changes
|
||||
const refreshStockData = useCallback(async () => {
|
||||
if (selectedStocks.length === 0) return;
|
||||
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
// Fetch updated data for each stock
|
||||
const updatedStocks = await Promise.all(
|
||||
selectedStocks.map(async stock => {
|
||||
const { historicalData, longName } = await getHistoricalData(
|
||||
stock.symbol,
|
||||
dateRange.startDate,
|
||||
dateRange.endDate
|
||||
);
|
||||
|
||||
return {
|
||||
...stock,
|
||||
name: longName || stock.name,
|
||||
historicalData
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
// Update chart data
|
||||
setStockData(processStockData(updatedStocks));
|
||||
|
||||
// Unconditionally update selectedStocks so the table refreshes
|
||||
// Process in batches for better performance
|
||||
const batchSize = 3;
|
||||
const batches = [];
|
||||
|
||||
for (let i = 0; i < selectedStocks.length; i += batchSize) {
|
||||
batches.push(selectedStocks.slice(i, i + batchSize));
|
||||
}
|
||||
|
||||
const updatedStocks = [...selectedStocks];
|
||||
|
||||
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);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
setSelectedStocks(updatedStocks);
|
||||
|
||||
toast.success("Stock data refreshed");
|
||||
processStockData(updatedStocks);
|
||||
|
||||
} catch (error) {
|
||||
console.error("Error refreshing data:", error);
|
||||
toast.error("Failed to refresh stock data");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [dateRange, processStockData]);
|
||||
}, [dateRange, selectedStocks, timePeriod, processStockData]);
|
||||
|
||||
// Calculate performance metrics for each stock with best/worst year
|
||||
const calculatePerformanceMetrics = useCallback((stock: Asset) => {
|
||||
|
@ -673,6 +690,11 @@ const StockExplorer = () => {
|
|||
</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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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 {
|
||||
const start = Math.floor(startDate.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({
|
||||
period1: start.toString(),
|
||||
period2: end.toString(),
|
||||
interval: '1d',
|
||||
interval: interval,
|
||||
});
|
||||
|
||||
const url = `${API_BASE}/v8/finance/chart/${symbol}${!isDev ? encodeURIComponent(`?${params}`) : `?${params}`}`;
|
||||
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 { timestamp, indicators, meta } = data.chart.result[0] as YahooChartResult;
|
||||
const quotes = indicators.quote[0];
|
||||
|
||||
const lessThenADay = ["60m", "1h", "90m", "45m", "30m", "15m", "5m", "2m", "1m"].includes(interval);
|
||||
|
||||
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
|
||||
}
|
||||
} 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) => {
|
||||
const vals = Object.values(dayData.assets);
|
||||
// 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)}`;
|
||||
};
|
||||
|
||||
export const formatDateToISO = (date: Date) => formatDate(date, 'yyyy-MM-dd');
|
||||
export const isValidDate = (dateString: string) => isValid(parseISO(dateString));
|
||||
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));
|
Loading…
Add table
Reference in a new issue