Ver código fonte

feat(stats): energy usage in cost records and trends (#1432)

The Most Expensive record on the Statistics page ranked prints by
filament cost alone, ignoring the per-print energy cost Bambuddy
already measures via an attached smart plug. It now ranks by
filament + measured energy cost; prints without smart-plug data
compete on filament cost alone, as before.

Filament Trends gains an Energy Over Time chart: kWh per day (per
hour for ranges of a week or less, per week for long ranges), with
the range's total kWh and energy cost in the header. The chart only
renders when the selected range contains measured energy data, so
setups without smart plugs see no change.

The /archives/slim stats feed now carries each run's energy_kwh /
energy_cost from print_log_entries. Translated in all locales.
Covered by backend and frontend tests.
maziggy 1 mês atrás
pai
commit
d68724c689

+ 3 - 0
CHANGELOG.md

@@ -4,6 +4,9 @@ All notable changes to Bambuddy will be documented in this file.
 
 
 ## [1.2.6b1] - Unreleased
 ## [1.2.6b1] - Unreleased
 
 
+### Added
+- **Energy usage now feeds the statistics that previously only knew about filament (#1432)** — Bambuddy has measured per-print energy via an attached smart plug for a while (the plug's lifetime counter is captured at print start and the delta stored with the print), but two stats surfaces ignored it. First, the **Most Expensive** record on the Statistics page ranked prints by filament cost alone, so a cheap-filament print with hours of heated-chamber time could never win; it now ranks by filament + measured energy cost (prints without a smart plug simply compete on filament cost, as before). Second, **Filament Trends** gained an **Energy Over Time** chart — per-day kWh (per-hour for short ranges, per-week for long ones), with the range's total kWh and energy cost in the header. The chart only appears when the selected range actually contains measured energy data, so setups without smart plugs see no change. The `/archives/slim` stats feed now carries each run's `energy_kwh`/`energy_cost`. Translated in all locales. Covered by backend and frontend tests.
+
 ### Fixed
 ### Fixed
 - **The spool PA-Profil (Pressure Advance) picker only ever offered the 0.4mm K-profile, hiding nozzle-specific profiles for the same filament on multi-nozzle printers (#2618)** — When a printer had two K-profiles for one filament differing only in nozzle size (e.g. PAHT-CF at 0.4mm K=0.042 and 0.6mm K=0.028), the **Edit Spool → PA-Profil** tab (and the SpoolBuddy write-tag page, which shares the picker) showed only the 0.4mm entry ("1 match, K=0.042"), regardless of the nozzle actually installed. **Root cause.** Both surfaces fetched a printer's calibrations with `getKProfiles(printer.id)`, which defaults the nozzle filter to `0.4` — and the printer/MQTT layer filters strictly by that diameter, so the 0.6mm profile was never retrieved. (The AMS-Slot config dialog was already fixed for this in #1899; these two pickers were not.) **Fix.** The picker now queries every nozzle the printer reports installed (`0.4`, `0.6`, …) and merges the results, falling back to `0.4` only when the printer hasn't reported its nozzle hardware. Each profile row now also shows a nozzle-diameter badge so two identically-named profiles are distinguishable. Frontend-only. Covered by tests for the nozzle enumeration and the two-profile rendering.
 - **The spool PA-Profil (Pressure Advance) picker only ever offered the 0.4mm K-profile, hiding nozzle-specific profiles for the same filament on multi-nozzle printers (#2618)** — When a printer had two K-profiles for one filament differing only in nozzle size (e.g. PAHT-CF at 0.4mm K=0.042 and 0.6mm K=0.028), the **Edit Spool → PA-Profil** tab (and the SpoolBuddy write-tag page, which shares the picker) showed only the 0.4mm entry ("1 match, K=0.042"), regardless of the nozzle actually installed. **Root cause.** Both surfaces fetched a printer's calibrations with `getKProfiles(printer.id)`, which defaults the nozzle filter to `0.4` — and the printer/MQTT layer filters strictly by that diameter, so the 0.6mm profile was never retrieved. (The AMS-Slot config dialog was already fixed for this in #1899; these two pickers were not.) **Fix.** The picker now queries every nozzle the printer reports installed (`0.4`, `0.6`, …) and merges the results, falling back to `0.4` only when the printer hasn't reported its nozzle hardware. Each profile row now also shows a nozzle-diameter badge so two identically-named profiles are distinguishable. Frontend-only. Covered by tests for the nozzle enumeration and the two-profile rendering.
 - **Print-archive backups to a Gitea or Forgejo instance hosted under a URL path prefix could not be configured — the repository URL failed to parse (#2642, reporter @M1ndHunteR)** — Self-hosted Gitea/Forgejo is often served under a subpath (`ROOT_URL` like `https://host/gitea`), so repositories live at `https://host/gitea/owner/repo` rather than at the host root. **Root cause.** The Gitea backend (shared by Forgejo) assumed the repo sat directly under the host: URL parsing required exactly two path segments after the hostname, so a subpath URL's three segments (`gitea/owner/repo`) matched nothing and raised "Cannot parse repository URL". Even had it parsed, the API base was derived from scheme+host only, yielding `https://host/api/v1` instead of `https://host/gitea/api/v1`, so every API call would have 404'd. **Fix.** The Gitea/Forgejo backend now treats the final two path segments as `owner`/`repo` and keeps any leading segments as a base-path prefix, deriving the API base as `{scheme}://{host}{prefix}/api/v1`. Root-hosted instances are unaffected (empty prefix). GitHub/GitLab are untouched. Covered by parse and API-base tests for both providers.
 - **Print-archive backups to a Gitea or Forgejo instance hosted under a URL path prefix could not be configured — the repository URL failed to parse (#2642, reporter @M1ndHunteR)** — Self-hosted Gitea/Forgejo is often served under a subpath (`ROOT_URL` like `https://host/gitea`), so repositories live at `https://host/gitea/owner/repo` rather than at the host root. **Root cause.** The Gitea backend (shared by Forgejo) assumed the repo sat directly under the host: URL parsing required exactly two path segments after the hostname, so a subpath URL's three segments (`gitea/owner/repo`) matched nothing and raised "Cannot parse repository URL". Even had it parsed, the API base was derived from scheme+host only, yielding `https://host/api/v1` instead of `https://host/gitea/api/v1`, so every API call would have 404'd. **Fix.** The Gitea/Forgejo backend now treats the final two path segments as `owner`/`repo` and keeps any leading segments as a base-path prefix, deriving the API base as `{scheme}://{host}{prefix}/api/v1`. Root-hosted instances are unaffected (empty prefix). GitHub/GitLab are untouched. Covered by parse and API-base tests for both providers.

+ 4 - 0
backend/app/api/routes/archives.py

@@ -570,6 +570,8 @@ async def list_archives_slim(
             PrintLogEntry.filament_color,
             PrintLogEntry.filament_color,
             PrintLogEntry.status,
             PrintLogEntry.status,
             PrintLogEntry.cost,
             PrintLogEntry.cost,
+            PrintLogEntry.energy_kwh,
+            PrintLogEntry.energy_cost,
             PrintLogEntry.created_at,
             PrintLogEntry.created_at,
         )
         )
         .outerjoin(PrintArchive, PrintArchive.id == PrintLogEntry.archive_id)
         .outerjoin(PrintArchive, PrintArchive.id == PrintLogEntry.archive_id)
@@ -612,6 +614,8 @@ async def list_archives_slim(
             "started_at": r.started_at,
             "started_at": r.started_at,
             "completed_at": r.completed_at,
             "completed_at": r.completed_at,
             "cost": r.cost,
             "cost": r.cost,
+            "energy_kwh": r.energy_kwh,
+            "energy_cost": r.energy_cost,
             "quantity": 1,
             "quantity": 1,
             "created_at": r.created_at,
             "created_at": r.created_at,
         }
         }

+ 2 - 0
backend/app/schemas/archive.py

@@ -137,6 +137,8 @@ class ArchiveSlim(BaseModel):
     started_at: datetime | None
     started_at: datetime | None
     completed_at: datetime | None
     completed_at: datetime | None
     cost: float | None
     cost: float | None
+    energy_kwh: float | None = None
+    energy_cost: float | None = None
     quantity: int = 1
     quantity: int = 1
     created_at: datetime | None
     created_at: datetime | None
 
 

+ 23 - 0
backend/tests/integration/test_archives_api.py

@@ -1022,6 +1022,29 @@ class TestArchivesSlimAPI:
         assert "duplicates" not in item
         assert "duplicates" not in item
         assert "duplicate_count" not in item
         assert "duplicate_count" not in item
 
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_slim_includes_energy_fields(
+        self, async_client: AsyncClient, archive_factory, printer_factory, db_session
+    ):
+        """Per-print smart-plug energy surfaces through /slim so the stats
+        page can include it in cost records and trends (#1432)."""
+        printer = await printer_factory()
+        await archive_factory(
+            printer.id,
+            status="completed",
+            cost=1.50,
+            energy_kwh=0.421,
+            energy_cost=0.063,
+        )
+
+        response = await async_client.get("/api/v1/archives/slim")
+
+        assert response.status_code == 200
+        item = response.json()[0]
+        assert item["energy_kwh"] == 0.421
+        assert item["energy_cost"] == 0.063
+
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
     async def test_slim_computes_actual_time(
     async def test_slim_computes_actual_time(

+ 40 - 0
frontend/src/__tests__/pages/StatsPage.test.tsx

@@ -488,6 +488,46 @@ describe('StatsPage', () => {
       });
       });
     });
     });
 
 
