diff --git a/.gitignore b/.gitignore
index f5eb2dfd3..470b31f0d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -55,4 +55,4 @@ bambutrack.log.*
firmware/
# Node modules
-node_modules/
\ No newline at end of file
+node_modules/
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 1da6db071..468e50c6d 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -24,7 +24,7 @@ repos:
exclude: ^static/
- id: check-yaml
- id: check-json
- exclude: ^static/
+ exclude: ^(static/|frontend/tsconfig\.)
- id: check-added-large-files
args: ['--maxkb=1000']
exclude: ^static/assets/
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 86ab1293f..0e77a1446 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,13 @@
All notable changes to Bambuddy will be documented in this file.
+## [0.1.6] - 2026-01-24
+
+### New Features
+- **Recalculate Costs Button** - New button on Dashboard to recalculate all archive costs using current filament prices (Issue #120)
+### Fixes
+- **Reprint Cost Tracking** - Reprinting an archive now adds the cost to the existing total, so statistics accurately reflect total filament expenditure across all prints
+
## [0.1.6b11] - 2026-01-22
### New Features
diff --git a/backend/app/main.py b/backend/app/main.py
index a2cf84c55..849bee5f0 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -112,6 +112,9 @@ _expected_prints: dict[tuple[int, str], int] = {}
# Track starting energy for prints: {archive_id: starting_kwh}
_print_energy_start: dict[int, float] = {}
+# Track reprints to add costs on completion: {archive_id}
+_reprint_archives: set[int] = set()
+
async def _get_plug_energy(plug, db) -> dict | None:
"""Get energy from plug regardless of type (Tasmota or Home Assistant).
@@ -526,6 +529,10 @@ async def on_print_start(printer_id: int, data: dict):
if subtask_name:
_active_prints[(printer_id, f"{subtask_name}.3mf")] = archive.id
+ # Mark as reprint so we add cost on completion
+ _reprint_archives.add(archive.id)
+ logger.info(f"Marked archive {archive.id} as reprint for cost addition on completion")
+
# Set up energy tracking
try:
plug_result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == printer_id))
@@ -1235,6 +1242,15 @@ async def on_print_complete(printer_id: int, data: dict):
)
logger.info(f"[ARCHIVE] Archive {archive_id} status updated to {status}, failure_reason={failure_reason}")
+ # Add cost for reprints (first prints have cost set in archive_print())
+ if status == "completed" and archive_id in _reprint_archives:
+ _reprint_archives.discard(archive_id)
+ try:
+ await service.add_reprint_cost(archive_id)
+ logger.info(f"[ARCHIVE] Added reprint cost for archive {archive_id}")
+ except Exception as e:
+ logger.warning(f"[ARCHIVE] Failed to add reprint cost for archive {archive_id}: {e}")
+
await ws_manager.send_archive_updated(
{
"id": archive_id,
diff --git a/backend/app/services/archive.py b/backend/app/services/archive.py
index c0a3a27dc..ba28004ec 100644
--- a/backend/app/services/archive.py
+++ b/backend/app/services/archive.py
@@ -919,6 +919,43 @@ class ArchiveService:
await self.db.commit()
return True
+ async def add_reprint_cost(self, archive_id: int) -> bool:
+ """Add cost for a reprint to the existing archive cost."""
+ archive = await self.get_archive(archive_id)
+ if not archive:
+ return False
+
+ if not archive.filament_used_grams or not archive.filament_type:
+ return False
+
+ # Calculate cost based on filament type or default
+ from backend.app.api.routes.settings import get_setting
+
+ primary_type = archive.filament_type.split(",")[0].strip()
+
+ # Look up filament cost_per_kg from database
+ filament_result = await self.db.execute(select(Filament).where(Filament.type == primary_type).limit(1))
+ filament = filament_result.scalar_one_or_none()
+
+ if filament:
+ cost_per_kg = filament.cost_per_kg
+ else:
+ # Use default filament cost from settings
+ default_cost_setting = await get_setting(self.db, "default_filament_cost")
+ cost_per_kg = float(default_cost_setting) if default_cost_setting else 25.0
+
+ additional_cost = round((archive.filament_used_grams / 1000) * cost_per_kg, 2)
+
+ # Add to existing cost (or set if None)
+ if archive.cost is None:
+ archive.cost = additional_cost
+ else:
+ archive.cost = round(archive.cost + additional_cost, 2)
+
+ await self.db.commit()
+ logger.info(f"Added reprint cost {additional_cost} to archive {archive_id}, new total: {archive.cost}")
+ return True
+
async def list_archives(
self,
printer_id: int | None = None,
diff --git a/backend/tests/unit/services/test_archive_service.py b/backend/tests/unit/services/test_archive_service.py
index 1402ac03e..962559252 100644
--- a/backend/tests/unit/services/test_archive_service.py
+++ b/backend/tests/unit/services/test_archive_service.py
@@ -611,3 +611,54 @@ class TestMultiPlate3MFParsing:
is_multi_plate = len(plate_indices) > 1
assert is_multi_plate is False
+
+
+class TestReprintCostCalculation:
+ """Tests for reprint cost calculation."""
+
+ def test_cost_addition_logic(self):
+ """Test that reprint costs are added correctly."""
+ # Simulate the cost addition logic
+ existing_cost = 5.25 # Original print cost
+ filament_grams = 100.0
+ cost_per_kg = 25.0 # Default cost
+
+ # Calculate additional cost for reprint
+ additional_cost = round((filament_grams / 1000) * cost_per_kg, 2)
+ assert additional_cost == 2.50
+
+ # Add to existing cost
+ new_total = round(existing_cost + additional_cost, 2)
+ assert new_total == 7.75
+
+ def test_cost_addition_with_none_existing(self):
+ """Test cost addition when existing cost is None."""
+ existing_cost = None
+ filament_grams = 200.0
+ cost_per_kg = 15.0
+
+ additional_cost = round((filament_grams / 1000) * cost_per_kg, 2)
+ assert additional_cost == 3.0
+
+ # When existing is None, just use additional
+ new_total = additional_cost if existing_cost is None else round(existing_cost + additional_cost, 2)
+ assert new_total == 3.0
+
+ def test_cost_with_custom_filament_price(self):
+ """Test cost calculation with custom filament price."""
+ filament_grams = 150.0
+ custom_cost_per_kg = 35.0 # More expensive filament
+
+ cost = round((filament_grams / 1000) * custom_cost_per_kg, 2)
+ assert cost == 5.25
+
+ def test_multiple_reprints_accumulate(self):
+ """Test that multiple reprints accumulate costs correctly."""
+ filament_grams = 100.0
+ cost_per_kg = 20.0
+ single_print_cost = round((filament_grams / 1000) * cost_per_kg, 2)
+ assert single_print_cost == 2.0
+
+ # After 3 prints (1 original + 2 reprints)
+ total_after_3_prints = round(single_print_cost * 3, 2)
+ assert total_after_3_prints == 6.0
diff --git a/frontend/src/__tests__/pages/StatsPage.test.tsx b/frontend/src/__tests__/pages/StatsPage.test.tsx
index 26d0d7ba7..e86c2e2f7 100644
--- a/frontend/src/__tests__/pages/StatsPage.test.tsx
+++ b/frontend/src/__tests__/pages/StatsPage.test.tsx
@@ -196,4 +196,14 @@ describe('StatsPage', () => {
});
});
});
+
+ describe('recalculate costs', () => {
+ it('has recalculate costs button', async () => {
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByText('Recalculate Costs')).toBeInTheDocument();
+ });
+ });
+ });
});
diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts
index 9502de323..60f7598a2 100644
--- a/frontend/src/api/client.ts
+++ b/frontend/src/api/client.ts
@@ -1680,6 +1680,8 @@ export const api = {
deleteArchive: (id: number) =>
request(`/archives/${id}`, { method: 'DELETE' }),
getArchiveStats: () => request('/archives/stats'),
+ recalculateCosts: () =>
+ request<{ message: string; updated: number }>('/archives/recalculate-costs', { method: 'POST' }),
getFailureAnalysis: (options?: { days?: number; printerId?: number; projectId?: number }) => {
const params = new URLSearchParams();
if (options?.days) params.set('days', String(options.days));
diff --git a/frontend/src/pages/StatsPage.tsx b/frontend/src/pages/StatsPage.tsx
index f85dec4a6..d3e09ed1e 100644
--- a/frontend/src/pages/StatsPage.tsx
+++ b/frontend/src/pages/StatsPage.tsx
@@ -16,6 +16,7 @@ import {
Loader2,
Eye,
RotateCcw,
+ Calculator,
} from 'lucide-react';
import { Button } from '../components/Button';
import { useToast } from '../contexts/ToastContext';
@@ -508,6 +509,7 @@ export function StatsPage() {
const [showExportMenu, setShowExportMenu] = useState(false);
const [dashboardKey, setDashboardKey] = useState(0);
const [hiddenCount, setHiddenCount] = useState(0);
+ const [isRecalculating, setIsRecalculating] = useState(false);
// Read hidden count from localStorage
useEffect(() => {
@@ -533,7 +535,7 @@ export function StatsPage() {
};
}, [dashboardKey]);
- const { data: stats, isLoading } = useQuery({
+ const { data: stats, isLoading, refetch: refetchStats } = useQuery({
queryKey: ['archiveStats'],
queryFn: api.getArchiveStats,
});
@@ -572,6 +574,19 @@ export function StatsPage() {
}
};
+ const handleRecalculateCosts = async () => {
+ setIsRecalculating(true);
+ try {
+ const result = await api.recalculateCosts();
+ await refetchStats();
+ showToast(`Recalculated costs for ${result.updated} archives`);
+ } catch {
+ showToast('Failed to recalculate costs', 'error');
+ } finally {
+ setIsRecalculating(false);
+ }
+ };
+
const currency = settings?.currency || '$';
const printerMap = new Map(printers?.map((p) => [String(p.id), p.name]) || []);
const printDates = archives?.map((a) => a.created_at) || [];
@@ -672,6 +687,20 @@ export function StatsPage() {
Reset Layout
+ {/* Recalculate Costs */}
+
{/* Export dropdown */}