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.
This commit is contained in:
maziggy
2026-05-24 15:21:10 +02:00
parent b9b06a7351
commit 1e734fb7c6
18 changed files with 492 additions and 423 deletions
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -1216,6 +1216,7 @@ export default {
timeAccuracy: '時間精度',
successful: '成功',
failed: '失敗',
cancelled: 'キャンセル',
perfectEstimate: '100% = 完全な推定',
noTimeAccuracyData: '時間精度データがありません',
noFilamentData: 'フィラメントデータがありません',
+1
View File
@@ -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
View File
@@ -1217,6 +1217,7 @@ export default {
timeAccuracy: '时间准确度',
successful: '成功:',
failed: '失败:',
cancelled: '已取消:',
perfectEstimate: '100% = 完美估计',
noTimeAccuracyData: '暂无时间准确度数据',
noFilamentData: '暂无耗材数据',
+1
View File
@@ -1217,6 +1217,7 @@ export default {
timeAccuracy: '時間準確度',
successful: '成功:',
failed: '失敗:',
cancelled: '已取消:',
perfectEstimate: '100% = 完美估計',
noTimeAccuracyData: '尚無時間準確度資料',
noFilamentData: '尚無耗材資料',
+14 -2
View File
@@ -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 && (