add improvements for speed and responsiveness

This commit is contained in:
tomato6966 2025-03-08 13:41:46 +01:00
parent 292fc08b6c
commit 304471c314
14 changed files with 821 additions and 355 deletions

View file

@ -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>
)}
</>
);
};

View file

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

View file

@ -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>

View file

@ -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 &amp; Add Another
</button>
</div>
</div>
))
)}
</div>

View file

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

View file

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

View file

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

View 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;
}

View file

@ -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(

View file

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

View file

@ -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) {

View 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";
}

View file

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

View file

@ -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));