+    it('Most Expensive includes per-print energy cost (#1432)', async () => {
+      // Cheap filament + expensive heated-chamber print must outrank a
+      // pricier-filament print once measured energy cost is added in.
+      server.use(
+        http.get('/api/v1/archives/slim', () =>
+          HttpResponse.json([
+            {
+              id: 20, created_at: '2024-03-01T10:00:00Z',
+              started_at: '2024-03-01T10:00:00Z',
+              completed_at: '2024-03-01T14:00:00Z',
+              print_name: 'PLA Filament Heavy', status: 'completed',
+              printer_id: 1, filament_type: 'PLA', filament_color: '#00FF00',
+              filament_used_grams: 200, actual_time_seconds: 14400,
+              print_time_seconds: 14000, cost: 5.00,
+              energy_kwh: null, energy_cost: null, quantity: 1,
+            },
+            {
+              id: 21, created_at: '2024-03-02T10:00:00Z',
+              started_at: '2024-03-02T10:00:00Z',
+              completed_at: '2024-03-02T20:00:00Z',
+              print_name: 'ABS Energy Hog', status: 'completed',
+              printer_id: 1, filament_type: 'ABS', filament_color: '#FF0000',
+              filament_used_grams: 100, actual_time_seconds: 36000,
+              print_time_seconds: 35000, cost: 4.00,
+              energy_kwh: 8.5, energy_cost: 2.55, quantity: 1,
+            },
+          ]),
+        ),
+      );
+
+      render(<StatsPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Most Expensive')).toBeInTheDocument();
+      });
+      // 4.00 + 2.55 = 6.55 beats 5.00 flat
+      expect(screen.getByText('$6.55')).toBeInTheDocument();
+      expect(screen.getAllByText('ABS Energy Hog').length).toBeGreaterThan(0);
+    });
+
     it('shows success streak record', async () => {
     it('shows success streak record', async () => {
       render(<StatsPage />);
       render(<StatsPage />);
 
 

+ 2 - 0
frontend/src/api/client.ts

@@ -727,6 +727,8 @@ export interface ArchiveSlim {
   started_at: string | null;
   started_at: string | null;
   completed_at: string | null;
   completed_at: string | null;
   cost: number | null;
   cost: number | null;
+  energy_kwh: number | null;
+  energy_cost: number | null;
   quantity: number;
   quantity: number;
   created_at: string;
   created_at: string;
 }
 }

+ 62 - 6
frontend/src/components/FilamentTrends.tsx

@@ -36,16 +36,17 @@ export function FilamentTrends({ archives, currency = '$', dateFrom, dateTo }: F
 
 
   // 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; energy: number; prints: number }>();
 
 
     archives.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')}`;
 
 
-      const existing = dataMap.get(key) || { date: key, filament: 0, cost: 0, prints: 0 };
+      const existing = dataMap.get(key) || { date: key, filament: 0, cost: 0, energy: 0, prints: 0 };
       existing.filament += archive.filament_used_grams || 0;
       existing.filament += archive.filament_used_grams || 0;
       existing.cost += archive.cost || 0;
       existing.cost += archive.cost || 0;
+      existing.energy += archive.energy_kwh || 0;
       existing.prints += archive.quantity || 1;
       existing.prints += archive.quantity || 1;
       dataMap.set(key, existing);
       dataMap.set(key, existing);
     });
     });
