|
@@ -1,4 +1,5 @@
|
|
|
import { useMemo, useState } from 'react';
|
|
import { useMemo, useState } from 'react';
|
|
|
|
|
+import { useTranslation } from 'react-i18next';
|
|
|
import {
|
|
import {
|
|
|
AreaChart,
|
|
AreaChart,
|
|
|
Area,
|
|
Area,
|
|
@@ -7,55 +8,37 @@ import {
|
|
|
CartesianGrid,
|
|
CartesianGrid,
|
|
|
Tooltip,
|
|
Tooltip,
|
|
|
ResponsiveContainer,
|
|
ResponsiveContainer,
|
|
|
- BarChart,
|
|
|
|
|
- Bar,
|
|
|
|
|
PieChart,
|
|
PieChart,
|
|
|
Pie,
|
|
Pie,
|
|
|
Cell,
|
|
Cell,
|
|
|
- Legend,
|
|
|
|
|
} from 'recharts';
|
|
} from 'recharts';
|
|
|
-import type { Archive } from '../api/client';
|
|
|
|
|
|
|
+import type { ArchiveSlim } from '../api/client';
|
|
|
|
|
+import { MetricToggle, type Metric } from './MetricToggle';
|
|
|
import { parseUTCDate } from '../utils/date';
|
|
import { parseUTCDate } from '../utils/date';
|
|
|
|
|
+import { formatWeight } from '../utils/weight';
|
|
|
|
|
|
|
|
interface FilamentTrendsProps {
|
|
interface FilamentTrendsProps {
|
|
|
- archives: Archive[];
|
|
|
|
|
|
|
+ archives: ArchiveSlim[];
|
|
|
currency?: string;
|
|
currency?: string;
|
|
|
|
|
+ dateFrom?: string;
|
|
|
|
|
+ dateTo?: string;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-type TimeRange = '7d' | '30d' | '90d' | '365d' | 'all';
|
|
|
|
|
-
|
|
|
|
|
const COLORS = ['#00ae42', '#3b82f6', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899', '#14b8a6', '#f97316'];
|
|
const COLORS = ['#00ae42', '#3b82f6', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899', '#14b8a6', '#f97316'];
|
|
|
|
|
|
|
|
-function getDateRange(range: TimeRange): Date {
|
|
|
|
|
- const now = new Date();
|
|
|
|
|
- switch (range) {
|
|
|
|
|
- case '7d':
|
|
|
|
|
- return new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
|
|
|
|
- case '30d':
|
|
|
|
|
- return new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
|
|
|
|
|
- case '90d':
|
|
|
|
|
- return new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000);
|
|
|
|
|
- case '365d':
|
|
|
|
|
- return new Date(now.getTime() - 365 * 24 * 60 * 60 * 1000);
|
|
|
|
|
- case 'all':
|
|
|
|
|
- return new Date(0);
|
|
|
|
|
- }
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-export function FilamentTrends({ archives, currency = '$' }: FilamentTrendsProps) {
|
|
|
|
|
- const [timeRange, setTimeRange] = useState<TimeRange>('30d');
|
|
|
|
|
|
|
+const DAY_NAMES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
|
|
|
|
+const HOUR_SUFFIXES = ['12am', '1am', '2am', '3am', '4am', '5am', '6am', '7am', '8am', '9am', '10am', '11am', '12pm', '1pm', '2pm', '3pm', '4pm', '5pm', '6pm', '7pm', '8pm', '9pm', '10pm', '11pm'];
|
|
|
|
|
|
|
|
- // Filter archives by time range
|
|
|
|
|
- const filteredArchives = useMemo(() => {
|
|
|
|
|
- const startDate = getDateRange(timeRange);
|
|
|
|
|
- return archives.filter(a => (parseUTCDate(a.completed_at || a.created_at) || new Date(0)) >= startDate);
|
|
|
|
|
- }, [archives, timeRange]);
|
|
|
|
|
|
|
+export function FilamentTrends({ archives, currency = '$', dateFrom, dateTo }: FilamentTrendsProps) {
|
|
|
|
|
+ const { t } = useTranslation();
|
|
|
|
|
+ const [filamentTypeMetric, setFilamentTypeMetric] = useState<Metric>('weight');
|
|
|
|
|
+ const [colorMetric, setColorMetric] = useState<Metric>('weight');
|
|
|
|
|
|
|
|
// Calculate daily usage data
|
|
// Calculate daily usage data
|
|
|
const dailyData = useMemo(() => {
|
|
const dailyData = useMemo(() => {
|
|
|
const dataMap = new Map<string, { date: string; filament: number; cost: number; prints: number }>();
|
|
const dataMap = new Map<string, { date: string; filament: number; cost: number; prints: number }>();
|
|
|
|
|
|
|
|
- filteredArchives.forEach(archive => {
|
|
|
|
|
|
|
+ archives.forEach(archive => {
|
|
|
const date = parseUTCDate(archive.completed_at || archive.created_at) || new Date();
|
|
const date = parseUTCDate(archive.completed_at || archive.created_at) || new Date();
|
|
|
// Use local date string for grouping
|
|
// Use local date string for grouping
|
|
|
const key = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
|
|
const key = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
|
|
@@ -73,25 +56,69 @@ export function FilamentTrends({ archives, currency = '$' }: FilamentTrendsProps
|
|
|
...d,
|
|
...d,
|
|
|
dateLabel: new Date(d.date).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }),
|
|
dateLabel: new Date(d.date).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }),
|
|
|
}));
|
|
}));
|
|
|
- }, [filteredArchives]);
|
|
|
|
|
|
|
+ }, [archives]);
|
|
|
|
|
|
|
|
- // Calculate weekly aggregated data for longer time ranges
|
|
|
|
|
|
|
+ // Compute effective span in days from props or archive spread
|
|
|
|
|
+ const spanDays = useMemo(() => {
|
|
|
|
|
+ if (dateFrom && dateTo) {
|
|
|
|
|
+ return Math.max((new Date(dateTo).getTime() - new Date(dateFrom).getTime()) / 86400000, 0) + 1;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (dateFrom) {
|
|
|
|
|
+ return Math.max((Date.now() - new Date(dateFrom).getTime()) / 86400000, 0) + 1;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (archives.length < 2) return 0;
|
|
|
|
|
+ const times = archives.map(a => new Date(a.completed_at || a.created_at).getTime());
|
|
|
|
|
+ return (Math.max(...times) - Math.min(...times)) / 86400000;
|
|
|
|
|
+ }, [archives, dateFrom, dateTo]);
|
|
|
|
|
+
|
|
|
|
|
+ // Calculate hourly data for short timeframes (≤ 7 days)
|
|
|
|
|
+ const hourlyData = useMemo(() => {
|
|
|
|
|
+ if (spanDays > 7) return [];
|
|
|
|
|
+
|
|
|
|
|
+ const dataMap = new Map<string, { date: string; filament: number; cost: number; prints: number }>();
|
|
|
|
|
+ const multiDay = spanDays > 1;
|
|
|
|
|
+
|
|
|
|
|
+ archives.forEach(archive => {
|
|
|
|
|
+ const date = parseUTCDate(archive.completed_at || archive.created_at) || new Date();
|
|
|
|
|
+ const h = date.getHours();
|
|
|
|
|
+ const key = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}T${String(h).padStart(2, '0')}`;
|
|
|
|
|
+
|
|
|
|
|
+ const existing = dataMap.get(key) || { date: key, filament: 0, cost: 0, prints: 0 };
|
|
|
|
|
+ existing.filament += archive.filament_used_grams || 0;
|
|
|
|
|
+ existing.cost += archive.cost || 0;
|
|
|
|
|
+ existing.prints += archive.quantity || 1;
|
|
|
|
|
+ dataMap.set(key, existing);
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ return Array.from(dataMap.values())
|
|
|
|
|
+ .sort((a, b) => a.date.localeCompare(b.date))
|
|
|
|
|
+ .map(d => {
|
|
|
|
|
+ const [datePart, hourPart] = d.date.split('T');
|
|
|
|
|
+ const dt = new Date(datePart);
|
|
|
|
|
+ const h = parseInt(hourPart, 10);
|
|
|
|
|
+ const label = multiDay
|
|
|
|
|
+ ? `${DAY_NAMES[dt.getDay()]} ${HOUR_SUFFIXES[h]}`
|
|
|
|
|
+ : HOUR_SUFFIXES[h];
|
|
|
|
|
+ return { ...d, dateLabel: label };
|
|
|
|
|
+ });
|
|
|
|
|
+ }, [archives, spanDays]);
|
|
|
|
|
+
|
|
|
|
|
+ // Calculate weekly aggregated data when there are many daily points
|
|
|
const weeklyData = useMemo(() => {
|
|
const weeklyData = useMemo(() => {
|
|
|
- if (timeRange === '7d' || timeRange === '30d') return dailyData;
|
|
|
|
|
|
|
+ if (dailyData.length <= 60) return dailyData;
|
|
|
|
|
|
|
|
const dataMap = new Map<string, { week: string; filament: number; cost: number; prints: number }>();
|
|
const dataMap = new Map<string, { week: string; filament: number; cost: number; prints: number }>();
|
|
|
|
|
|
|
|
- filteredArchives.forEach(archive => {
|
|
|
|
|
- const date = parseUTCDate(archive.completed_at || archive.created_at) || new Date();
|
|
|
|
|
- // Get week start (Sunday)
|
|
|
|
|
|
|
+ dailyData.forEach(day => {
|
|
|
|
|
+ const date = new Date(day.date);
|
|
|
const weekStart = new Date(date);
|
|
const weekStart = new Date(date);
|
|
|
weekStart.setDate(date.getDate() - date.getDay());
|
|
weekStart.setDate(date.getDate() - date.getDay());
|
|
|
const key = `${weekStart.getFullYear()}-${String(weekStart.getMonth() + 1).padStart(2, '0')}-${String(weekStart.getDate()).padStart(2, '0')}`;
|
|
const key = `${weekStart.getFullYear()}-${String(weekStart.getMonth() + 1).padStart(2, '0')}-${String(weekStart.getDate()).padStart(2, '0')}`;
|
|
|
|
|
|
|
|
const existing = dataMap.get(key) || { week: key, filament: 0, cost: 0, prints: 0 };
|
|
const existing = dataMap.get(key) || { week: key, filament: 0, cost: 0, prints: 0 };
|
|
|
- existing.filament += archive.filament_used_grams || 0;
|
|
|
|
|
- existing.cost += archive.cost || 0;
|
|
|
|
|
- existing.prints += archive.quantity || 1;
|
|
|
|
|
|
|
+ existing.filament += day.filament;
|
|
|
|
|
+ existing.cost += day.cost;
|
|
|
|
|
+ existing.prints += day.prints;
|
|
|
dataMap.set(key, existing);
|
|
dataMap.set(key, existing);
|
|
|
});
|
|
});
|
|
|
|
|
|
|
@@ -102,13 +129,13 @@ export function FilamentTrends({ archives, currency = '$' }: FilamentTrendsProps
|
|
|
dateLabel: `Week of ${new Date(d.week).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}`,
|
|
dateLabel: `Week of ${new Date(d.week).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}`,
|
|
|
...d,
|
|
...d,
|
|
|
}));
|
|
}));
|
|
|
- }, [filteredArchives, dailyData, timeRange]);
|
|
|
|
|
|
|
+ }, [dailyData]);
|
|
|
|
|
|
|
|
// Usage by filament type
|
|
// Usage by filament type
|
|
|
const filamentTypeData = useMemo(() => {
|
|
const filamentTypeData = useMemo(() => {
|
|
|
const dataMap = new Map<string, number>();
|
|
const dataMap = new Map<string, number>();
|
|
|
|
|
|
|
|
- filteredArchives.forEach(archive => {
|
|
|
|
|
|
|
+ archives.forEach(archive => {
|
|
|
const type = archive.filament_type || 'Unknown';
|
|
const type = archive.filament_type || 'Unknown';
|
|
|
// Handle multiple types (e.g., "PLA, PETG")
|
|
// Handle multiple types (e.g., "PLA, PETG")
|
|
|
const types = type.split(', ');
|
|
const types = type.split(', ');
|
|
@@ -121,80 +148,119 @@ export function FilamentTrends({ archives, currency = '$' }: FilamentTrendsProps
|
|
|
return Array.from(dataMap.entries())
|
|
return Array.from(dataMap.entries())
|
|
|
.map(([name, value]) => ({ name, value: Math.round(value) }))
|
|
.map(([name, value]) => ({ name, value: Math.round(value) }))
|
|
|
.sort((a, b) => b.value - a.value);
|
|
.sort((a, b) => b.value - a.value);
|
|
|
- }, [filteredArchives]);
|
|
|
|
|
|
|
+ }, [archives]);
|
|
|
|
|
|
|
|
- // Monthly comparison data
|
|
|
|
|
- const monthlyComparison = useMemo(() => {
|
|
|
|
|
- const now = new Date();
|
|
|
|
|
- const months: { month: string; filament: number; cost: number; prints: number }[] = [];
|
|
|
|
|
|
|
+ // Usage by filament type (print count)
|
|
|
|
|
+ const filamentTypePrintData = useMemo(() => {
|
|
|
|
|
+ const dataMap = new Map<string, number>();
|
|
|
|
|
+ archives.forEach(archive => {
|
|
|
|
|
+ const type = archive.filament_type || 'Unknown';
|
|
|
|
|
+ const types = type.split(', ');
|
|
|
|
|
+ types.forEach(t => {
|
|
|
|
|
+ dataMap.set(t, (dataMap.get(t) || 0) + 1);
|
|
|
|
|
+ });
|
|
|
|
|
+ });
|
|
|
|
|
+ return Array.from(dataMap.entries())
|
|
|
|
|
+ .map(([name, value]) => ({ name, value }))
|
|
|
|
|
+ .sort((a, b) => b.value - a.value);
|
|
|
|
|
+ }, [archives]);
|
|
|
|
|
|
|
|
- for (let i = 5; i >= 0; i--) {
|
|
|
|
|
- const monthDate = new Date(now.getFullYear(), now.getMonth() - i, 1);
|
|
|
|
|
- const monthEnd = new Date(now.getFullYear(), now.getMonth() - i + 1, 0);
|
|
|
|
|
- const monthStr = monthDate.toLocaleDateString('en-US', { month: 'short', year: '2-digit' });
|
|
|
|
|
|
|
+ // Usage by filament type (print time in hours)
|
|
|
|
|
+ const filamentTypeTimeData = useMemo(() => {
|
|
|
|
|
+ const dataMap = new Map<string, number>();
|
|
|
|
|
+ archives.forEach(archive => {
|
|
|
|
|
+ const type = archive.filament_type || 'Unknown';
|
|
|
|
|
+ const types = type.split(', ');
|
|
|
|
|
+ const seconds = (archive.actual_time_seconds || archive.print_time_seconds || 0) / types.length;
|
|
|
|
|
+ types.forEach(t => {
|
|
|
|
|
+ dataMap.set(t, (dataMap.get(t) || 0) + seconds);
|
|
|
|
|
+ });
|
|
|
|
|
+ });
|
|
|
|
|
+ return Array.from(dataMap.entries())
|
|
|
|
|
+ .map(([name, seconds]) => ({ name, value: Math.round((seconds / 3600) * 10) / 10 }))
|
|
|
|
|
+ .sort((a, b) => b.value - a.value);
|
|
|
|
|
+ }, [archives]);
|
|
|
|
|
|
|
|
- const monthArchives = archives.filter(a => {
|
|
|
|
|
- const d = parseUTCDate(a.completed_at || a.created_at) || new Date(0);
|
|
|
|
|
- return d >= monthDate && d <= monthEnd;
|
|
|
|
|
|
|
+ // Success rate by filament type
|
|
|
|
|
+ const filamentSuccessData = useMemo(() => {
|
|
|
|
|
+ const map = new Map<string, { completed: number; failed: number }>();
|
|
|
|
|
+ archives.forEach(a => {
|
|
|
|
|
+ if (a.status !== 'completed' && a.status !== 'failed') return;
|
|
|
|
|
+ const types = (a.filament_type || 'Unknown').split(', ');
|
|
|
|
|
+ types.forEach(type => {
|
|
|
|
|
+ const entry = map.get(type) || { completed: 0, failed: 0 };
|
|
|
|
|
+ if (a.status === 'completed') entry.completed++;
|
|
|
|
|
+ else entry.failed++;
|
|
|
|
|
+ map.set(type, entry);
|
|
|
});
|
|
});
|
|
|
|
|
+ });
|
|
|
|
|
+ return Array.from(map.entries())
|
|
|
|
|
+ .filter(([, v]) => v.completed + v.failed >= 2)
|
|
|
|
|
+ .map(([name, v]) => {
|
|
|
|
|
+ const total = v.completed + v.failed;
|
|
|
|
|
+ const rate = Math.round((v.completed / total) * 100);
|
|
|
|
|
+ return { name, rate, total };
|
|
|
|
|
+ })
|
|
|
|
|
+ .sort((a, b) => b.rate - a.rate);
|
|
|
|
|
+ }, [archives]);
|
|
|
|
|
+
|
|
|
|
|
+ // Color distribution
|
|
|
|
|
+ const colorData = useMemo(() => {
|
|
|
|
|
+ const colorMap = new Map<string, { count: number; weight: number }>();
|
|
|
|
|
+
|
|
|
|
|
+ archives.forEach(a => {
|
|
|
|
|
+ if (!a.filament_color) return;
|
|
|
|
|
+ const colors = a.filament_color.split(',').map(c => c.trim());
|
|
|
|
|
+ const weightPerColor = (a.filament_used_grams || 0) / colors.length;
|
|
|
|
|
|
|
|
- months.push({
|
|
|
|
|
- month: monthStr,
|
|
|
|
|
- filament: Math.round(monthArchives.reduce((sum, a) => sum + (a.filament_used_grams || 0), 0)),
|
|
|
|
|
- cost: monthArchives.reduce((sum, a) => sum + (a.cost || 0), 0),
|
|
|
|
|
- prints: monthArchives.reduce((sum, a) => sum + (a.quantity || 1), 0),
|
|
|
|
|
|
|
+ colors.forEach(hex => {
|
|
|
|
|
+ const entry = colorMap.get(hex) || { count: 0, weight: 0 };
|
|
|
|
|
+ entry.count++;
|
|
|
|
|
+ entry.weight += weightPerColor;
|
|
|
|
|
+ colorMap.set(hex, entry);
|
|
|
});
|
|
});
|
|
|
- }
|
|
|
|
|
|
|
+ });
|
|
|
|
|
|
|
|
- return months;
|
|
|
|
|
- }, [archives]);
|
|
|
|
|
|
|
+ return Array.from(colorMap.entries())
|
|
|
|
|
+ .map(([hex, data]) => ({
|
|
|
|
|
+ hex,
|
|
|
|
|
+ value: colorMetric === 'prints' ? data.count : Math.round(data.weight),
|
|
|
|
|
+ }))
|
|
|
|
|
+ .sort((a, b) => b.value - a.value);
|
|
|
|
|
+ }, [archives, colorMetric]);
|
|
|
|
|
|
|
|
- const chartData = timeRange === '7d' || timeRange === '30d' ? dailyData : weeklyData;
|
|
|
|
|
- const totalFilament = filteredArchives.reduce((sum, a) => sum + (a.filament_used_grams || 0), 0);
|
|
|
|
|
- const totalCost = filteredArchives.reduce((sum, a) => sum + (a.cost || 0), 0);
|
|
|
|
|
- const totalPrints = filteredArchives.reduce((sum, a) => sum + (a.quantity || 1), 0);
|
|
|
|
|
|
|
+ const activeFilamentTypeData =
|
|
|
|
|
+ filamentTypeMetric === 'weight' ? filamentTypeData :
|
|
|
|
|
+ filamentTypeMetric === 'prints' ? filamentTypePrintData :
|
|
|
|
|
+ filamentTypeTimeData;
|
|
|
|
|
|
|
|
- return (
|
|
|
|
|
- <div className="space-y-6">
|
|
|
|
|
- {/* Time Range Selector */}
|
|
|
|
|
- <div className="flex items-center justify-between max-[550px]:flex-col max-[550px]:items-start max-[550px]:gap-2">
|
|
|
|
|
- <h3 className="text-lg font-semibold text-white">Filament Usage Trends</h3>
|
|
|
|
|
- <div className="flex gap-1 bg-bambu-dark rounded-lg p-1">
|
|
|
|
|
- {(['7d', '30d', '90d', '365d', 'all'] as TimeRange[]).map((range) => (
|
|
|
|
|
- <button
|
|
|
|
|
- key={range}
|
|
|
|
|
- onClick={() => setTimeRange(range)}
|
|
|
|
|
- className={`px-3 py-1 text-sm rounded-md transition-colors ${
|
|
|
|
|
- timeRange === range
|
|
|
|
|
- ? 'bg-bambu-green text-white'
|
|
|
|
|
- : 'text-bambu-gray hover:text-white'
|
|
|
|
|
- }`}
|
|
|
|
|
- >
|
|
|
|
|
- {range === 'all' ? 'All' : range.replace('d', 'D')}
|
|
|
|
|
- </button>
|
|
|
|
|
- ))}
|
|
|
|
|
- </div>
|
|
|
|
|
- </div>
|
|
|
|
|
|
|
+ const chartData = spanDays <= 7 && hourlyData.length > 0 ? hourlyData : weeklyData;
|
|
|
|
|
+ const totalFilament = archives.reduce((sum, a) => sum + (a.filament_used_grams || 0), 0);
|
|
|
|
|
+ const totalCost = archives.reduce((sum, a) => sum + (a.cost || 0), 0);
|
|
|
|
|
+ const totalPrints = archives.reduce((sum, a) => sum + (a.quantity || 1), 0);
|
|
|
|
|
+ const printerCount = new Set(archives.map(a => a.printer_id).filter(Boolean)).size;
|
|
|
|
|
|
|
|
|
|
+ return (
|
|
|
|
|
+ <div className="space-y-4">
|
|
|
{/* Summary Cards */}
|
|
{/* Summary Cards */}
|
|
|
- <div className="grid grid-cols-3 gap-4 max-[640px]:grid-cols-1">
|
|
|
|
|
|
|
+ <div className="grid grid-cols-3 gap-2 max-[640px]:grid-cols-1">
|
|
|
<div className="bg-bambu-dark rounded-lg p-4">
|
|
<div className="bg-bambu-dark rounded-lg p-4">
|
|
|
- <div className="flex items-center justify-between gap-3">
|
|
|
|
|
- <p className="text-sm text-bambu-gray leading-none">Period Filament</p>
|
|
|
|
|
- <p className="text-2xl font-bold text-white leading-none">{(totalFilament / 1000).toFixed(2)}kg</p>
|
|
|
|
|
|
|
+ <div className="flex items-center justify-between gap-2">
|
|
|
|
|
+ <p className="text-sm text-bambu-gray leading-none">{t('stats.periodFilament')}</p>
|
|
|
|
|
+ <p className="text-2xl font-bold text-white leading-none">{formatWeight(totalFilament)}</p>
|
|
|
</div>
|
|
</div>
|
|
|
- <p className="text-xs text-bambu-gray">{totalFilament.toFixed(0)}g total</p>
|
|
|
|
|
|
|
+ <p className="text-xs text-bambu-gray">{printerCount} {t('nav.printers').toLowerCase()}</p>
|
|
|
</div>
|
|
</div>
|
|
|
<div className="bg-bambu-dark rounded-lg p-4">
|
|
<div className="bg-bambu-dark rounded-lg p-4">
|
|
|
- <div className="flex items-center justify-between gap-3">
|
|
|
|
|
- <p className="text-sm text-bambu-gray leading-none">Period Cost</p>
|
|
|
|
|
|
|
+ <div className="flex items-center justify-between gap-2">
|
|
|
|
|
+ <p className="text-sm text-bambu-gray leading-none">{t('stats.periodCost')}</p>
|
|
|
<p className="text-2xl font-bold text-white leading-none">{currency}{totalCost.toFixed(2)}</p>
|
|
<p className="text-2xl font-bold text-white leading-none">{currency}{totalCost.toFixed(2)}</p>
|
|
|
</div>
|
|
</div>
|
|
|
- <p className="text-xs text-bambu-gray">{totalPrints} prints</p>
|
|
|
|
|
|
|
+ <p className="text-xs text-bambu-gray">{totalPrints} {t('common.prints')}</p>
|
|
|
</div>
|
|
</div>
|
|
|
<div className="bg-bambu-dark rounded-lg p-4">
|
|
<div className="bg-bambu-dark rounded-lg p-4">
|
|
|
- <div className="flex items-center justify-between gap-3">
|
|
|
|
|
- <p className="text-sm text-bambu-gray leading-none">Avg per Print</p>
|
|
|
|
|
|
|
+ <div className="flex items-center justify-between gap-2">
|
|
|
|
|
+ <p className="text-sm text-bambu-gray leading-none">{t('stats.avgPerPrint')}</p>
|
|
|
<p className="text-2xl font-bold text-white leading-none">
|
|
<p className="text-2xl font-bold text-white leading-none">
|
|
|
{totalPrints > 0
|
|
{totalPrints > 0
|
|
|
? (totalFilament / totalPrints).toFixed(0)
|
|
? (totalFilament / totalPrints).toFixed(0)
|
|
@@ -210,7 +276,7 @@ export function FilamentTrends({ archives, currency = '$' }: FilamentTrendsProps
|
|
|
{/* Usage Over Time Chart */}
|
|
{/* Usage Over Time Chart */}
|
|
|
{chartData.length > 0 ? (
|
|
{chartData.length > 0 ? (
|
|
|
<div className="bg-bambu-dark rounded-lg p-4">
|
|
<div className="bg-bambu-dark rounded-lg p-4">
|
|
|
- <h4 className="text-sm font-medium text-bambu-gray mb-4">Usage Over Time</h4>
|
|
|
|
|
|
|
+ <h4 className="text-sm font-medium text-bambu-gray mb-4">{t('stats.usageOverTime')}</h4>
|
|
|
<ResponsiveContainer width="100%" height={250}>
|
|
<ResponsiveContainer width="100%" height={250}>
|
|
|
<AreaChart data={chartData}>
|
|
<AreaChart data={chartData}>
|
|
|
<defs>
|
|
<defs>
|
|
@@ -253,21 +319,24 @@ export function FilamentTrends({ archives, currency = '$' }: FilamentTrendsProps
|
|
|
</div>
|
|
</div>
|
|
|
) : (
|
|
) : (
|
|
|
<div className="bg-bambu-dark rounded-lg p-8 text-center text-bambu-gray">
|
|
<div className="bg-bambu-dark rounded-lg p-8 text-center text-bambu-gray">
|
|
|
- No data for selected time range
|
|
|
|
|
|
|
+ {t('stats.noPrintDataInRange')}
|
|
|
</div>
|
|
</div>
|
|
|
)}
|
|
)}
|
|
|
|
|
|
|
|
{/* Bottom Charts */}
|
|
{/* Bottom Charts */}
|
|
|
- <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
|
|
|
|
|
+ <div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
|
|
{/* Filament Type Distribution */}
|
|
{/* Filament Type Distribution */}
|
|
|
<div className="bg-bambu-dark rounded-lg p-4">
|
|
<div className="bg-bambu-dark rounded-lg p-4">
|
|
|
- <h4 className="text-sm font-medium text-bambu-gray mb-4">By Filament Type</h4>
|
|
|
|
|
- {filamentTypeData.length > 0 ? (
|
|
|
|
|
|
|
+ <div className="flex items-center justify-between mb-4">
|
|
|
|
|
+ <h4 className="text-sm font-medium text-bambu-gray">{t('stats.byMaterial')}</h4>
|
|
|
|
|
+ <MetricToggle value={filamentTypeMetric} onChange={setFilamentTypeMetric} />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ {activeFilamentTypeData.length > 0 ? (
|
|
|
<div className="flex items-center gap-4">
|
|
<div className="flex items-center gap-4">
|
|
|
<ResponsiveContainer width={160} height={160}>
|
|
<ResponsiveContainer width={160} height={160}>
|
|
|
<PieChart>
|
|
<PieChart>
|
|
|
<Pie
|
|
<Pie
|
|
|
- data={filamentTypeData}
|
|
|
|
|
|
|
+ data={activeFilamentTypeData}
|
|
|
cx="50%"
|
|
cx="50%"
|
|
|
cy="50%"
|
|
cy="50%"
|
|
|
innerRadius={40}
|
|
innerRadius={40}
|
|
@@ -275,7 +344,7 @@ export function FilamentTrends({ archives, currency = '$' }: FilamentTrendsProps
|
|
|
paddingAngle={2}
|
|
paddingAngle={2}
|
|
|
dataKey="value"
|
|
dataKey="value"
|
|
|
>
|
|
>
|
|
|
- {filamentTypeData.map((_, index) => (
|
|
|
|
|
|
|
+ {activeFilamentTypeData.map((_, index) => (
|
|
|
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
|
|
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
|
|
|
))}
|
|
))}
|
|
|
</Pie>
|
|
</Pie>
|
|
@@ -285,13 +354,18 @@ export function FilamentTrends({ archives, currency = '$' }: FilamentTrendsProps
|
|
|
border: '1px solid #3d3d3d',
|
|
border: '1px solid #3d3d3d',
|
|
|
borderRadius: '8px',
|
|
borderRadius: '8px',
|
|
|
}}
|
|
}}
|
|
|
- formatter={(value) => [`${value ?? 0}g`, 'Usage']}
|
|
|
|
|
|
|
+ formatter={(value) => [
|
|
|
|
|
+ filamentTypeMetric === 'weight' ? formatWeight(Number(value ?? 0)) :
|
|
|
|
|
+ filamentTypeMetric === 'time' ? `${Number(value ?? 0)}h` :
|
|
|
|
|
+ `${value ?? 0}`,
|
|
|
|
|
+ filamentTypeMetric === 'weight' ? 'Usage' : filamentTypeMetric === 'time' ? 'Time' : 'Prints',
|
|
|
|
|
+ ]}
|
|
|
/>
|
|
/>
|
|
|
</PieChart>
|
|
</PieChart>
|
|
|
</ResponsiveContainer>
|
|
</ResponsiveContainer>
|
|
|
<div className="flex-1 space-y-2 overflow-hidden">
|
|
<div className="flex-1 space-y-2 overflow-hidden">
|
|
|
- {filamentTypeData.map((entry, index) => {
|
|
|
|
|
- const total = filamentTypeData.reduce((sum, e) => sum + e.value, 0);
|
|
|
|
|
|
|
+ {activeFilamentTypeData.map((entry, index) => {
|
|
|
|
|
+ const total = activeFilamentTypeData.reduce((sum, e) => sum + e.value, 0);
|
|
|
const percent = total > 0 ? ((entry.value / total) * 100).toFixed(0) : 0;
|
|
const percent = total > 0 ? ((entry.value / total) * 100).toFixed(0) : 0;
|
|
|
return (
|
|
return (
|
|
|
<div key={entry.name} className="flex items-center gap-2 text-sm">
|
|
<div key={entry.name} className="flex items-center gap-2 text-sm">
|
|
@@ -300,7 +374,11 @@ export function FilamentTrends({ archives, currency = '$' }: FilamentTrendsProps
|
|
|
style={{ backgroundColor: COLORS[index % COLORS.length] }}
|
|
style={{ backgroundColor: COLORS[index % COLORS.length] }}
|
|
|
/>
|
|
/>
|
|
|
<span className="text-white truncate flex-1">{entry.name}</span>
|
|
<span className="text-white truncate flex-1">{entry.name}</span>
|
|
|
- <span className="text-bambu-gray flex-shrink-0">{percent}%</span>
|
|
|
|
|
|
|
+ <span className="text-bambu-gray flex-shrink-0">
|
|
|
|
|
+ {filamentTypeMetric === 'weight' ? formatWeight(entry.value) :
|
|
|
|
|
+ filamentTypeMetric === 'time' ? `${entry.value}h` :
|
|
|
|
|
+ entry.value} · {percent}%
|
|
|
|
|
+ </span>
|
|
|
</div>
|
|
</div>
|
|
|
);
|
|
);
|
|
|
})}
|
|
})}
|
|
@@ -308,34 +386,115 @@ export function FilamentTrends({ archives, currency = '$' }: FilamentTrendsProps
|
|
|
</div>
|
|
</div>
|
|
|
) : (
|
|
) : (
|
|
|
<div className="h-[160px] flex items-center justify-center text-bambu-gray">
|
|
<div className="h-[160px] flex items-center justify-center text-bambu-gray">
|
|
|
- No filament data
|
|
|
|
|
|
|
+ {t('stats.noFilamentData')}
|
|
|
</div>
|
|
</div>
|
|
|
)}
|
|
)}
|
|
|
</div>
|
|
</div>
|
|
|
|
|
|
|
|
- {/* Monthly Comparison */}
|
|
|
|
|
|
|
+ {/* Success by Material */}
|
|
|
<div className="bg-bambu-dark rounded-lg p-4">
|
|
<div className="bg-bambu-dark rounded-lg p-4">
|
|
|
- <h4 className="text-sm font-medium text-bambu-gray mb-4">Monthly Comparison</h4>
|
|
|
|
|
- <ResponsiveContainer width="100%" height={200}>
|
|
|
|
|
- <BarChart data={monthlyComparison}>
|
|
|
|
|
- <CartesianGrid strokeDasharray="3 3" stroke="#3d3d3d" />
|
|
|
|
|
- <XAxis dataKey="month" stroke="#9ca3af" tick={{ fontSize: 12 }} />
|
|
|
|
|
- <YAxis stroke="#9ca3af" tick={{ fontSize: 12 }} tickFormatter={(v) => `${v}g`} />
|
|
|
|
|
- <Tooltip
|
|
|
|
|
- contentStyle={{
|
|
|
|
|
- backgroundColor: '#2d2d2d',
|
|
|
|
|
- border: '1px solid #3d3d3d',
|
|
|
|
|
- borderRadius: '8px',
|
|
|
|
|
- }}
|
|
|
|
|
- formatter={(value, name) => [
|
|
|
|
|
- name === 'filament' ? `${value ?? 0}g` : name === 'cost' ? `${currency}${Number(value ?? 0).toFixed(2)}` : value ?? 0,
|
|
|
|
|
- name === 'filament' ? 'Filament' : name === 'cost' ? 'Cost' : 'Prints'
|
|
|
|
|
- ]}
|
|
|
|
|
- />
|
|
|
|
|
- <Legend />
|
|
|
|
|
- <Bar dataKey="filament" name="Filament (g)" fill="#00ae42" radius={[4, 4, 0, 0]} />
|
|
|
|
|
- </BarChart>
|
|
|
|
|
- </ResponsiveContainer>
|
|
|
|
|
|
|
+ <h4 className="text-sm font-medium text-bambu-gray mb-4">{t('stats.filamentSuccess')}</h4>
|
|
|
|
|
+ {filamentSuccessData.length > 0 ? (
|
|
|
|
|
+ <div className="space-y-1.5">
|
|
|
|
|
+ {filamentSuccessData.map(d => (
|
|
|
|
|
+ <div key={d.name} className="flex items-center gap-2 text-sm">
|
|
|
|
|
+ <span className="text-white truncate w-20 flex-shrink-0">{d.name}</span>
|
|
|
|
|
+ <div className="flex-1 h-1.5 bg-bambu-dark-secondary rounded-full">
|
|
|
|
|
+ <div
|
|
|
|
|
+ className={`h-full rounded-full transition-all ${
|
|
|
|
|
+ d.rate >= 90 ? 'bg-status-ok' : d.rate >= 70 ? 'bg-status-warning' : 'bg-status-error'
|
|
|
|
|
+ }`}
|
|
|
|
|
+ style={{ width: `${d.rate}%` }}
|
|
|
|
|
+ />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <span className={`font-medium flex-shrink-0 tabular-nums ${
|
|
|
|
|
+ d.rate >= 90 ? 'text-status-ok' : d.rate >= 70 ? 'text-status-warning' : 'text-status-error'
|
|
|
|
|
+ }`}>
|
|
|
|
|
+ {d.rate}%
|
|
|
|
|
+ </span>
|
|
|
|
|
+ <span className="text-bambu-gray flex-shrink-0 text-xs">({d.total})</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ ))}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ ) : (
|
|
|
|
|
+ <div className="h-[160px] flex items-center justify-center text-bambu-gray">
|
|
|
|
|
+ {t('stats.noArchiveData')}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ {/* Color Distribution */}
|
|
|
|
|
+ <div className="bg-bambu-dark rounded-lg p-4">
|
|
|
|
|
+ <div className="flex items-center justify-between mb-4">
|
|
|
|
|
+ <h4 className="text-sm font-medium text-bambu-gray">{t('stats.colorDistribution')}</h4>
|
|
|
|
|
+ <MetricToggle value={colorMetric} onChange={setColorMetric} exclude={['time']} />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ {colorData.length > 0 ? (() => {
|
|
|
|
|
+ const colorTotal = colorData.reduce((sum, e) => sum + e.value, 0);
|
|
|
|
|
+ return (
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <div className="relative mx-auto" style={{ width: 160, height: 160 }}>
|
|
|
|
|
+ <ResponsiveContainer width="100%" height="100%">
|
|
|
|
|
+ <PieChart>
|
|
|
|
|
+ <Pie
|
|
|
|
|
+ data={colorData}
|
|
|
|
|
+ cx="50%"
|
|
|
|
|
+ cy="50%"
|
|
|
|
|
+ innerRadius={45}
|
|
|
|
|
+ outerRadius={70}
|
|
|
|
|
+ paddingAngle={2}
|
|
|
|
|
+ dataKey="value"
|
|
|
|
|
+ >
|
|
|
|
|
+ {colorData.map((entry, index) => (
|
|
|
|
|
+ <Cell key={`color-${index}`} fill={entry.hex} stroke="#1a1a1a" strokeWidth={1} />
|
|
|
|
|
+ ))}
|
|
|
|
|
+ </Pie>
|
|
|
|
|
+ <Tooltip
|
|
|
|
|
+ contentStyle={{
|
|
|
|
|
+ backgroundColor: '#2d2d2d',
|
|
|
|
|
+ border: '1px solid #3d3d3d',
|
|
|
|
|
+ borderRadius: '8px',
|
|
|
|
|
+ }}
|
|
|
|
|
+ formatter={(value) => [
|
|
|
|
|
+ colorMetric === 'weight' ? formatWeight(Number(value ?? 0)) : `${value ?? 0}`,
|
|
|
|
|
+ colorMetric === 'weight' ? t('stats.filamentByWeight') : t('stats.filamentByPrints'),
|
|
|
|
|
+ ]}
|
|
|
|
|
+ />
|
|
|
|
|
+ </PieChart>
|
|
|
|
|
+ </ResponsiveContainer>
|
|
|
|
|
+ <div className="absolute inset-0 flex flex-col items-center justify-center">
|
|
|
|
|
+ <span className="text-lg font-bold text-white">
|
|
|
|
|
+ {colorMetric === 'weight' ? formatWeight(colorTotal) : colorTotal}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ <span className="text-[10px] text-bambu-gray">
|
|
|
|
|
+ {colorData.length} {colorData.length === 1 ? 'color' : 'colors'}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div className="grid grid-cols-2 gap-x-3 gap-y-1 mt-2">
|
|
|
|
|
+ {colorData.slice(0, 8).map((entry) => {
|
|
|
|
|
+ const percent = colorTotal > 0 ? ((entry.value / colorTotal) * 100).toFixed(0) : 0;
|
|
|
|
|
+ return (
|
|
|
|
|
+ <div key={entry.hex} className="flex items-center gap-1.5 text-xs min-w-0">
|
|
|
|
|
+ <div className="w-2.5 h-2.5 rounded-full flex-shrink-0 border border-white/20"
|
|
|
|
|
+ style={{ backgroundColor: entry.hex }} />
|
|
|
|
|
+ <span className="text-bambu-gray truncate">
|
|
|
|
|
+ {percent}%
|
|
|
|
|
+ </span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ );
|
|
|
|
|
+ })}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ {colorData.length > 8 && (
|
|
|
|
|
+ <p className="text-[10px] text-bambu-gray mt-1 text-center">+{colorData.length - 8} more</p>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ );
|
|
|
|
|
+ })() : (
|
|
|
|
|
+ <div className="h-[160px] flex items-center justify-center text-bambu-gray">
|
|
|
|
|
+ {t('stats.noColorData')}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
</div>
|
|
</div>
|
|
|
</div>
|
|
</div>
|
|
|
</div>
|
|
</div>
|