FilamentTrends.tsx 13 KB

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