@@ -75,7 +76,7 @@ export function FilamentTrends({ archives, currency = '$', dateFrom, dateTo }: F
   const hourlyData = useMemo(() => {
   const hourlyData = useMemo(() => {
     if (spanDays > 7) return [];
     if (spanDays > 7) return [];
 
 
-    const dataMap = new Map<string, { date: string; filament: number; cost: number; prints: number }>();
+    const dataMap = new Map<string, { date: string; filament: number; cost: number; energy: number; prints: number }>();
     const multiDay = spanDays > 1;
     const multiDay = spanDays > 1;
 
 
     archives.forEach(archive => {
     archives.forEach(archive => {
@@ -83,9 +84,10 @@ export function FilamentTrends({ archives, currency = '$', dateFrom, dateTo }: F
       const h = date.getHours();
       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 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 };
+      const existing = dataMap.get(key) || { date: key, filament: 0, cost: 0, energy: 0, prints: 0 };
       existing.filament += archive.filament_used_grams || 0;
       existing.filament += archive.filament_used_grams || 0;
       existing.cost += archive.cost || 0;
       existing.cost += archive.cost || 0;
+      existing.energy += archive.energy_kwh || 0;
       existing.prints += archive.quantity || 1;
       existing.prints += archive.quantity || 1;
       dataMap.set(key, existing);
       dataMap.set(key, existing);
     });
     });
@@ -107,7 +109,7 @@ export function FilamentTrends({ archives, currency = '$', dateFrom, dateTo }: F
   const weeklyData = useMemo(() => {
   const weeklyData = useMemo(() => {
     if (dailyData.length <= 60) 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; energy: number; prints: number }>();
 
 
     dailyData.forEach(day => {
     dailyData.forEach(day => {
       const date = new Date(day.date);
       const date = new Date(day.date);
@@ -115,9 +117,10 @@ export function FilamentTrends({ archives, currency = '$', dateFrom, dateTo }: F
       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, energy: 0, prints: 0 };
       existing.filament += day.filament;
       existing.filament += day.filament;
       existing.cost += day.cost;
       existing.cost += day.cost;
+      existing.energy += day.energy;
       existing.prints += day.prints;
       existing.prints += day.prints;
       dataMap.set(key, existing);
       dataMap.set(key, existing);
     });
     });
@@ -237,6 +240,8 @@ export function FilamentTrends({ archives, currency = '$', dateFrom, dateTo }: F
   const chartData = spanDays <= 7 && hourlyData.length > 0 ? hourlyData : weeklyData;
   const chartData = spanDays <= 7 && hourlyData.length > 0 ? hourlyData : weeklyData;
   const totalFilament = archives.reduce((sum, a) => sum + (a.filament_used_grams || 0), 0);
   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 totalCost = archives.reduce((sum, a) => sum + (a.cost || 0), 0);
+  const totalEnergy = archives.reduce((sum, a) => sum + (a.energy_kwh || 0), 0);
+  const totalEnergyCost = archives.reduce((sum, a) => sum + (a.energy_cost || 0), 0);
   const totalPrints = archives.reduce((sum, a) => sum + (a.quantity || 1), 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;
   const printerCount = new Set(archives.map(a => a.printer_id).filter(Boolean)).size;
 
 
@@ -323,6 +328,57 @@ export function FilamentTrends({ archives, currency = '$', dateFrom, dateTo }: F
         </div>
         </div>
       )}
       )}
 
 
+      {/* Energy Over Time Chart (#1432) — only when smart-plug per-print data exists */}
+      {totalEnergy > 0 && chartData.length > 0 && (
+        <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.energyOverTime')}</h4>
+            <span className="text-xs text-bambu-gray">
+              {totalEnergy.toFixed(3)} kWh · {currency}{totalEnergyCost.toFixed(2)}
+            </span>
+          </div>
+          <ResponsiveContainer width="100%" height={250}>
+            <AreaChart data={chartData}>
+              <defs>
+                <linearGradient id="colorEnergy" x1="0" y1="0" x2="0" y2="1">
+                  <stop offset="5%" stopColor="#f59e0b" stopOpacity={0.3}/>
+                  <stop offset="95%" stopColor="#f59e0b" stopOpacity={0}/>
+                </linearGradient>
+              </defs>
+              <CartesianGrid strokeDasharray="3 3" stroke="#3d3d3d" />
+              <XAxis
+                dataKey="dateLabel"
+                stroke="#9ca3af"
+                tick={{ fontSize: 12 }}
+                interval="preserveStartEnd"
+              />
+              <YAxis
+                stroke="#9ca3af"
+                tick={{ fontSize: 12 }}
+                tickFormatter={(value) => `${value}kWh`}
+              />
+              <Tooltip
+                contentStyle={{
+                  backgroundColor: '#2d2d2d',
+                  border: '1px solid #3d3d3d',
+                  borderRadius: '8px',
+                }}
+                labelStyle={{ color: '#fff' }}
+                formatter={(value) => [`${Number(value ?? 0).toFixed(3)} kWh`, t('stats.energyUsed')]}
+              />
+              <Area
+                type="monotone"
+                dataKey="energy"
+                stroke="#f59e0b"
+                strokeWidth={2}
+                fillOpacity={1}
+                fill="url(#colorEnergy)"
+              />
+            </AreaChart>
+          </ResponsiveContainer>
+        </div>
+      )}
+
       {/* Bottom Charts */}
       {/* Bottom Charts */}
       <div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
       <div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
         {/* Filament Type Distribution */}
         {/* Filament Type Distribution */}

+ 1 - 0
frontend/src/i18n/locales/de.ts

@@ -1468,6 +1468,7 @@ export default {
     periodCost: 'Kosten',
     periodCost: 'Kosten',
     avgPerPrint: 'Durchschnitt pro Druck',
     avgPerPrint: 'Durchschnitt pro Druck',
     usageOverTime: 'Verbrauch im Zeitverlauf',
     usageOverTime: 'Verbrauch im Zeitverlauf',
+    energyOverTime: 'Energie im Zeitverlauf',
     filamentByWeight: 'Gewicht',
     filamentByWeight: 'Gewicht',
     printDuration: 'Druckdauer',
     printDuration: 'Druckdauer',
     printerUtilization: 'Druckerauslastung',
     printerUtilization: 'Druckerauslastung',

+ 1 - 0
frontend/src/i18n/locales/en.ts

@@ -1484,6 +1484,7 @@ export default {
     periodCost: 'Period Cost',
     periodCost: 'Period Cost',
     avgPerPrint: 'Avg per Print',
     avgPerPrint: 'Avg per Print',
     usageOverTime: 'Usage Over Time',
     usageOverTime: 'Usage Over Time',
+    energyOverTime: 'Energy Over Time',
     filamentByWeight: 'Weight',
     filamentByWeight: 'Weight',
     printDuration: 'Print Duration',
     printDuration: 'Print Duration',
     printerUtilization: 'Printer Utilization',
     printerUtilization: 'Printer Utilization',

+ 1 - 0
frontend/src/i18n/locales/es.ts

@@ -1468,6 +1468,7 @@ export default {
     periodCost: 'Coste del periodo',
     periodCost: 'Coste del periodo',
     avgPerPrint: 'Promedio por impresión',
     avgPerPrint: 'Promedio por impresión',
     usageOverTime: 'Uso a lo largo del tiempo',
     usageOverTime: 'Uso a lo largo del tiempo',
+    energyOverTime: 'Energía a lo largo del tiempo',
     filamentByWeight: 'Peso',
     filamentByWeight: 'Peso',
     printDuration: 'Duración de la impresión',
     printDuration: 'Duración de la impresión',
     printerUtilization: 'Uso de la impresora',
     printerUtilization: 'Uso de la impresora',

+ 1 - 0
frontend/src/i18n/locales/fr.ts

@@ -1468,6 +1468,7 @@ export default {
     periodCost: 'Coût',
     periodCost: 'Coût',
     avgPerPrint: 'Moy. par impression',
     avgPerPrint: 'Moy. par impression',
     usageOverTime: 'Utilisation dans le temps',
     usageOverTime: 'Utilisation dans le temps',
+    energyOverTime: 'Énergie dans le temps',
     filamentByWeight: 'Poids',
     filamentByWeight: 'Poids',
     printDuration: 'Durée d\'impression',
     printDuration: 'Durée d\'impression',
     printerUtilization: 'Utilisation imprimante',
     printerUtilization: 'Utilisation imprimante',

+ 1 - 0
frontend/src/i18n/locales/it.ts

@@ -1468,6 +1468,7 @@ export default {
     periodCost: 'Costo',
     periodCost: 'Costo',
     avgPerPrint: 'Media per stampa',
     avgPerPrint: 'Media per stampa',
     usageOverTime: 'Utilizzo nel tempo',
     usageOverTime: 'Utilizzo nel tempo',
+    energyOverTime: 'Energia nel tempo',
     filamentByWeight: 'Peso',
     filamentByWeight: 'Peso',
     printDuration: 'Durata stampa',
     printDuration: 'Durata stampa',
     printerUtilization: 'Utilizzo stampante',
     printerUtilization: 'Utilizzo stampante',

+ 1 - 0
frontend/src/i18n/locales/ja.ts

@@ -1467,6 +1467,7 @@ export default {
     periodCost: 'コスト',
     periodCost: 'コスト',
     avgPerPrint: '1印刷あたりの平均',
     avgPerPrint: '1印刷あたりの平均',
     usageOverTime: '時間推移',
     usageOverTime: '時間推移',
+    energyOverTime: 'エネルギー使用量の推移',
     filamentByWeight: '重量',
     filamentByWeight: '重量',
     printDuration: '印刷時間分布',
     printDuration: '印刷時間分布',
     printerUtilization: 'プリンター稼働率',
     printerUtilization: 'プリンター稼働率',

+ 1 - 0
frontend/src/i18n/locales/ko.ts

@@ -1398,6 +1398,7 @@ export default {
     periodCost: '기간 비용',
     periodCost: '기간 비용',
     avgPerPrint: '인쇄당 평균',
     avgPerPrint: '인쇄당 평균',
     usageOverTime: '시간별 사용량',
     usageOverTime: '시간별 사용량',
+    energyOverTime: '시간별 에너지 사용량',
     filamentByWeight: '무게',
     filamentByWeight: '무게',
     printDuration: '인쇄 시간',
     printDuration: '인쇄 시간',
     printerUtilization: '프린터 사용률',
     printerUtilization: '프린터 사용률',

+ 1 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -1468,6 +1468,7 @@ export default {
     periodCost: 'Custo',
     periodCost: 'Custo',
     avgPerPrint: 'Média por impressão',
     avgPerPrint: 'Média por impressão',
     usageOverTime: 'Uso ao longo do tempo',
     usageOverTime: 'Uso ao longo do tempo',
+    energyOverTime: 'Energia ao longo do tempo',
     filamentByWeight: 'Peso',
     filamentByWeight: 'Peso',
     printDuration: 'Duração da impressão',
     printDuration: 'Duração da impressão',
     printerUtilization: 'Utilização da impressora',
     printerUtilization: 'Utilização da impressora',

+ 1 - 0
frontend/src/i18n/locales/ru.ts

@@ -1395,6 +1395,7 @@ export default {
     periodCost: "Стоимость за период",
     periodCost: "Стоимость за период",
     avgPerPrint: "В среднем на печать",
     avgPerPrint: "В среднем на печать",
     usageOverTime: "Расход по времени",
     usageOverTime: "Расход по времени",
+    energyOverTime: "Энергия по времени",
     filamentByWeight: "По весу",
     filamentByWeight: "По весу",
     printDuration: "Продолжительность печати",
     printDuration: "Продолжительность печати",
     printerUtilization: "Загрузка принтеров",
     printerUtilization: "Загрузка принтеров",

+ 1 - 0
frontend/src/i18n/locales/tr.ts

@@ -1469,6 +1469,7 @@ export default {
     periodCost: 'Dönem Maliyeti',
     periodCost: 'Dönem Maliyeti',
     avgPerPrint: 'Baskı Başına Ort.',
     avgPerPrint: 'Baskı Başına Ort.',
     usageOverTime: 'Zaman İçinde Kullanım',
     usageOverTime: 'Zaman İçinde Kullanım',
+    energyOverTime: 'Zaman İçinde Enerji',
     filamentByWeight: 'Ağırlık',
     filamentByWeight: 'Ağırlık',
     printDuration: 'Baskı Süresi',
     printDuration: 'Baskı Süresi',
     printerUtilization: 'Yazıcı Kullanımı',
     printerUtilization: 'Yazıcı Kullanımı',

+ 1 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -1468,6 +1468,7 @@ export default {
     periodCost: '期间成本',
     periodCost: '期间成本',
     avgPerPrint: '每次打印平均',
     avgPerPrint: '每次打印平均',
     usageOverTime: '随时间的使用量',
     usageOverTime: '随时间的使用量',
+    energyOverTime: '随时间的能耗',
     filamentByWeight: '重量',
     filamentByWeight: '重量',
     printDuration: '打印时长',
     printDuration: '打印时长',
     printerUtilization: '打印机利用率',
     printerUtilization: '打印机利用率',

+ 1 - 0
frontend/src/i18n/locales/zh-TW.ts

@@ -1468,6 +1468,7 @@ export default {
     periodCost: '期間成本',
     periodCost: '期間成本',
     avgPerPrint: '每次列印平均',
     avgPerPrint: '每次列印平均',
     usageOverTime: '隨時間的使用量',
     usageOverTime: '隨時間的使用量',
+    energyOverTime: '隨時間的能耗',
     filamentByWeight: '重量',
     filamentByWeight: '重量',
     printDuration: '列印時長',
     printDuration: '列印時長',
     printerUtilization: '印表機利用率',
     printerUtilization: '印表機利用率',

+ 3 - 1
frontend/src/pages/StatsPage.tsx

@@ -882,7 +882,9 @@ function RecordsWidget({ archives, currency }: { archives: ArchiveSlim[]; curren
       });
       });
     }
     }
 
 
-    const costliest = findMax(a => a.cost);
+    // Filament + measured energy (#1432); prints without a smart plug have
+    // energy_cost null and compete on filament cost alone.
+    const costliest = findMax(a => (a.cost ?? 0) + (a.energy_cost ?? 0));
     if (costliest.archive) {
     if (costliest.archive) {
       result.push({
       result.push({
         icon: DollarSign, iconColor: 'text-green-600 dark:text-green-400', label: t('stats.mostExpensivePrint'),
         icon: DollarSign, iconColor: 'text-green-600 dark:text-green-400', label: t('stats.mostExpensivePrint'),

Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/index-BnC7vrvV.js


+ 1 - 1
static/index.html

@@ -26,7 +26,7 @@
 
 
     <!-- Splash screens for iOS -->
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-FqCjQymn.js"></script>
+    <script type="module" crossorigin src="/assets/index-BnC7vrvV.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-Di24iyOw.css">
     <link rel="stylesheet" crossorigin href="/assets/index-Di24iyOw.css">
   </head>
   </head>
   <body>
   <body>

Alguns arquivos não foram mostrados porque muitos arquivos mudaram nesse diff