Browse Source

fix(stats): cancelled prints get their own bucket; gauge denominator excludes them (#1390 follow-up)

  Reporter (@IndividualGhost1905) saw Total: 20 / Success: 18 / Failed: 1
  and asked where the 20th print went. The Quick Stats endpoint counted
  status == "completed" → Successful and status == "failed" → Failed, but
  used a raw count(*) for Total Prints, so the four other PrintLogEntry
  statuses (aborted, stopped, cancelled, skipped) silently inflated the
  total without showing up in any breakdown row. The earlier #1390 round
  had committed a test locking in this exact behaviour
  ("uses total_prints as denominator so cancelled/stopped events count"),
  which was wrong: it conflated user intent with print quality.

  Three-bucket classification, applied across the whole stats surface
  and matching how the rest of the codebase already groups statuses
  (main.py:430, 1729; failure_analysis status filter):

    successful = completed
    failed     = failed + aborted    (printer-detected quality failures)
    cancelled  = stopped + cancelled + skipped  (user/queue stopped)

  Quick Stats endpoint returns the new cancelled_prints field;
  ArchiveStats.cancelled_prints defaults to 0 so older fixtures still
  parse. SuccessRateWidget gauge now divides by successful + failed
  only — a cancelled roll no longer drags the gauge down — and a
  Cancelled row appears in the breakdown so the missing prints don't
  silently vanish from Total Prints.

  Failure Analysis service applies the same denominator fix to both
  the headline failure_rate and the per-week trend, so a week with
  several cancellations and zero failures reads as 0% rather than
  a misleading "failed / total".

  i18n: new stats.cancelled key in all 9 locales with real
  translations (no English fallback), parity script clean.

  Tests: the existing 'uses total_prints as denominator' assertion
  is inverted to assert the new behaviour (40 / 20 / 35 → 67% gauge,
  Cancelled: 35 visible). The unchanged-display path (140 / 10 / 0
  → 93%) still holds since 140 / (140 + 10) = 93.33% rounds the
  same. 33 StatsPage tests + 6 backend stats/failure tests green.
maziggy 3 days ago
parent
commit
1e734fb7c6

File diff suppressed because it is too large
+ 0 - 0
CHANGELOG.md


+ 15 - 1
backend/app/api/routes/archives.py

@@ -865,10 +865,23 @@ async def get_archive_stats(
     successful_prints = successful_result.scalar() or 0
 
     failed_result = await db.execute(
-        select(func.count(PrintLogEntry.id)).where(PrintLogEntry.status == "failed", *base_conditions)
+        select(func.count(PrintLogEntry.id)).where(PrintLogEntry.status.in_(("failed", "aborted")), *base_conditions)
     )
     failed_prints = failed_result.scalar() or 0
 
+    # User/system-stopped prints — stopped/cancelled/skipped are distinct from
+    # quality failures: the user (or the queue) interrupted them, the printer
+    # didn't detect a fault. Bucketed separately so the Success Rate gauge
+    # divides by completed + failed only (a cancelled print shouldn't drag
+    # the gauge down), while still being visible in the breakdown so they
+    # don't silently vanish from Total Prints (#1390).
+    cancelled_result = await db.execute(
+        select(func.count(PrintLogEntry.id)).where(
+            PrintLogEntry.status.in_(("stopped", "cancelled", "skipped")), *base_conditions
+        )
+    )
+    cancelled_prints = cancelled_result.scalar() or 0
+
     # Total elapsed time — PrintLogEntry stores duration_seconds directly so we
     # can sum it server-side. Rows missing duration fall back to the slicer
     # estimate from the archive (joined for that case only).
@@ -990,6 +1003,7 @@ async def get_archive_stats(
         total_prints=total_prints,
         successful_prints=successful_prints,
         failed_prints=failed_prints,
+        cancelled_prints=cancelled_prints,
         total_print_time_hours=round(total_time, 1),
         total_filament_grams=round(total_filament, 1),
         total_cost=round(total_cost, 2),

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

@@ -147,6 +147,10 @@ class ArchiveStats(BaseModel):
     total_prints: int
     successful_prints: int
     failed_prints: int
+    # User/system-stopped prints (PrintLogEntry.status in stopped/cancelled/
+    # skipped). Defaulted so older clients that don't send this field still
+    # validate against historical fixtures.
+    cancelled_prints: int = 0
     total_print_time_hours: float
     total_filament_grams: float
     total_cost: float

+ 19 - 2
backend/app/services/failure_analysis.py

@@ -75,6 +75,11 @@ class FailureAnalysisService:
         total_result = await self.db.execute(select(func.count(PrintLogEntry.id)).where(and_(*base_filter)))
         total_prints = total_result.scalar() or 0
 
+        successful_result = await self.db.execute(
+            select(func.count(PrintLogEntry.id)).where(and_(*base_filter, PrintLogEntry.status == "completed"))
+        )
+        successful_prints = successful_result.scalar() or 0
+
         failed_result = await self.db.execute(
             select(func.count(PrintLogEntry.id)).where(
                 and_(*base_filter, PrintLogEntry.status.in_(["failed", "aborted"]))
@@ -82,7 +87,14 @@ class FailureAnalysisService:
         )
         failed_prints = failed_result.scalar() or 0
 
-        failure_rate = (failed_prints / total_prints * 100) if total_prints > 0 else 0
+        # Failure rate divides by quality-outcome prints only — a cancelled or
+        # skipped print is neither a success nor a failure of the printer, so
+        # including it in the denominator silently lowered the displayed rate
+        # whenever the user stopped jobs (#1390). Total Prints (the absolute
+        # count incl. cancelled) is still returned separately for the "X / Y
+        # prints failed" caption.
+        outcome_prints = successful_prints + failed_prints
+        failure_rate = (failed_prints / outcome_prints * 100) if outcome_prints > 0 else 0
 
         # Failures by reason
         reason_result = await self.db.execute(
@@ -188,6 +200,9 @@ class FailureAnalysisService:
             ]
 
             week_total = await self.db.execute(select(func.count(PrintLogEntry.id)).where(and_(*week_filter)))
+            week_successful = await self.db.execute(
+                select(func.count(PrintLogEntry.id)).where(and_(*week_filter, PrintLogEntry.status == "completed"))
+            )
             week_failed = await self.db.execute(
                 select(func.count(PrintLogEntry.id)).where(
                     and_(*week_filter, PrintLogEntry.status.in_(["failed", "aborted"]))
@@ -195,8 +210,10 @@ class FailureAnalysisService:
             )
 
             total = week_total.scalar() or 0
+            successful = week_successful.scalar() or 0
             failed = week_failed.scalar() or 0
-            rate = (failed / total * 100) if total > 0 else 0
+            week_outcome = successful + failed
+            rate = (failed / week_outcome * 100) if week_outcome > 0 else 0
 
             trend_data.append(
                 {

+ 17 - 6
frontend/src/__tests__/pages/StatsPage.test.tsx

@@ -14,6 +14,7 @@ const mockStats = {
   total_prints: 150,
   successful_prints: 140,
   failed_prints: 10,
+  cancelled_prints: 0,
   total_print_time_hours: 500.5,
   total_filament_grams: 5500,
   total_cost: 125.50,
@@ -215,11 +216,14 @@ describe('StatsPage', () => {
       });
     });
 
-    it('uses total_prints as denominator so cancelled/stopped events count (#1390)', async () => {
-      // 40 successful out of 100 total — with 20 failed and 40 cancelled/stopped
-      // mixed in. Old formula (successful / (successful + failed)) would have
-      // shown 40 / (40 + 20) = 67%. New formula shows 40 / 100 = 40%, which
-      // matches the "Total Prints: 100" the user reads right above the gauge.
+    it('excludes cancelled prints from the rate denominator and surfaces them in the breakdown (#1390)', async () => {
+      // 40 completed, 20 failed (printer-detected), 40 user-cancelled out of
+      // 100 total. The earlier fix divided by total_prints and reported 40%,
+      // which conflated user intent with print quality — cancelling a roll
+      // because you changed your mind shouldn't be counted against the
+      // printer's success rate. New behaviour: gauge = 40 / (40 + 20) = 67%;
+      // cancelled count still visible in the row breakdown so the missing
+      // 40 prints don't silently disappear.
       server.use(
         http.get('/api/v1/archives/stats', () =>
           HttpResponse.json({
@@ -227,6 +231,7 @@ describe('StatsPage', () => {
             total_prints: 100,
             successful_prints: 40,
             failed_prints: 20,
+            cancelled_prints: 35,
           }),
         ),
       );
@@ -234,7 +239,13 @@ describe('StatsPage', () => {
 
       await waitFor(() => {
         expect(screen.getByText('Success Rate')).toBeInTheDocument();
-        expect(screen.getByText('40%')).toBeInTheDocument();
+        expect(screen.getByText('67%')).toBeInTheDocument();
+        // Cancelled count surfaces in the breakdown so the missing prints
+        // aren't silently swallowed (was the original bug in #1390). Pick a
+        // value distinct from successful_prints/failed_prints to keep the
+        // getByText query unambiguous.
+        expect(screen.getByText('Cancelled:')).toBeInTheDocument();
+        expect(screen.getByText('35')).toBeInTheDocument();
       });
     });
   });

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

@@ -651,6 +651,7 @@ export interface ArchiveStats {
   total_prints: number;
   successful_prints: number;
   failed_prints: number;
+  cancelled_prints: number;
   total_print_time_hours: number;
   total_filament_grams: number;
   total_cost: number;

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

@@ -1217,6 +1217,7 @@ export default {
     timeAccuracy: 'Zeitgenauigkeit',
     successful: 'Erfolgreich:',
     failed: 'Fehlgeschlagen:',
+    cancelled: 'Abgebrochen:',
     perfectEstimate: '100% = perfekte Schätzung',
     noTimeAccuracyData: 'Noch keine Zeitgenauigkeitsdaten',
     noFilamentData: 'Keine Filamentdaten verfügbar',

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

@@ -1217,6 +1217,7 @@ export default {
     timeAccuracy: 'Time Accuracy',
     successful: 'Successful:',
     failed: 'Failed:',
+    cancelled: 'Cancelled:',
     perfectEstimate: '100% = perfect estimate',
     noTimeAccuracyData: 'No time accuracy data yet',
     noFilamentData: 'No filament data available',

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

@@ -1217,6 +1217,7 @@ export default {
     timeAccuracy: 'Precisión temporal',
     successful: 'Con éxito:',
     failed: 'Fallidas:',
+    cancelled: 'Canceladas:',
     perfectEstimate: '100% = estimación perfecta',
     noTimeAccuracyData: 'Aún no hay datos de precisión temporal',
     noFilamentData: 'No hay datos de filamento disponibles',

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

@@ -1217,6 +1217,7 @@ export default {
     timeAccuracy: 'Précision du temps',
     successful: 'Succès :',
     failed: 'Échecs :',
+    cancelled: 'Annulés :',
     perfectEstimate: '100% = estimation parfaite',
     noTimeAccuracyData: 'Pas encore de données de précision',
     noFilamentData: 'Aucune donnée de filament',

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

@@ -1217,6 +1217,7 @@ export default {
     timeAccuracy: 'Accuratezza tempo',
     successful: 'Riuscite:',
     failed: 'Fallite:',
+    cancelled: 'Annullate:',
     perfectEstimate: '100% = stima perfetta',
     noTimeAccuracyData: 'Nessun dato accuratezza tempo',
     noFilamentData: 'Nessun dato filamento',

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

@@ -1216,6 +1216,7 @@ export default {
     timeAccuracy: '時間精度',
     successful: '成功',
     failed: '失敗',
+    cancelled: 'キャンセル',
     perfectEstimate: '100% = 完全な推定',
     noTimeAccuracyData: '時間精度データがありません',
     noFilamentData: 'フィラメントデータがありません',

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

@@ -1217,6 +1217,7 @@ export default {
     timeAccuracy: 'Precisão do Tempo',
     successful: 'Bem-sucedido:',
     failed: 'Falhou:',
+    cancelled: 'Cancelado:',
     perfectEstimate: '100% = estimativa perfeita',
     noTimeAccuracyData: 'Nenhum dado de precisão de tempo disponível',
     noFilamentData: 'Nenhum dado de filamento disponível',

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

@@ -1217,6 +1217,7 @@ export default {
     timeAccuracy: '时间准确度',
     successful: '成功:',
     failed: '失败:',
+    cancelled: '已取消:',
     perfectEstimate: '100% = 完美估计',
     noTimeAccuracyData: '暂无时间准确度数据',
     noFilamentData: '暂无耗材数据',

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

@@ -1217,6 +1217,7 @@ export default {
     timeAccuracy: '時間準確度',
     successful: '成功:',
     failed: '失敗:',
+    cancelled: '已取消:',
     perfectEstimate: '100% = 完美估計',
     noTimeAccuracyData: '尚無時間準確度資料',
     noFilamentData: '尚無耗材資料',

+ 14 - 2
frontend/src/pages/StatsPage.tsx

@@ -6,6 +6,7 @@ import {
   Clock,
   CheckCircle,
   XCircle,
+  Ban,
   DollarSign,
   Target,
   Zap,
@@ -191,14 +192,20 @@ function SuccessRateWidget({
     total_prints: number;
     successful_prints: number;
     failed_prints: number;
+    cancelled_prints?: number;
     prints_by_printer: Record<string, number>;
   } | undefined;
   printerMap: Map<string, string>;
   size?: 1 | 2 | 4;
 }) {
   const { t } = useTranslation();
-  const successRate = stats?.total_prints
-    ? Math.round(((stats.successful_prints || 0) / stats.total_prints) * 100)
+  // Denominator is completed + failed only — a user/system-cancelled print is
+  // neither a quality success nor a quality failure, so including it would
+  // silently lower the rate whenever the user stopped a job. The cancelled
+  // count is still shown in the breakdown below so it doesn't vanish (#1390).
+  const outcomePrints = (stats?.successful_prints || 0) + (stats?.failed_prints || 0);
+  const successRate = outcomePrints
+    ? Math.round(((stats?.successful_prints || 0) / outcomePrints) * 100)
     : 0;
 
   // Scale gauge size based on widget size
@@ -245,6 +252,11 @@ function SuccessRateWidget({
             <span className="text-sm text-bambu-gray">{t('stats.failed')}</span>
             <span className="text-sm text-white font-medium">{stats?.failed_prints || 0}</span>
           </div>
+          <div className="flex items-center gap-2">
+            <Ban className="w-4 h-4 text-bambu-gray flex-shrink-0" />
+            <span className="text-sm text-bambu-gray">{t('stats.cancelled')}</span>
+            <span className="text-sm text-white font-medium">{stats?.cancelled_prints || 0}</span>
+          </div>
         </div>
         {/* Show per-printer breakdown when expanded */}
         {size >= 2 && stats?.prints_by_printer && Object.keys(stats.prints_by_printer).length > 0 && (

File diff suppressed because it is too large
+ 0 - 0
static/assets/index-BW850Rth.js


+ 1 - 1
static/index.html

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

Some files were not shown because too many files changed in this diff