From 304471c314f8cedd6c5978797bae0783540f072e Mon Sep 17 00:00:00 2001 From: tomato6966 Date: Sat, 8 Mar 2025 13:41:46 +0100 Subject: [PATCH] add improvements for speed and responsiveness --- src/components/Chart/ChartContent.tsx | 375 +++++++++++------- src/components/Chart/ChartLegend.tsx | 119 ++++-- src/components/Landing/AppShell.tsx | 73 +++- src/components/Modals/AddAssetModal.tsx | 34 +- src/components/PortfolioChart.tsx | 218 +++++++--- src/components/PortfolioTable.tsx | 82 +++- src/components/utils/DateRangePicker.tsx | 134 ++++--- src/components/utils/IsMobile.tsx | 22 + src/main.tsx | 2 +- src/pages/StockExplorer.tsx | 82 ++-- src/services/yahooFinanceService.ts | 10 +- .../calculations/intervalBasedOnDateRange.ts | 19 + src/utils/calculations/portfolioValue.ts | 2 +- src/utils/formatters.ts | 4 +- 14 files changed, 821 insertions(+), 355 deletions(-) create mode 100644 src/components/utils/IsMobile.tsx create mode 100644 src/utils/calculations/intervalBasedOnDateRange.ts diff --git a/src/components/Chart/ChartContent.tsx b/src/components/Chart/ChartContent.tsx index dfdae2b..032c283 100644 --- a/src/components/Chart/ChartContent.tsx +++ b/src/components/Chart/ChartContent.tsx @@ -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; @@ -23,16 +21,14 @@ interface ChartContentProps { assetColors: Record; 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) => ( - <> -
- handleUpdateDateRange({ ...dateRange, startDate: date })} - onEndDateChange={(date) => handleUpdateDateRange({ ...dateRange, endDate: date })} - /> -
- - -
-
-
- - - - format(new Date(date), 'dd.MM.yyyy')} - /> - `${value.toFixed(2)}€`} - /> - `${value.toFixed(2)}%`} - /> - { - 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')} - /> - } /> - - - - {assets.map((asset) => ( - + {!isFullscreen && ( +
+
+ - ))} - - - -
- - *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. - -

- **Note: The % is based on daily weighted average data, thus the percentages might alter slightly. -

