5bb67d6663
- Purchase form now includes a date picker (defaults to today) - New Sell model, CRUD endpoints (/sells), and stats integration - AddSell and SellList components added to dashboard - Portfolio chart updated to reflect sells over time Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
162 lines
4.7 KiB
JavaScript
162 lines
4.7 KiB
JavaScript
import React, { useRef } from 'react';
|
|
import {
|
|
Chart as ChartJS,
|
|
LineElement,
|
|
PointElement,
|
|
LinearScale,
|
|
CategoryScale,
|
|
Tooltip,
|
|
Legend,
|
|
Filler,
|
|
} from 'chart.js';
|
|
import { Line } from 'react-chartjs-2';
|
|
|
|
ChartJS.register(LineElement, PointElement, LinearScale, CategoryScale, Tooltip, Legend, Filler);
|
|
|
|
const styles = {
|
|
card: { background: '#1a1a1a', padding: '1.5rem', borderRadius: '12px', border: '1px solid #333', marginBottom: '1.5rem' },
|
|
title: { fontSize: '1.1rem', fontWeight: 700, marginBottom: '1rem', color: '#f7931a' },
|
|
saveBtn: { marginTop: '0.75rem', background: 'none', border: '1px solid #555', color: '#aaa', borderRadius: '6px', padding: '0.4rem 1rem', cursor: 'pointer', fontSize: '0.85rem' },
|
|
};
|
|
|
|
function toDateKey(date) {
|
|
return date.toISOString().split('T')[0];
|
|
}
|
|
|
|
function priceOn(date, priceMap, currentPrice, isToday, sortedPurchases) {
|
|
if (isToday) return currentPrice || 0;
|
|
// Try candle history (walk back up to 7 days)
|
|
for (let i = 0; i <= 7; i++) {
|
|
const d = new Date(date);
|
|
d.setDate(d.getDate() - i);
|
|
const p = priceMap[toDateKey(d)];
|
|
if (p) return p;
|
|
}
|
|
// Fall back to most recent purchase price up to this date
|
|
let fallback = null;
|
|
for (const p of sortedPurchases) {
|
|
const pd = new Date(p.created_at);
|
|
pd.setHours(0, 0, 0, 0);
|
|
if (pd <= date) fallback = p.price_eur;
|
|
}
|
|
return fallback;
|
|
}
|
|
|
|
export default function PortfolioChart({ purchases, sells, stats, btcHistory }) {
|
|
const chartRef = useRef(null);
|
|
|
|
if (!purchases || purchases.length === 0) return null;
|
|
|
|
const sorted = [...purchases].sort((a, b) => new Date(a.created_at) - new Date(b.created_at));
|
|
const sortedSells = [...(sells || [])].sort((a, b) => new Date(a.created_at) - new Date(b.created_at));
|
|
const today = new Date();
|
|
today.setHours(0, 0, 0, 0);
|
|
|
|
// Build price lookup from candle history
|
|
const priceMap = {};
|
|
(btcHistory || []).forEach(({ date, close }) => { priceMap[date] = close; });
|
|
|
|
const firstDate = new Date(sorted[0].created_at);
|
|
firstDate.setHours(0, 0, 0, 0);
|
|
|
|
// Generate biweekly dates from first purchase to today
|
|
const dates = [];
|
|
const cursor = new Date(firstDate);
|
|
while (cursor <= today) {
|
|
dates.push(new Date(cursor));
|
|
cursor.setDate(cursor.getDate() + 7);
|
|
}
|
|
if (toDateKey(dates[dates.length - 1]) !== toDateKey(today)) {
|
|
dates.push(new Date(today));
|
|
}
|
|
|
|
const labels = [];
|
|
const portfolioValues = [];
|
|
const investedValues = [];
|
|
|
|
dates.forEach(date => {
|
|
const isToday = toDateKey(date) === toDateKey(today);
|
|
const price = priceOn(date, priceMap, stats?.current_price, isToday, sorted);
|
|
if (price === null) return; // no price data available, skip
|
|
|
|
// Cumulative BTC and invested up to this date
|
|
let cumBtc = 0;
|
|
let cumInvested = 0;
|
|
sorted.forEach(p => {
|
|
const pDate = new Date(p.created_at);
|
|
pDate.setHours(0, 0, 0, 0);
|
|
if (pDate <= date) {
|
|
cumBtc += p.amount_eur / p.price_eur;
|
|
cumInvested += p.amount_eur;
|
|
}
|
|
});
|
|
sortedSells.forEach(s => {
|
|
const sDate = new Date(s.created_at);
|
|
sDate.setHours(0, 0, 0, 0);
|
|
if (sDate <= date) {
|
|
cumBtc -= s.btc_amount;
|
|
cumInvested -= s.btc_amount * s.price_eur;
|
|
}
|
|
});
|
|
|
|
if (cumBtc === 0) return; // no purchases yet at this date
|
|
|
|
labels.push(date.toLocaleDateString('en-GB'));
|
|
portfolioValues.push(parseFloat((cumBtc * price).toFixed(2)));
|
|
investedValues.push(parseFloat(cumInvested.toFixed(2)));
|
|
});
|
|
|
|
const data = {
|
|
labels,
|
|
datasets: [
|
|
{
|
|
label: 'Portfolio Value (€)',
|
|
data: portfolioValues,
|
|
borderColor: '#f7931a',
|
|
backgroundColor: 'rgba(247,147,26,0.1)',
|
|
fill: true,
|
|
tension: 0.3,
|
|
},
|
|
{
|
|
label: 'Total Invested (€)',
|
|
data: investedValues,
|
|
borderColor: '#4fc3f7',
|
|
backgroundColor: 'transparent',
|
|
borderDash: [6, 3],
|
|
tension: 0.3,
|
|
},
|
|
],
|
|
};
|
|
|
|
const options = {
|
|
responsive: true,
|
|
plugins: {
|
|
legend: { labels: { color: '#aaa' } },
|
|
tooltip: { mode: 'index', intersect: false },
|
|
},
|
|
scales: {
|
|
x: { ticks: { color: '#666' }, grid: { color: '#2a2a2a' } },
|
|
y: { ticks: { color: '#666' }, grid: { color: '#2a2a2a' } },
|
|
},
|
|
};
|
|
|
|
const handleSave = () => {
|
|
const chart = chartRef.current;
|
|
if (!chart) return;
|
|
const url = chart.toBase64Image();
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = 'btc-portfolio.png';
|
|
a.click();
|
|
};
|
|
|
|
return (
|
|
<div style={styles.card}>
|
|
<div style={styles.title}>Portfolio Chart</div>
|
|
<Line ref={chartRef} data={data} options={options} />
|
|
<br />
|
|
<button style={styles.saveBtn} onClick={handleSave}>Save as PNG</button>
|
|
</div>
|
|
);
|
|
}
|