|
@@ -0,0 +1,274 @@
|
|
|
|
|
+<script>
|
|
|
|
|
+ import { onMount } from 'svelte';
|
|
|
|
|
+ import { Chart, registerables } from 'chart.js';
|
|
|
|
|
+ import { getRequest } from '../utils/api.js';
|
|
|
|
|
+ import { fade } from 'svelte/transition';
|
|
|
|
|
+
|
|
|
|
|
+ Chart.register(...registerables);
|
|
|
|
|
+
|
|
|
|
|
+ export let portfolioId;
|
|
|
|
|
+ export let authToken;
|
|
|
|
|
+ export let currency = localStorage.getItem('defaultCurrency') || 'USD';
|
|
|
|
|
+
|
|
|
|
|
+ let chartCanvas;
|
|
|
|
|
+ let chartInstance;
|
|
|
|
|
+ let isLoading = true;
|
|
|
|
|
+ let historyData = [];
|
|
|
|
|
+ let currentValue = null;
|
|
|
|
|
+ let valueChange = null;
|
|
|
|
|
+ let valueChangePercent = null;
|
|
|
|
|
+
|
|
|
|
|
+ async function fetchData() {
|
|
|
|
|
+ try {
|
|
|
|
|
+ isLoading = true;
|
|
|
|
|
+ const data = await getPortfolioHistory(portfolioId, authToken, currency);
|
|
|
|
|
+ historyData = data;
|
|
|
|
|
+
|
|
|
|
|
+ if (data.length > 0) {
|
|
|
|
|
+ // Sort by date to ensure proper order
|
|
|
|
|
+ historyData = data.sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt));
|
|
|
|
|
+
|
|
|
|
|
+ // Calculate value change
|
|
|
|
|
+ const firstValue = historyData[0].totalValue;
|
|
|
|
|
+ const lastValue = historyData[historyData.length - 1].totalValue;
|
|
|
|
|
+ currentValue = lastValue;
|
|
|
|
|
+ valueChange = lastValue - firstValue;
|
|
|
|
|
+ valueChangePercent = firstValue !== 0 ? ((lastValue - firstValue) / firstValue) * 100 : 0;
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (err) {
|
|
|
|
|
+ console.error('Error fetching portfolio history:', err);
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ isLoading = false;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ async function getPortfolioHistory(portfolioId, token, currency) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const response = await getRequest(
|
|
|
|
|
+ `${import.meta.env.VITE_STOCKS_HOST}/api/portfolios/${portfolioId}/history?currency=${currency}`,
|
|
|
|
|
+ {},
|
|
|
|
|
+ token
|
|
|
|
|
+ );
|
|
|
|
|
+
|
|
|
|
|
+ if (response.ok) {
|
|
|
|
|
+ return await response.json();
|
|
|
|
|
+ } else {
|
|
|
|
|
+ console.error('Failed to fetch portfolio history:', response.status);
|
|
|
|
|
+ return [];
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (err) {
|
|
|
|
|
+ console.error('Error fetching portfolio history:', err);
|
|
|
|
|
+ return [];
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function renderChart(data) {
|
|
|
|
|
+ if (!data || data.length === 0 || !chartCanvas) return;
|
|
|
|
|
+
|
|
|
|
|
+ const labels = data.map((item) => {
|
|
|
|
|
+ const date = new Date(item.createdAt);
|
|
|
|
|
+ const month = date.toLocaleDateString('en-US', { month: 'short' });
|
|
|
|
|
+ const year = date.getFullYear();
|
|
|
|
|
+ return `${month} ${year}`;
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ const values = data.map((item) => item.totalValue);
|
|
|
|
|
+
|
|
|
|
|
+ if (chartInstance) {
|
|
|
|
|
+ chartInstance.destroy();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const gradientFill = chartCanvas.getContext('2d').createLinearGradient(0, 0, 0, 400);
|
|
|
|
|
+ gradientFill.addColorStop(0, 'rgba(59, 130, 246, 0.3)');
|
|
|
|
|
+ gradientFill.addColorStop(1, 'rgba(59, 130, 246, 0.05)');
|
|
|
|
|
+
|
|
|
|
|
+ chartInstance = new Chart(chartCanvas, {
|
|
|
|
|
+ type: 'line',
|
|
|
|
|
+ data: {
|
|
|
|
|
+ labels,
|
|
|
|
|
+ datasets: [
|
|
|
|
|
+ {
|
|
|
|
|
+ label: `Portfolio Value (${currency})`,
|
|
|
|
|
+ data: values,
|
|
|
|
|
+ fill: true,
|
|
|
|
|
+ backgroundColor: gradientFill,
|
|
|
|
|
+ borderColor: 'rgb(59, 130, 246)',
|
|
|
|
|
+ borderWidth: 3,
|
|
|
|
|
+ pointBackgroundColor: 'rgb(59, 130, 246)',
|
|
|
|
|
+ pointBorderColor: 'white',
|
|
|
|
|
+ pointBorderWidth: 2,
|
|
|
|
|
+ pointRadius: 5,
|
|
|
|
|
+ pointHoverRadius: 7,
|
|
|
|
|
+ tension: 0.4
|
|
|
|
|
+ }
|
|
|
|
|
+ ]
|
|
|
|
|
+ },
|
|
|
|
|
+ options: {
|
|
|
|
|
+ responsive: true,
|
|
|
|
|
+ maintainAspectRatio: false,
|
|
|
|
|
+ interaction: {
|
|
|
|
|
+ intersect: false,
|
|
|
|
|
+ mode: 'index'
|
|
|
|
|
+ },
|
|
|
|
|
+ scales: {
|
|
|
|
|
+ x: {
|
|
|
|
|
+ title: {
|
|
|
|
|
+ display: true,
|
|
|
|
|
+ text: 'Date',
|
|
|
|
|
+ color: 'rgb(107, 114, 128)',
|
|
|
|
|
+ font: {
|
|
|
|
|
+ size: 12,
|
|
|
|
|
+ weight: 'bold'
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
|
|
+ grid: {
|
|
|
|
|
+ color: 'rgba(107, 114, 128, 0.1)'
|
|
|
|
|
+ },
|
|
|
|
|
+ ticks: {
|
|
|
|
|
+ color: 'rgb(107, 114, 128)'
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
|
|
+ y: {
|
|
|
|
|
+ title: {
|
|
|
|
|
+ display: true,
|
|
|
|
|
+ text: `Value (${currency})`,
|
|
|
|
|
+ color: 'rgb(107, 114, 128)',
|
|
|
|
|
+ font: {
|
|
|
|
|
+ size: 12,
|
|
|
|
|
+ weight: 'bold'
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
|
|
+ grid: {
|
|
|
|
|
+ color: 'rgba(107, 114, 128, 0.1)'
|
|
|
|
|
+ },
|
|
|
|
|
+ ticks: {
|
|
|
|
|
+ color: 'rgb(107, 114, 128)',
|
|
|
|
|
+ callback: function (value) {
|
|
|
|
|
+ return value.toLocaleString('en-US', {
|
|
|
|
|
+ style: 'currency',
|
|
|
|
|
+ currency: currency,
|
|
|
|
|
+ minimumFractionDigits: 0,
|
|
|
|
|
+ maximumFractionDigits: 0
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
|
|
+ plugins: {
|
|
|
|
|
+ legend: {
|
|
|
|
|
+ display: false
|
|
|
|
|
+ },
|
|
|
|
|
+ tooltip: {
|
|
|
|
|
+ backgroundColor: 'rgba(0, 0, 0, 0.8)',
|
|
|
|
|
+ titleColor: 'white',
|
|
|
|
|
+ bodyColor: 'white',
|
|
|
|
|
+ borderColor: 'rgb(59, 130, 246)',
|
|
|
|
|
+ borderWidth: 1,
|
|
|
|
|
+ cornerRadius: 8,
|
|
|
|
|
+ displayColors: false,
|
|
|
|
|
+ callbacks: {
|
|
|
|
|
+ title: (context) => {
|
|
|
|
|
+ const idx = context[0].dataIndex;
|
|
|
|
|
+ const raw = data[idx];
|
|
|
|
|
+ const date = new Date(raw.createdAt);
|
|
|
|
|
+ return date.toLocaleDateString('en-US', {
|
|
|
|
|
+ year: 'numeric',
|
|
|
|
|
+ month: 'long',
|
|
|
|
|
+ day: 'numeric'
|
|
|
|
|
+ });
|
|
|
|
|
+ },
|
|
|
|
|
+ label: (context) => {
|
|
|
|
|
+ const value = context.parsed.y;
|
|
|
|
|
+ const totalAssets = data[context.dataIndex].totalAssets;
|
|
|
|
|
+ return [
|
|
|
|
|
+ `Portfolio Value: ${value.toLocaleString('en-US', {
|
|
|
|
|
+ style: 'currency',
|
|
|
|
|
+ currency: currency
|
|
|
|
|
+ })}`,
|
|
|
|
|
+ `Total Assets: ${totalAssets}`
|
|
|
|
|
+ ];
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Reactive statement to fetch data when portfolioId, authToken or currency changes
|
|
|
|
|
+ $: if (portfolioId && authToken && currency) {
|
|
|
|
|
+ fetchData();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Reactive statement to render chart when data and canvas are both ready
|
|
|
|
|
+ $: if (historyData.length > 0 && chartCanvas) {
|
|
|
|
|
+ renderChart(historyData);
|
|
|
|
|
+ }
|
|
|
|
|
+</script>
|
|
|
|
|
+
|
|
|
|
|
+{#if isLoading}
|
|
|
|
|
+ <div in:fade class="flex justify-center items-center py-10">
|
|
|
|
|
+ <svg
|
|
|
|
|
+ class="animate-spin h-8 w-8 text-blue-500 dark:text-blue-300"
|
|
|
|
|
+ xmlns="http://www.w3.org/2000/svg"
|
|
|
|
|
+ fill="none"
|
|
|
|
|
+ viewBox="0 0 24 24"
|
|
|
|
|
+ >
|
|
|
|
|
+ <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"
|
|
|
|
|
+ ></circle>
|
|
|
|
|
+ <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z"></path>
|
|
|
|
|
+ </svg>
|
|
|
|
|
+ </div>
|
|
|
|
|
+{:else if historyData.length === 0}
|
|
|
|
|
+ <div class="w-full max-w-6xl mx-auto bg-white dark:bg-gray-900 p-6 rounded-xl shadow-md">
|
|
|
|
|
+ <h3 class="text-xl font-semibold text-gray-800 dark:text-gray-100 mb-4">
|
|
|
|
|
+ Portfolio Value History
|
|
|
|
|
+ </h3>
|
|
|
|
|
+ <div class="text-center py-8">
|
|
|
|
|
+ <p class="text-gray-500 dark:text-gray-400">No portfolio history data available.</p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+{:else}
|
|
|
|
|
+ <div class="w-full max-w-6xl mx-auto bg-white dark:bg-gray-900 p-6 rounded-xl shadow-md">
|
|
|
|
|
+ <h3 class="text-xl font-semibold text-gray-800 dark:text-gray-100 mb-4">
|
|
|
|
|
+ Portfolio Value History
|
|
|
|
|
+ {#if currentValue !== null}
|
|
|
|
|
+ <span class="ml-2 text-blue-500 dark:text-blue-300 text-lg font-medium">
|
|
|
|
|
+ ({currentValue.toLocaleString('en-US', {
|
|
|
|
|
+ style: 'currency',
|
|
|
|
|
+ currency: currency
|
|
|
|
|
+ })}
|
|
|
|
|
+ {#if valueChangePercent !== null}
|
|
|
|
|
+ <span
|
|
|
|
|
+ class="{valueChangePercent >= 0
|
|
|
|
|
+ ? 'text-green-500'
|
|
|
|
|
+ : 'text-red-500'} text-sm font-semibold ml-1 align-middle"
|
|
|
|
|
+ >
|
|
|
|
|
+ {valueChangePercent >= 0 ? '+' : ''}{valueChangePercent.toFixed(2)}%
|
|
|
|
|
+ </span>
|
|
|
|
|
+ {/if}
|
|
|
|
|
+ )
|
|
|
|
|
+ </span>
|
|
|
|
|
+ {/if}
|
|
|
|
|
+ </h3>
|
|
|
|
|
+
|
|
|
|
|
+ {#if valueChange !== null}
|
|
|
|
|
+ <div class="mb-4 text-sm text-gray-600 dark:text-gray-400">
|
|
|
|
|
+ <span class="font-medium">Total Change: </span>
|
|
|
|
|
+ <span class="{valueChange >= 0 ? 'text-green-600' : 'text-red-600'} font-semibold">
|
|
|
|
|
+ {valueChange >= 0 ? '+' : ''}{valueChange.toLocaleString('en-US', {
|
|
|
|
|
+ style: 'currency',
|
|
|
|
|
+ currency: currency
|
|
|
|
|
+ })}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ <span class="ml-2 text-xs text-gray-500">
|
|
|
|
|
+ ({historyData.length} months of data)
|
|
|
|
|
+ </span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ {/if}
|
|
|
|
|
+
|
|
|
|
|
+ <div class="relative h-80">
|
|
|
|
|
+ <canvas bind:this={chartCanvas}></canvas>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+{/if}
|