FilamentTrends.tsx 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340
  1. import { useMemo, useState } from 'react';
  2. import {
  3. AreaChart,
  4. Area,
  5. XAxis,
  6. YAxis,
  7. CartesianGrid,
  8. Tooltip,
  9. ResponsiveContainer,
  10. BarChart,
  11. Bar,
  12. PieChart,
  13. Pie,
  14. Cell,
  15. Legend,
  16. } from 'recharts';
  17. import type { Archive } from '../api/client';
  18. import { parseUTCDate } from '../utils/date';
  19. interface FilamentTrendsProps {
  20. archives: Archive[];
  21. currency?: string;
  22. }
  23. type TimeRange = '7d' | '30d' | '90d' | '365d' | 'all';
  24. const COLORS = ['#00ae42', '#3b82f6', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899', '#14b8a6', '#f97316'];
  25. function getDateRange(range: TimeRange): Date {
  26. const now = new Date();
  27. switch (range) {
  28. case '7d':
  29. return new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
  30. case '30d':
  31. return new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
  32. case '90d':
  33. return new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000);
  34. case '365d':
  35. return new Date(now.getTime() - 365 * 24 * 60 * 60 * 1000);
  36. case 'all':
  37. return new Date(0);
  38. }
  39. }
  40. export function FilamentTrends({ archives, currency = '$' }: FilamentTrendsProps) {
  41. const [timeRange, setTimeRange] = useState<TimeRange>('30d');
  42. // Filter archives by time range
  43. const filteredArchives = useMemo(() => {
  44. const startDate = getDateRange(timeRange);
  45. return archives.filter(a => (parseUTCDate(a.completed_at || a.created_at) || new Date(0)) >= startDate);
  46. }, [archives, timeRange]);
  47. // Calculate daily usage data
  48. const dailyData = useMemo(() => {
  49. const dataMap = new Map<string, { date: string; filament: number; cost: number; prints: number }>();
  50. filteredArchives.forEach(archive => {
  51. const date = parseUTCDate(archive.completed_at || archive.created_at) || new Date();
  52. // Use local date string for grouping
  53. const key = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
  54. const existing = dataMap.get(key) || { date: key, filament: 0, cost: 0, prints: 0 };
  55. const qty = archive.quantity || 1;
  56. existing.filament += (archive.filament_used_grams || 0) * qty;
  57. existing.cost += archive.cost || 0;
  58. existing.prints += qty;
  59. dataMap.set(key, existing);
  60. });
  61. return Array.from(dataMap.values())
  62. .sort((a, b) => a.date.localeCompare(b.date))
  63. .map(d => ({
  64. ...d,
  65. dateLabel: new Date(d.date).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }),
  66. }));
  67. }, [filteredArchives]);
  68. // Calculate weekly aggregated data for longer time ranges
  69. const weeklyData = useMemo(() => {
  70. if (timeRange === '7d' || timeRange === '30d') return dailyData;
  71. const dataMap = new Map<string, { week: string; filament: number; cost: number; prints: number }>();
  72. filteredArchives.forEach(archive => {
  73. const date = parseUTCDate(archive.completed_at || archive.created_at) || new Date();
  74. // Get week start (Sunday)
  75. const weekStart = new Date(date);
  76. weekStart.setDate(date.getDate() - date.getDay());
  77. const key = `${weekStart.getFullYear()}-${String(weekStart.getMonth() + 1).padStart(2, '0')}-${String(weekStart.getDate()).padStart(2, '0')}`;
  78. const existing = dataMap.get(key) || { week: key, filament: 0, cost: 0, prints: 0 };
  79. const qty = archive.quantity || 1;
  80. existing.filament += (archive.filament_used_grams || 0) * qty;
  81. existing.cost += archive.cost || 0;
  82. existing.prints += qty;
  83. dataMap.set(key, existing);
  84. });
  85. return Array.from(dataMap.values())
  86. .sort((a, b) => a.week.localeCompare(b.week))
  87. .map(d => ({
  88. date: d.week,
  89. dateLabel: `Week of ${new Date(d.week).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}`,
  90. ...d,
  91. }));
  92. }, [filteredArchives, dailyData, timeRange]);
  93. // Usage by filament type
  94. const filamentTypeData = useMemo(() => {
  95. const dataMap = new Map<string, number>();
  96. filteredArchives.forEach(archive => {
  97. const type = archive.filament_type || 'Unknown';
  98. const qty = archive.quantity || 1;
  99. // Handle multiple types (e.g., "PLA, PETG")
  100. const types = type.split(', ');
  101. types.forEach(t => {
  102. const grams = ((archive.filament_used_grams || 0) * qty) / types.length;
  103. dataMap.set(t, (dataMap.get(t) || 0) + grams);
  104. });
  105. });
  106. return Array.from(dataMap.entries())
  107. .map(([name, value]) => ({ name, value: Math.round(value) }))
  108. .sort((a, b) => b.value - a.value);
  109. }, [filteredArchives]);
  110. // Monthly comparison data
  111. const monthlyComparison = useMemo(() => {
  112. const now = new Date();
  113. const months: { month: string; filament: number; cost: number; prints: number }[] = [];
  114. for (let i = 5; i >= 0; i--) {
  115. const monthDate = new Date(now.getFullYear(), now.getMonth() - i, 1);
  116. const monthEnd = new Date(now.getFullYear(), now.getMonth() - i + 1, 0);
  117. const monthStr = monthDate.toLocaleDateString('en-US', { month: 'short', year: '2-digit' });
  118. const monthArchives = archives.filter(a => {
  119. const d = parseUTCDate(a.completed_at || a.created_at) || new Date(0);
  120. return d >= monthDate && d <= monthEnd;
  121. });
  122. months.push({
  123. month: monthStr,
  124. filament: Math.round(monthArchives.reduce((sum, a) => sum + (a.filament_used_grams || 0), 0)),
  125. cost: monthArchives.reduce((sum, a) => sum + (a.cost || 0), 0),
  126. prints: monthArchives.reduce((sum, a) => sum + (a.quantity || 1), 0),
  127. });
  128. }
  129. return months;
  130. }, [archives]);
  131. const chartData = timeRange === '7d' || timeRange === '30d' ? dailyData : weeklyData;
  132. const totalFilament = filteredArchives.reduce((sum, a) => sum + (a.filament_used_grams || 0), 0);
  133. const totalCost = filteredArchives.reduce((sum, a) => sum + (a.cost || 0), 0);
  134. const totalPrints = filteredArchives.reduce((sum, a) => sum + (a.quantity || 1), 0);
  135. return (
  136. <div className="space-y-6">
  137. {/* Time Range Selector */}
  138. <div className="flex items-center justify-between">
  139. <h3 className="text-lg font-semibold text-white">Filament Usage Trends</h3>
  140. <div className="flex gap-1 bg-bambu-dark rounded-lg p-1">
  141. {(['7d', '30d', '90d', '365d', 'all'] as TimeRange[]).map((range) => (
  142. <button
  143. key={range}
  144. onClick={() => setTimeRange(range)}
  145. className={`px-3 py-1 text-sm rounded-md transition-colors ${
  146. timeRange === range
  147. ? 'bg-bambu-green text-white'
  148. : 'text-bambu-gray hover:text-white'
  149. }`}
  150. >
  151. {range === 'all' ? 'All' : range.replace('d', 'D')}
  152. </button>
  153. ))}
  154. </div>
  155. </div>
  156. {/* Summary Cards */}
  157. <div className="grid grid-cols-3 gap-4">
  158. <div className="bg-bambu-dark rounded-lg p-4">
  159. <p className="text-sm text-bambu-gray">Period Filament</p>
  160. <p className="text-2xl font-bold text-white">{(totalFilament / 1000).toFixed(2)}kg</p>
  161. <p className="text-xs text-bambu-gray">{totalFilament.toFixed(0)}g total</p>
  162. </div>
  163. <div className="bg-bambu-dark rounded-lg p-4">
  164. <p className="text-sm text-bambu-gray">Period Cost</p>
  165. <p className="text-2xl font-bold text-white">{currency}{totalCost.toFixed(2)}</p>
  166. <p className="text-xs text-bambu-gray">{totalPrints} prints</p>
  167. </div>
  168. <div className="bg-bambu-dark rounded-lg p-4">
  169. <p className="text-sm text-bambu-gray">Avg per Print</p>
  170. <p className="text-2xl font-bold text-white">
  171. {totalPrints > 0
  172. ? (totalFilament / totalPrints).toFixed(0)
  173. : 0}g
  174. </p>
  175. <p className="text-xs text-bambu-gray">
  176. {currency}{totalPrints > 0 ? (totalCost / totalPrints).toFixed(2) : '0.00'} avg
  177. </p>
  178. </div>
  179. </div>
  180. {/* Usage Over Time Chart */}
  181. {chartData.length > 0 ? (
  182. <div className="bg-bambu-dark rounded-lg p-4">
  183. <h4 className="text-sm font-medium text-bambu-gray mb-4">Usage Over Time</h4>
  184. <ResponsiveContainer width="100%" height={250}>
  185. <AreaChart data={chartData}>
  186. <defs>
  187. <linearGradient id="colorFilament" x1="0" y1="0" x2="0" y2="1">
  188. <stop offset="5%" stopColor="#00ae42" stopOpacity={0.3}/>
  189. <stop offset="95%" stopColor="#00ae42" stopOpacity={0}/>
  190. </linearGradient>
  191. </defs>
  192. <CartesianGrid strokeDasharray="3 3" stroke="#3d3d3d" />
  193. <XAxis
  194. dataKey="dateLabel"
  195. stroke="#9ca3af"
  196. tick={{ fontSize: 12 }}
  197. interval="preserveStartEnd"
  198. />
  199. <YAxis
  200. stroke="#9ca3af"
  201. tick={{ fontSize: 12 }}
  202. tickFormatter={(value) => `${value}g`}
  203. />
  204. <Tooltip
  205. contentStyle={{
  206. backgroundColor: '#2d2d2d',
  207. border: '1px solid #3d3d3d',
  208. borderRadius: '8px',
  209. }}
  210. labelStyle={{ color: '#fff' }}
  211. formatter={(value) => [`${Number(value ?? 0).toFixed(0)}g`, 'Filament']}
  212. />
  213. <Area
  214. type="monotone"
  215. dataKey="filament"
  216. stroke="#00ae42"
  217. strokeWidth={2}
  218. fillOpacity={1}
  219. fill="url(#colorFilament)"
  220. />
  221. </AreaChart>
  222. </ResponsiveContainer>
  223. </div>
  224. ) : (
  225. <div className="bg-bambu-dark rounded-lg p-8 text-center text-bambu-gray">
  226. No data for selected time range
  227. </div>
  228. )}
  229. {/* Bottom Charts */}
  230. <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
  231. {/* Filament Type Distribution */}
  232. <div className="bg-bambu-dark rounded-lg p-4">
  233. <h4 className="text-sm font-medium text-bambu-gray mb-4">By Filament Type</h4>
  234. {filamentTypeData.length > 0 ? (
  235. <div className="flex items-center gap-4">
  236. <ResponsiveContainer width={160} height={160}>
  237. <PieChart>
  238. <Pie
  239. data={filamentTypeData}
  240. cx="50%"
  241. cy="50%"
  242. innerRadius={40}
  243. outerRadius={70}
  244. paddingAngle={2}
  245. dataKey="value"
  246. >
  247. {filamentTypeData.map((_, index) => (
  248. <Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
  249. ))}
  250. </Pie>
  251. <Tooltip
  252. contentStyle={{
  253. backgroundColor: '#2d2d2d',
  254. border: '1px solid #3d3d3d',
  255. borderRadius: '8px',
  256. }}
  257. formatter={(value) => [`${value ?? 0}g`, 'Usage']}
  258. />
  259. </PieChart>
  260. </ResponsiveContainer>
  261. <div className="flex-1 space-y-2 overflow-hidden">
  262. {filamentTypeData.map((entry, index) => {
  263. const total = filamentTypeData.reduce((sum, e) => sum + e.value, 0);
  264. const percent = total > 0 ? ((entry.value / total) * 100).toFixed(0) : 0;
  265. return (
  266. <div key={entry.name} className="flex items-center gap-2 text-sm">
  267. <div
  268. className="w-3 h-3 rounded-sm flex-shrink-0"
  269. style={{ backgroundColor: COLORS[index % COLORS.length] }}
  270. />
  271. <span className="text-white truncate flex-1">{entry.name}</span>
  272. <span className="text-bambu-gray flex-shrink-0">{percent}%</span>
  273. </div>
  274. );
  275. })}
  276. </div>
  277. </div>
  278. ) : (
  279. <div className="h-[160px] flex items-center justify-center text-bambu-gray">
  280. No filament data
  281. </div>
  282. )}
  283. </div>
  284. {/* Monthly Comparison */}
  285. <div className="bg-bambu-dark rounded-lg p-4">
  286. <h4 className="text-sm font-medium text-bambu-gray mb-4">Monthly Comparison</h4>
  287. <ResponsiveContainer width="100%" height={200}>
  288. <BarChart data={monthlyComparison}>
  289. <CartesianGrid strokeDasharray="3 3" stroke="#3d3d3d" />
  290. <XAxis dataKey="month" stroke="#9ca3af" tick={{ fontSize: 12 }} />
  291. <YAxis stroke="#9ca3af" tick={{ fontSize: 12 }} tickFormatter={(v) => `${v}g`} />
  292. <Tooltip
  293. contentStyle={{
  294. backgroundColor: '#2d2d2d',
  295. border: '1px solid #3d3d3d',
  296. borderRadius: '8px',
  297. }}
  298. formatter={(value, name) => [
  299. name === 'filament' ? `${value ?? 0}g` : name === 'cost' ? `${currency}${Number(value ?? 0).toFixed(2)}` : value ?? 0,
  300. name === 'filament' ? 'Filament' : name === 'cost' ? 'Cost' : 'Prints'
  301. ]}
  302. />
  303. <Legend />
  304. <Bar dataKey="filament" name="Filament (g)" fill="#00ae42" radius={[4, 4, 0, 0]} />
  305. </BarChart>
  306. </ResponsiveContainer>
  307. </div>
  308. </div>
  309. </div>
  310. );
  311. }