From cd3972400196bf25d7891d08e2f98a0dffce8578 Mon Sep 17 00:00:00 2001
From: hzm <934585316@qq.com>
Date: Tue, 17 Feb 2026 18:13:46 +0800
Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E5=9F=BA=E9=87=91?=
=?UTF-8?q?=E5=8E=86=E5=8F=B2=E5=87=80=E5=80=BC=E8=B6=8B=E5=8A=BF=E5=9B=BE?=
=?UTF-8?q?=E8=A1=A8=E5=8A=9F=E8=83=BD?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
app/api/fund.js | 85 ++++++++
app/components/FundTrendChart.jsx | 349 ++++++++++++++++++++++++++++++
app/lib/cacheRequest.js | 52 +++++
app/page.jsx | 57 ++++-
package-lock.json | 30 +++
package.json | 2 +
6 files changed, 572 insertions(+), 3 deletions(-)
create mode 100644 app/components/FundTrendChart.jsx
create mode 100644 app/lib/cacheRequest.js
diff --git a/app/api/fund.js b/app/api/fund.js
index d5805b5..a0014d8 100644
--- a/app/api/fund.js
+++ b/app/api/fund.js
@@ -412,3 +412,88 @@ export const submitFeedback = async (formData) => {
});
return response.json();
};
+
+let historyQueue = Promise.resolve();
+
+export const fetchFundHistory = async (code, range = '1m') => {
+ if (typeof window === 'undefined') return [];
+
+ const end = nowInTz();
+ let start = end.clone();
+
+ switch (range) {
+ case '1m': start = start.subtract(1, 'month'); break;
+ case '3m': start = start.subtract(3, 'month'); break;
+ case '6m': start = start.subtract(6, 'month'); break;
+ case '1y': start = start.subtract(1, 'year'); break;
+ case '3y': start = start.subtract(3, 'year'); break;
+ default: start = start.subtract(1, 'month');
+ }
+
+ const sdate = start.format('YYYY-MM-DD');
+ const edate = end.format('YYYY-MM-DD');
+ const per = 49;
+
+ return new Promise((resolve) => {
+ historyQueue = historyQueue.then(async () => {
+ let allData = [];
+ let page = 1;
+ let totalPages = 1;
+
+ try {
+ const parseContent = (content) => {
+ if (!content) return [];
+ const rows = content.split('
');
+ const data = [];
+ for (const row of rows) {
+ const cells = row.match(/]*>(.*?)<\/td>/g);
+ if (cells && cells.length >= 2) {
+ const dateStr = cells[0].replace(/<[^>]+>/g, '').trim();
+ const valStr = cells[1].replace(/<[^>]+>/g, '').trim();
+ const val = parseFloat(valStr);
+ if (/^\d{4}-\d{2}-\d{2}$/.test(dateStr) && !isNaN(val)) {
+ data.push({ date: dateStr, value: val });
+ }
+ }
+ }
+ return data;
+ };
+
+ // Fetch first page to get metadata
+ const firstUrl = `https://fundf10.eastmoney.com/F10DataApi.aspx?type=lsjz&code=${code}&page=${page}&per=${per}&sdate=${sdate}&edate=${edate}`;
+ await loadScript(firstUrl);
+
+ if (!window.apidata || !window.apidata.content || window.apidata.content.includes('暂无数据')) {
+ resolve([]);
+ return;
+ }
+
+ // Parse total pages
+ if (window.apidata.pages) {
+ totalPages = parseInt(window.apidata.pages, 10) || 1;
+ }
+
+ allData = allData.concat(parseContent(window.apidata.content));
+
+ // Fetch remaining pages
+ for (page = 2; page <= totalPages; page++) {
+ const nextUrl = `https://fundf10.eastmoney.com/F10DataApi.aspx?type=lsjz&code=${code}&page=${page}&per=${per}&sdate=${sdate}&edate=${edate}`;
+ await loadScript(nextUrl);
+ if (window.apidata && window.apidata.content) {
+ allData = allData.concat(parseContent(window.apidata.content));
+ }
+ }
+
+ // The data comes in reverse chronological order (newest first), so we need to reverse it for the chart (oldest first)
+ resolve(allData.reverse());
+
+ } catch (e) {
+ console.error('Fetch history error:', e);
+ resolve([]);
+ }
+ }).catch((e) => {
+ console.error('Queue error:', e);
+ resolve([]);
+ });
+ });
+};
diff --git a/app/components/FundTrendChart.jsx b/app/components/FundTrendChart.jsx
new file mode 100644
index 0000000..ca82ab7
--- /dev/null
+++ b/app/components/FundTrendChart.jsx
@@ -0,0 +1,349 @@
+'use client';
+
+import { useState, useEffect, useMemo, useRef } from 'react';
+import { fetchFundHistory } from '../api/fund';
+import { motion, AnimatePresence } from 'framer-motion';
+import { ChevronIcon } from './Icons';
+import {
+ Chart as ChartJS,
+ CategoryScale,
+ LinearScale,
+ PointElement,
+ LineElement,
+ Title,
+ Tooltip,
+ Legend,
+ Filler
+} from 'chart.js';
+import { Line } from 'react-chartjs-2';
+import {cachedRequest} from "../lib/cacheRequest";
+
+ChartJS.register(
+ CategoryScale,
+ LinearScale,
+ PointElement,
+ LineElement,
+ Title,
+ Tooltip,
+ Legend,
+ Filler
+);
+
+export default function FundTrendChart({ code, isExpanded, onToggleExpand }) {
+ const [range, setRange] = useState('1m');
+ const [data, setData] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+ const chartRef = useRef(null);
+
+ useEffect(() => {
+ // If collapsed, don't fetch data unless we have no data yet
+ if (!isExpanded && data.length > 0) return;
+
+ let active = true;
+ setLoading(true);
+ setError(null);
+ const cacheKey = `fund_history_${code}_${range}`;
+
+ if (isExpanded) {
+ cachedRequest(
+ () => fetchFundHistory(code, range),
+ cacheKey,
+ { cacheTime: 10 * 60 * 1000 }
+ )
+ .then(res => {
+ if (active) {
+ setData(res || []);
+ setLoading(false);
+ }
+ })
+ .catch(err => {
+ if (active) {
+ setError(err);
+ setLoading(false);
+ }
+ });
+
+ }
+ return () => { active = false; };
+ }, [code, range, isExpanded]);
+
+ const ranges = [
+ { label: '近1月', value: '1m' },
+ { label: '近3月', value: '3m' },
+ { label: '近6月', value: '6m' },
+ { label: '近1年', value: '1y' },
+ ];
+
+ const change = useMemo(() => {
+ if (!data.length) return 0;
+ const first = data[0].value;
+ const last = data[data.length - 1].value;
+ return ((last - first) / first) * 100;
+ }, [data]);
+
+ // Red for up, Green for down (CN market style)
+ // Hardcoded hex values from globals.css for Chart.js
+ const upColor = '#f87171'; // --danger
+ const downColor = '#34d399'; // --success
+ const lineColor = change >= 0 ? upColor : downColor;
+
+ const chartData = useMemo(() => {
+ return {
+ labels: data.map(d => d.date),
+ datasets: [
+ {
+ label: '单位净值',
+ data: data.map(d => d.value),
+ borderColor: lineColor,
+ backgroundColor: (context) => {
+ const ctx = context.chart.ctx;
+ const gradient = ctx.createLinearGradient(0, 0, 0, 200);
+ gradient.addColorStop(0, `${lineColor}33`); // 20% opacity
+ gradient.addColorStop(1, `${lineColor}00`); // 0% opacity
+ return gradient;
+ },
+ borderWidth: 2,
+ pointRadius: 0,
+ pointHoverRadius: 4,
+ fill: true,
+ tension: 0.2
+ }
+ ]
+ };
+ }, [data, lineColor]);
+
+ const options = useMemo(() => {
+ return {
+ responsive: true,
+ maintainAspectRatio: false,
+ plugins: {
+ legend: {
+ display: false
+ },
+ tooltip: {
+ enabled: false, // 禁用默认 Tooltip,使用自定义绘制
+ mode: 'index',
+ intersect: false,
+ external: () => {} // 禁用外部 HTML tooltip
+ }
+ },
+ scales: {
+ x: {
+ display: true,
+ grid: {
+ display: false,
+ drawBorder: false
+ },
+ ticks: {
+ color: '#9ca3af',
+ font: { size: 10 },
+ maxTicksLimit: 4,
+ maxRotation: 0
+ },
+ border: { display: false }
+ },
+ y: {
+ display: true,
+ position: 'right',
+ grid: {
+ color: '#1f2937',
+ drawBorder: false,
+ tickLength: 0
+ },
+ ticks: {
+ color: '#9ca3af',
+ font: { size: 10 },
+ count: 5
+ },
+ border: { display: false }
+ }
+ },
+ interaction: {
+ mode: 'index',
+ intersect: false,
+ },
+ onHover: (event, chartElement) => {
+ event.native.target.style.cursor = chartElement[0] ? 'crosshair' : 'default';
+ }
+ };
+ }, []);
+
+ const plugins = useMemo(() => [{
+ id: 'crosshair',
+ afterDraw: (chart) => {
+ // 检查是否有激活的点
+ let activePoint = null;
+ if (chart.tooltip?._active?.length) {
+ activePoint = chart.tooltip._active[0];
+ } else {
+ // 如果 tooltip._active 为空(可能因为 enabled: false 导致内部状态更新机制差异),
+ // 尝试从 getActiveElements 获取,这在 Chart.js 3+ 中是推荐方式
+ const activeElements = chart.getActiveElements();
+ if (activeElements && activeElements.length) {
+ activePoint = activeElements[0];
+ }
+ }
+
+ if (activePoint) {
+ const ctx = chart.ctx;
+ const x = activePoint.element.x;
+ const y = activePoint.element.y;
+ const topY = chart.scales.y.top;
+ const bottomY = chart.scales.y.bottom;
+ const leftX = chart.scales.x.left;
+ const rightX = chart.scales.x.right;
+
+ ctx.save();
+ ctx.beginPath();
+ ctx.setLineDash([3, 3]);
+ ctx.lineWidth = 1;
+ ctx.strokeStyle = '#9ca3af';
+
+ // Draw vertical line
+ ctx.moveTo(x, topY);
+ ctx.lineTo(x, bottomY);
+
+ // Draw horizontal line
+ ctx.moveTo(leftX, y);
+ ctx.lineTo(rightX, y);
+
+ ctx.stroke();
+
+ // 获取 --primary 颜色
+ const primaryColor = getComputedStyle(document.documentElement).getPropertyValue('--primary').trim() || '#22d3ee';
+
+ // Draw labels
+ ctx.font = '10px sans-serif';
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'middle';
+
+ // 获取数据点
+ // 优先使用 chart.data 中的数据,避免闭包过时问题
+ // activePoint.index 是当前数据集中的索引
+ const datasetIndex = activePoint.datasetIndex;
+ const index = activePoint.index;
+
+ const labels = chart.data.labels;
+ const datasets = chart.data.datasets;
+
+ if (labels && datasets && datasets[datasetIndex] && datasets[datasetIndex].data) {
+ const dateStr = labels[index];
+ const value = datasets[datasetIndex].data[index];
+
+ if (dateStr !== undefined && value !== undefined) {
+ // X axis label (date)
+ const textWidth = ctx.measureText(dateStr).width + 8;
+ ctx.fillStyle = primaryColor;
+ ctx.fillRect(x - textWidth / 2, bottomY, textWidth, 16);
+ ctx.fillStyle = '#0f172a'; // --background
+ ctx.fillText(dateStr, x, bottomY + 8);
+
+ // Y axis label (value)
+ const valueStr = typeof value === 'number' ? value.toFixed(4) : value;
+ const valWidth = ctx.measureText(valueStr).width + 8;
+ ctx.fillStyle = primaryColor;
+ ctx.fillRect(rightX - valWidth, y - 8, valWidth, 16);
+ ctx.fillStyle = '#0f172a'; // --background
+ ctx.textAlign = 'center';
+ ctx.fillText(valueStr, rightX - valWidth / 2, y);
+ }
+ }
+
+ ctx.restore();
+ }
+ }
+ }], []); // 移除 data 依赖,因为我们直接从 chart 实例读取数据
+
+ return (
+ e.stopPropagation()}>
+
+
+
+ 业绩走势
+
+
+ {data.length > 0 && (
+
+ {ranges.find(r => r.value === range)?.label}涨跌幅
+
+ {change > 0 ? '+' : ''}{change.toFixed(2)}%
+
+
+ )}
+
+
+
+
+ {isExpanded && (
+
+
+ {loading && (
+
+ 加载中...
+
+ )}
+
+ {!loading && data.length === 0 && (
+
+ 暂无数据
+
+ )}
+
+ {data.length > 0 && (
+
+ )}
+
+
+
+ {ranges.map(r => (
+
+ ))}
+
+
+ )}
+
+
+ );
+}
diff --git a/app/lib/cacheRequest.js b/app/lib/cacheRequest.js
new file mode 100644
index 0000000..58b8341
--- /dev/null
+++ b/app/lib/cacheRequest.js
@@ -0,0 +1,52 @@
+
+const _cacheResultMap = new Map();
+const _cachePendingPromiseMap = new Map();
+
+/**
+ * 对函数式写法的请求进行缓存
+ * @param request
+ * @param cacheKey
+ * @param options
+ */
+export async function cachedRequest(
+ request,
+ cacheKey,
+ options
+) {
+ const {
+ cacheResultMap = _cacheResultMap,
+ cachePendingPromiseMap = _cachePendingPromiseMap,
+ cacheTime = 1000 * 10,
+ } = options ?? {};
+ let result;
+ // 如果有缓存直接返回
+ if (cacheResultMap.has(cacheKey)) {
+ result = cacheResultMap.get(cacheKey);
+ } else if (cachePendingPromiseMap.has(cacheKey)) {
+ // 如果没有缓存,需要判断是否存在相同正在发起的请求
+ result = await cachePendingPromiseMap.get(cacheKey);
+ } else {
+ // 发起真实请求
+ cachePendingPromiseMap.set(cacheKey, request());
+ result = await cachePendingPromiseMap.get(cacheKey);
+ cacheResultMap.set(cacheKey, result);
+ // 设置清除缓存时间
+ if (cacheTime > 0) {
+ setTimeout(() => {
+ cacheResultMap.delete(cacheKey);
+ }, cacheTime);
+ }
+ // 得到请求结果后 清理请求
+ cachePendingPromiseMap.delete(cacheKey);
+ }
+ return result;
+}
+
+/**
+ * 清除缓存的请求结果
+ * @param key
+ */
+export function clearCachedRequest(key) {
+ _cacheResultMap.delete(key);
+ _cachePendingPromiseMap.delete(key);
+}
diff --git a/app/page.jsx b/app/page.jsx
index 55e97ec..097ccff 100644
--- a/app/page.jsx
+++ b/app/page.jsx
@@ -9,6 +9,7 @@ import utc from 'dayjs/plugin/utc';
import timezone from 'dayjs/plugin/timezone';
import Announcement from "./components/Announcement";
import { DatePicker, DonateTabs, NumericInput, Stat } from "./components/Common";
+import FundTrendChart from "./components/FundTrendChart";
import { ChevronIcon, CloseIcon, CloudIcon, DragIcon, ExitIcon, EyeIcon, EyeOffIcon, GridIcon, ListIcon, LoginIcon, LogoutIcon, MailIcon, PinIcon, PinOffIcon, PlusIcon, RefreshIcon, SettingsIcon, SortIcon, StarIcon, TrashIcon, UpdateIcon, UserIcon } from "./components/Icons";
import weChatGroupImg from "./assets/weChatGroup.png";
import { supabase, isSupabaseConfigured } from './lib/supabase';
@@ -1903,6 +1904,7 @@ export default function HomePage() {
// 收起/展开状态
const [collapsedCodes, setCollapsedCodes] = useState(new Set());
+ const [collapsedTrends, setCollapsedTrends] = useState(new Set()); // New state for collapsed trend charts
// 自选状态
const [favorites, setFavorites] = useState(new Set());
@@ -1925,9 +1927,14 @@ export default function HomePage() {
const [lastSyncTime, setLastSyncTime] = useState(null);
useEffect(() => {
+ // 优先使用服务端返回的时间,如果没有则使用本地存储的时间
+ // 这里只设置初始值,后续更新由接口返回的时间驱动
const stored = window.localStorage.getItem('localUpdatedAt');
if (stored) {
setLastSyncTime(stored);
+ } else {
+ // 如果没有存储的时间,暂时设为 null,等待接口返回
+ setLastSyncTime(null);
}
}, []);
const [userMenuOpen, setUserMenuOpen] = useState(false);
@@ -2484,7 +2491,7 @@ export default function HomePage() {
}, []);
const storageHelper = useMemo(() => {
- const keys = new Set(['funds', 'favorites', 'groups', 'collapsedCodes', 'refreshMs', 'holdings', 'pendingTrades', 'viewMode']);
+ const keys = new Set(['funds', 'favorites', 'groups', 'collapsedCodes', 'collapsedTrends', 'refreshMs', 'holdings', 'pendingTrades', 'viewMode']);
const triggerSync = (key, prevValue, nextValue) => {
if (keys.has(key)) {
if (key === 'funds') {
@@ -2527,7 +2534,7 @@ export default function HomePage() {
}, [getFundCodesSignature, scheduleSync]);
useEffect(() => {
- const keys = new Set(['funds', 'favorites', 'groups', 'collapsedCodes', 'refreshMs', 'holdings', 'pendingTrades', 'viewMode']);
+ const keys = new Set(['funds', 'favorites', 'groups', 'collapsedCodes', 'collapsedTrends', 'refreshMs', 'holdings', 'pendingTrades', 'viewMode']);
const onStorage = (e) => {
if (!e.key) return;
if (e.key === 'localUpdatedAt') {
@@ -2582,6 +2589,19 @@ export default function HomePage() {
});
};
+ const toggleTrendCollapse = (code) => {
+ setCollapsedTrends(prev => {
+ const next = new Set(prev);
+ if (next.has(code)) {
+ next.delete(code);
+ } else {
+ next.add(code);
+ }
+ storageHelper.setItem('collapsedTrends', JSON.stringify(Array.from(next)));
+ return next;
+ });
+ };
+
const handleAddGroup = (name) => {
const newGroup = {
id: `group_${Date.now()}`,
@@ -2688,6 +2708,11 @@ export default function HomePage() {
if (Array.isArray(savedCollapsed)) {
setCollapsedCodes(new Set(savedCollapsed));
}
+ // 加载业绩走势收起状态
+ const savedTrends = JSON.parse(localStorage.getItem('collapsedTrends') || '[]');
+ if (Array.isArray(savedTrends)) {
+ setCollapsedTrends(new Set(savedTrends));
+ }
// 加载自选状态
const savedFavorites = JSON.parse(localStorage.getItem('favorites') || '[]');
if (Array.isArray(savedFavorites)) {
@@ -2781,6 +2806,8 @@ export default function HomePage() {
});
const { data: { subscription } } = supabase.auth.onAuthStateChange(async (event, session) => {
+ // INITIAL_SESSION 会由 getSession() 主动触发,这里不再重复处理
+ if (event === 'INITIAL_SESSION') return;
await handleSession(session ?? null, event);
});
@@ -3172,6 +3199,15 @@ export default function HomePage() {
return nextSet;
});
+ // 同步删除业绩走势收起状态
+ setCollapsedTrends(prev => {
+ if (!prev.has(removeCode)) return prev;
+ const nextSet = new Set(prev);
+ nextSet.delete(removeCode);
+ storageHelper.setItem('collapsedTrends', JSON.stringify(Array.from(nextSet)));
+ return nextSet;
+ });
+
// 同步删除自选状态
setFavorites(prev => {
if (!prev.has(removeCode)) return prev;
@@ -3240,6 +3276,10 @@ export default function HomePage() {
? Array.from(new Set(payload.collapsedCodes.map(normalizeCode).filter((code) => uniqueFundCodes.includes(code)))).sort()
: [];
+ const collapsedTrends = Array.isArray(payload.collapsedTrends)
+ ? Array.from(new Set(payload.collapsedTrends.map(normalizeCode).filter((code) => uniqueFundCodes.includes(code)))).sort()
+ : [];
+
const groups = Array.isArray(payload.groups)
? payload.groups
.map((group) => {
@@ -3304,6 +3344,7 @@ export default function HomePage() {
favorites,
groups,
collapsedCodes,
+ collapsedTrends,
refreshMs: Number.isFinite(payload.refreshMs) ? payload.refreshMs : 30000,
holdings,
pendingTrades,
@@ -3317,6 +3358,7 @@ export default function HomePage() {
const favorites = JSON.parse(localStorage.getItem('favorites') || '[]');
const groups = JSON.parse(localStorage.getItem('groups') || '[]');
const collapsedCodes = JSON.parse(localStorage.getItem('collapsedCodes') || '[]');
+ const collapsedTrends = JSON.parse(localStorage.getItem('collapsedTrends') || '[]');
const viewMode = localStorage.getItem('viewMode') === 'list' ? 'list' : 'card';
const fundCodes = new Set(
Array.isArray(funds)
@@ -3355,6 +3397,9 @@ export default function HomePage() {
const cleanedCollapsed = Array.isArray(collapsedCodes)
? collapsedCodes.filter((code) => fundCodes.has(code))
: [];
+ const cleanedCollapsedTrends = Array.isArray(collapsedTrends)
+ ? collapsedTrends.filter((code) => fundCodes.has(code))
+ : [];
const cleanedGroups = Array.isArray(groups)
? groups.map((group) => ({
...group,
@@ -3371,6 +3416,7 @@ export default function HomePage() {
favorites: cleanedFavorites,
groups: cleanedGroups,
collapsedCodes: cleanedCollapsed,
+ collapsedTrends: cleanedCollapsedTrends,
refreshMs: parseInt(localStorage.getItem('refreshMs') || '30000', 10),
holdings: cleanedHoldings,
pendingTrades: cleanedPendingTrades,
@@ -3397,7 +3443,7 @@ export default function HomePage() {
skipSyncRef.current = true;
try {
if (cloudUpdatedAt) {
- storageHelper.setItem('localUpdatedAt', toTz(cloudUpdatedAt).toISOString());
+ storageHelper.setItem('localUpdatedAt', cloudUpdatedAt);
}
const nextFunds = Array.isArray(cloudData.funds) ? dedupeByCode(cloudData.funds) : [];
setFunds(nextFunds);
@@ -4763,6 +4809,11 @@ export default function HomePage() {
)}
+ toggleTrendCollapse(f.code)}
+ />
>
)}
diff --git a/package-lock.json b/package-lock.json
index 6251ced..ada1d06 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -11,10 +11,12 @@
"@dicebear/collection": "^9.3.1",
"@dicebear/core": "^9.3.1",
"@supabase/supabase-js": "^2.78.0",
+ "chart.js": "^4.5.1",
"dayjs": "^1.11.19",
"framer-motion": "^12.29.2",
"next": "^16.1.5",
"react": "18.3.1",
+ "react-chartjs-2": "^5.3.1",
"react-dom": "18.3.1"
},
"devDependencies": {
@@ -963,6 +965,12 @@
"url": "https://opencollective.com/libvips"
}
},
+ "node_modules/@kurkle/color": {
+ "version": "0.3.4",
+ "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
+ "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
+ "license": "MIT"
+ },
"node_modules/@next/env": {
"version": "16.1.6",
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.1.6.tgz",
@@ -1254,6 +1262,18 @@
],
"license": "CC-BY-4.0"
},
+ "node_modules/chart.js": {
+ "version": "4.5.1",
+ "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
+ "integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
+ "license": "MIT",
+ "dependencies": {
+ "@kurkle/color": "^0.3.0"
+ },
+ "engines": {
+ "pnpm": ">=8"
+ }
+ },
"node_modules/client-only": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
@@ -1453,6 +1473,16 @@
"node": ">=0.10.0"
}
},
+ "node_modules/react-chartjs-2": {
+ "version": "5.3.1",
+ "resolved": "https://registry.npmjs.org/react-chartjs-2/-/react-chartjs-2-5.3.1.tgz",
+ "integrity": "sha512-h5IPXKg9EXpjoBzUfyWJvllMjG2mQ4EiuHQFhms/AjUm0XSZHhyRy2xVmLXHKrtcdrPO4mnGqRtYoD0vp95A0A==",
+ "license": "MIT",
+ "peerDependencies": {
+ "chart.js": "^4.1.1",
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
"node_modules/react-dom": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
diff --git a/package.json b/package.json
index 6795e5f..f18f919 100644
--- a/package.json
+++ b/package.json
@@ -11,10 +11,12 @@
"@dicebear/collection": "^9.3.1",
"@dicebear/core": "^9.3.1",
"@supabase/supabase-js": "^2.78.0",
+ "chart.js": "^4.5.1",
"dayjs": "^1.11.19",
"framer-motion": "^12.29.2",
"next": "^16.1.5",
"react": "18.3.1",
+ "react-chartjs-2": "^5.3.1",
"react-dom": "18.3.1"
},
"engines": {
|