- -); +
+
+ +
+
+ )} +
+ + + + { + 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' }} + /> + { + 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} + /> + `${value.toFixed(0)}%`} + tick={{ fontSize: window.innerWidth < 768 ? 9 : 12 }} + width={window.innerWidth < 480 ? 25 : 35} + tickCount={window.innerWidth < 768 ? 5 : 8} + /> + { + // 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 && ( + } /> + )} + + {/* Lines remain mostly the same, but with adjusted stroke width for mobile */} + + + + {assets.map((asset) => ( + + ))} + + + +
+ {!isFullscreen && ( +
+

+ *Note: Left axis shows portfolio value/invested capital, right axis shows percentage gains/losses. +

+

+ **Percentages based on daily weighted average data. +

+
+ )} + + ); +}; diff --git a/src/components/Chart/ChartLegend.tsx b/src/components/Chart/ChartLegend.tsx index ef1f330..3e3670f 100644 --- a/src/components/Chart/ChartLegend.tsx +++ b/src/components/Chart/ChartLegend.tsx @@ -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; toggleAsset: (assetId: string) => void; toggleAllAssets: () => void; - removeAsset?: (assetId: string) => void; + isCompact?: boolean; + assetColors?: Record; + 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 ( -
+
@@ -25,55 +75,52 @@ export const ChartLegend = memo(({ payload, hideAssets, hiddenAssets, toggleAsse {hideAssets ? ( <> - Show All + {!isCompact && "Show All"} ) : ( <> - Hide All + {!isCompact && "Hide All"} )}
-
- {payload.map((entry: any, index: number) => { +
+ {legendItems.map((entry: any, index: number) => { + if (!entry.dataKey) { + return null; + } + const assetId = entry.dataKey.split('_')[0]; const isHidden = hideAssets || hiddenAssets.has(assetId); + return ( -
+
- - {removeAsset && !['total', 'invested', 'percentageChange', 'ttwor'].includes(assetId) && ( - - )}
); })} diff --git a/src/components/Landing/AppShell.tsx b/src/components/Landing/AppShell.tsx index 85c7090..5c77264 100644 --- a/src/components/Landing/AppShell.tsx +++ b/src/components/Landing/AppShell.tsx @@ -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 (
-
+
-
-

Portfolio Simulator

-
+ {/* Desktop Header */} +
+

Portfolio Simulator

+
+ + + Stock Explorer + +
+
+ + {/* Mobile Header */} +
+

Portfolio Simulator

+
+ + +
+
+ + {/* Mobile Menu */} + {mobileMenuOpen && ( +
+ Stock Explorer
-
+ )} + {children}
@@ -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 by Tomato6966 + Built with by Tomato6966
diff --git a/src/components/Modals/AddAssetModal.tsx b/src/components/Modals/AddAssetModal.tsx index cf79431..15ebb54 100644 --- a/src/components/Modals/AddAssetModal.tsx +++ b/src/components/Modals/AddAssetModal.tsx @@ -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 }) {
) : ( searchResults.map((result) => ( - +
+ + +
+
)) )}
diff --git a/src/components/PortfolioChart.tsx b/src/components/PortfolioChart.tsx index 9cb7ec7..168421c 100644 --- a/src/components/PortfolioChart.tsx +++ b/src/components/PortfolioChart.tsx @@ -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>(new Set()); + const [isFullscreen, setIsFullscreen] = useState(false); + const [hideAssets, setHideAssets] = useState(false); + const [hiddenAssets, setHiddenAssets] = useState>(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 = useMemo(() => { const usedColors = new Set(); 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 ( -
-
-

Portfolio Chart

- +
+
+

Portfolio Chart

+
+ {isMobile && ( + + )} + +
- + {(showLegendAndDateRange && isMobile) && ( + <> + {/* Legend and Date-Range as a full-screen modal */} +
+
+

Legend & Date-Range

+ +
+
+
+ +
+ +
+
+ + )} +
+
+ +
+ {!isMobile && ( +
+
+ +
+ +
+ )} +
+ {showControls && ( +
+
+

Chart Controls

+ +
+
+ + { + 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} + /> +
+
+ )} + {isHistoricalLoading && ( +
+ +
+ )}
); } return ( -
+
+ {isHistoricalLoading && ( +
+ +
+ )}
); }; diff --git a/src/components/PortfolioTable.tsx b/src/components/PortfolioTable.tsx index dd3d7a6..abe7b27 100644 --- a/src/components/PortfolioTable.tsx +++ b/src/components/PortfolioTable.tsx @@ -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" > -

{asset.name}

+
+

{asset.name}

+
+ +
+
@@ -598,6 +606,76 @@ export default memo(function PortfolioTable() { onClose={() => setShowPortfolioPerformance(false)} /> )} + +
+
+

Assets Performance Overview

+
+ +
+ {assets.map(asset => { + const assetPerformance = calculateInvestmentPerformance([asset]); + const totalInvested = assetPerformance.summary.totalInvested; + const currentValue = assetPerformance.summary.currentValue; + const performancePercent = assetPerformance.summary.performancePercentage; + + return ( +
+
+

+ {asset.name} +

+ +
+ +
+
+
Invested
+
€{totalInvested.toFixed(2)}
+
+
+
Current
+
€{currentValue.toFixed(2)}
+
+
+
Performance
+
= 0 ? 'text-green-500' : 'text-red-500'}> + {performancePercent.toFixed(2)}% +
+
+
+
Positions
+
{asset.investments.length}
+
+
+ + +
+ ); + })} +
+
); }); diff --git a/src/components/utils/DateRangePicker.tsx b/src/components/utils/DateRangePicker.tsx index 65e84d8..377b136 100644 --- a/src/components/utils/DateRangePicker.tsx +++ b/src/components/utils/DateRangePicker.tsx @@ -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(null); - const endDateRef = useRef(null); +export const DateRangePicker = ({ startDate, endDate, onDateRangeChange }: DateRangePickerProps) => { + const [localStartDate, setLocalStartDate] = useState(startDate); + const [localEndDate, setLocalEndDate] = useState(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) => { + 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) => { + 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 ( -
-
-
+ + {/* Made with Love Badge */} +
+ Made with ❤️ by 0xroko +
); }; diff --git a/src/services/yahooFinanceService.ts b/src/services/yahooFinanceService.ts index 332da1c..b762c8f 100644 --- a/src/services/yahooFinanceService.ts +++ b/src/services/yahooFinanceService.ts @@ -88,7 +88,7 @@ export const searchAssets = async (query: string, equityType: string): Promise { +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) { diff --git a/src/utils/calculations/intervalBasedOnDateRange.ts b/src/utils/calculations/intervalBasedOnDateRange.ts new file mode 100644 index 0000000..bb7e446 --- /dev/null +++ b/src/utils/calculations/intervalBasedOnDateRange.ts @@ -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"; +} + diff --git a/src/utils/calculations/portfolioValue.ts b/src/utils/calculations/portfolioValue.ts index 6291363..4da4b55 100644 --- a/src/utils/calculations/portfolioValue.ts +++ b/src/utils/calculations/portfolioValue.ts @@ -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); } ); }; diff --git a/src/utils/formatters.ts b/src/utils/formatters.ts index 4880e92..2ac2300 100644 --- a/src/utils/formatters.ts +++ b/src/utils/formatters.ts @@ -37,5 +37,5 @@ export const getHexColor = (usedColors: Set, 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)); \ No newline at end of file