mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-19 15:54:27 +02:00
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:
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -1216,6 +1216,7 @@ export default {
|
||||
timeAccuracy: '時間精度',
|
||||
successful: '成功',
|
||||
failed: '失敗',
|
||||
cancelled: 'キャンセル',
|
||||
perfectEstimate: '100% = 完全な推定',
|
||||
noTimeAccuracyData: '時間精度データがありません',
|
||||
noFilamentData: 'フィラメントデータがありません',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -1217,6 +1217,7 @@ export default {
|
||||
timeAccuracy: '时间准确度',
|
||||
successful: '成功:',
|
||||
failed: '失败:',
|
||||
cancelled: '已取消:',
|
||||
perfectEstimate: '100% = 完美估计',
|
||||
noTimeAccuracyData: '暂无时间准确度数据',
|
||||
noFilamentData: '暂无耗材数据',
|
||||
|
||||
@@ -1217,6 +1217,7 @@ export default {
|
||||
timeAccuracy: '時間準確度',
|
||||
successful: '成功:',
|
||||
failed: '失敗:',
|
||||
cancelled: '已取消:',
|
||||
perfectEstimate: '100% = 完美估計',
|
||||
noTimeAccuracyData: '尚無時間準確度資料',
|
||||
noFilamentData: '尚無耗材資料',
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
Reference in New Issue
Block a user