Parcourir la source

Archives: each print against its plate's estimate (issue #3375)

- The archive keeps each plate's slicer estimate from its 3MF
  (plate_estimates), read when it is made, given its 3MF, re-sliced or
  merged, and for older archives once after startup
- The card badge compares the latest print with its plate's estimate,
  also for archives printed more than once; the archive page shows
  each print's duration and filament next to its plate's estimate
- Statistics' time accuracy and per-print times go by each print's
  plate; start and queue job-started notifications give the plate's
  time; a queued job moved to another plate takes that plate's time
- A print of a multi-plate file whose plate is not known gets no
  estimate rather than the whole file's
maziggy il y a 1 jour
Parent
commit
f839f12ecb
72 fichiers modifiés avec 811 ajouts et 128 suppressions
  1. 0 8
      .basedpyright/baseline.json
  2. 1 0
      CHANGELOG.md
  3. 105 73
      backend/app/api/routes/archives.py
  4. 3 0
      backend/app/api/routes/library.py
  5. 37 19
      backend/app/api/routes/print_queue.py
  6. 2 2
      backend/app/api/routes/projects.py
  7. 5 0
      backend/app/core/database.py
  8. 43 4
      backend/app/main.py
  9. 8 0
      backend/app/models/archive.py
  10. 3 0
      backend/app/schemas/archive.py
  11. 4 0
      backend/app/schemas/print_log.py
  12. 6 0
      backend/app/services/archive.py
  13. 5 0
      backend/app/services/archive_merge.py
  14. 92 1
      backend/app/services/print_cost_estimate.py
  15. 5 3
      backend/app/services/print_scheduler.py
  16. 35 0
      backend/app/utils/threemf_tools.py
  17. 16 0
      backend/tests/integration/test_archive_merge.py
  18. 356 0
      backend/tests/integration/test_plate_estimates_3375.py
  19. 16 11
      backend/tests/unit/test_archive_run_aggregation.py
  20. 11 1
      frontend/src/__tests__/pages/ArchiveDetailPage.test.tsx
  21. 14 0
      frontend/src/__tests__/pages/ArchivesPage.test.tsx
  22. 4 0
      frontend/src/api/client.ts
  23. 1 0
      frontend/src/i18n/locales/de.ts
  24. 1 0
      frontend/src/i18n/locales/en.ts
  25. 1 0
      frontend/src/i18n/locales/es.ts
  26. 1 0
      frontend/src/i18n/locales/fr.ts
  27. 1 0
      frontend/src/i18n/locales/it.ts
  28. 1 0
      frontend/src/i18n/locales/ja.ts
  29. 1 0
      frontend/src/i18n/locales/ko.ts
  30. 1 0
      frontend/src/i18n/locales/nl.ts
  31. 1 0
      frontend/src/i18n/locales/pt-BR.ts
  32. 1 0
      frontend/src/i18n/locales/ru.ts
  33. 1 0
      frontend/src/i18n/locales/sv.ts
  34. 1 0
      frontend/src/i18n/locales/tr.ts
  35. 1 0
      frontend/src/i18n/locales/uk.ts
  36. 1 0
      frontend/src/i18n/locales/zh-CN.ts
  37. 1 0
      frontend/src/i18n/locales/zh-TW.ts
  38. 21 2
      frontend/src/pages/ArchiveDetailPage.tsx
  39. 1 1
      frontend/src/pages/ArchivesPage.tsx
  40. 0 0
      static/assets/ArchiveDetailPage-BYgLfUI5.js
  41. 0 0
      static/assets/ArchiveDetailPage-CE3S5Ntb.js
  42. 0 0
      static/assets/ArchivesPage-spPPt0zO.js
  43. 0 0
      static/assets/CamWallPage-DVCStqER.js
  44. 0 0
      static/assets/CameraPage-DLh81q1o.js
  45. 0 0
      static/assets/FileManagerPage-9He60d-E.js
  46. 0 0
      static/assets/InventoryPage-MB8eI959.js
  47. 0 0
      static/assets/Layout-EsGzEfN2.js
  48. 0 0
      static/assets/ModelSourcesPage-CsACNSLl.js
  49. 0 0
      static/assets/PrintersPage-C60-15sk.js
  50. 0 0
      static/assets/SettingsPage-AolqHSbH.js
  51. 0 0
      static/assets/SliceModal-xlCAt6L1.js
  52. 0 0
      static/assets/SpoolBuddyInventoryPage-C2aMrffm.js
  53. 1 1
      static/assets/SpoolBuddySettingsPage-honRrFLn.js
  54. 0 0
      static/assets/de-_h-xgGmk.js
  55. 0 0
      static/assets/es-DLc3IFPL.js
  56. 0 0
      static/assets/fr-Y9m7e-OP.js
  57. 0 0
      static/assets/index-BLsD_TqQ.js
  58. 0 0
      static/assets/it-CCYNAAbJ.js
  59. 0 0
      static/assets/ja-Wu0jjGZB.js
  60. 0 0
      static/assets/ko-ttQJeZA8.js
  61. 0 0
      static/assets/locationSensorDefaults-BTgcf3a0.js
  62. 0 0
      static/assets/nl-yr9k7i88.js
  63. 0 0
      static/assets/pt-BR-D3rz6xfc.js
  64. 0 0
      static/assets/ru-Bk73u0k1.js
  65. 0 0
      static/assets/spoolCost-BxD5FkQf.js
  66. 0 0
      static/assets/sv-BYkeC-YL.js
  67. 0 0
      static/assets/tr-hAlhUDvw.js
  68. 0 0
      static/assets/uk-BUQiIynH.js
  69. 0 0
      static/assets/usePageFileDrop-COa1IjqH.js
  70. 0 0
      static/assets/zh-CN-B6eLhLb3.js
  71. 0 0
      static/assets/zh-TW-C27wUXZ5.js
  72. 2 2
      static/index.html

+ 0 - 8
.basedpyright/baseline.json

@@ -1723,14 +1723,6 @@
                     "lineCount": 1
                 }
             },
-            {
-                "code": "reportOptionalMemberAccess",
-                "range": {
-                    "startColumn": 54,
-                    "endColumn": 72,
-                    "lineCount": 1
-                }
-            },
             {
                 "code": "reportOptionalMemberAccess",
                 "range": {

+ 1 - 0
CHANGELOG.md

@@ -215,6 +215,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **The frontend build no longer warns about `path` and `crypto` being externalized for the STEP previewer (#2976)** — `occt-import-js`, the Emscripten build behind STEP previews, requires both modules, but only inside its `ENVIRONMENT_IS_NODE` branches; in the browser it loads its `.wasm` from the URL the preview worker passes and draws randomness from `crypto.getRandomValues`. Vite still externalized both and printed two warnings on every build. `vite.config.ts` now drops exactly those two warnings for that one package through `build.rolldownOptions.onLog`, so an externalization anywhere else, or of any other module, still shows.
 
 ### Fixed
+- **One plate of a multi-plate file is measured against that plate (#3375, reported by @sargonas)** — The slicer's estimate on an archive adds all plates of the file up, so printing one plate of a three-plate file showed +200 % on the card, an archive printed more than once showed no badge at all, and statistics only kept time accuracies between 50 % and 200 %. An archive now keeps each plate's estimate from its 3MF (archives made before get theirs in the background after the update), and each print is measured against the plate it printed: the card's badge compares the latest print with its plate's estimate, the archive page shows each print's duration and filament next to its plate's estimate, the statistics' time accuracy goes by each print's plate, and the print start and queue job started notifications give the plate's time (the printer's own remaining time when the plate of a multi-plate file is not known). Moving a queued job to another plate now also updates the time shortest-job-first orders it by. A print of a multi-plate file whose plate is not known gets no estimate rather than the whole file's. Wiki updated.
 - **Git backup: prints come back with their archives** — A Git backup held archives only, so restoring onto a rebuilt instance gave archives without their prints: statistics and the Print Log came back empty, and each print's printer, plate, outcome, cost and energy was lost. The backup now also writes each print (`archives/print_runs.json`) and which archive was merged into which (`archives/aliases.json`), and **Print archives** restores both: each print is attached to its restored archive, which then shows its latest print's outcome. An archive printed again since the backup is recognised by when it was archived and its prints' start times instead of being added a second time, and an archive merged into another, before or after the backup, is not restored as one of its own, with its spool usage going to the archive it went into. A print running when the backup was made comes back cancelled, like its archive. The one-tap confirm link, photos, timelapses and wallet links are not in the backup. Older backups restore as before. Wiki updated.
 - **Orca Cloud presets with quoted string lists** - Orca Cloud returns lists like compatible_printers as one INI-quoted string, so its processes were hidden for every printer, and a process made for a user printer preset was refused by the slicer. Bambuddy now reads these lists the way OrcaSlicer does, in the preset list and on the slice path. Reported in #3380, fixed by @pleasantone in #3381.
 - **A camera opened in the iOS home-screen web app can be left again (#3419)** — In Bambuddy added to the home screen ("Add to Home Screen" in Safari), opening a camera in window mode loaded the camera page into the app itself, full screen, with no way back short of closing the app. A web app has no second window to open, so its cameras now always open as the floating overlay: the camera button offers no window mode, and the overlay has no pop-out button. A Camera Wall tile opens the camera page in place. The camera page has a **Back** button whenever it was not opened as a window (web app, bookmark, link), which returns to the previous Bambuddy page or the start page, and its pop-in does the same there instead of trying to close itself. Bambuddy installed as an app on a desktop or Android is unchanged: a camera window opens normally there. Wiki updated.

+ 105 - 73
backend/app/api/routes/archives.py

@@ -9,6 +9,7 @@ from dataclasses import replace
 from datetime import date, datetime, timedelta, timezone
 from html import escape as html_escape
 from pathlib import Path
+from types import SimpleNamespace
 
 from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, Request, UploadFile
 from fastapi.responses import FileResponse, Response
@@ -58,7 +59,11 @@ from backend.app.services.print_confirmation import (
     is_one_tap_request,
     is_unattended_fetch,
 )
-from backend.app.services.print_cost_estimate import default_cost_per_kg as _default_cost_per_kg, price_one_print
+from backend.app.services.print_cost_estimate import (
+    default_cost_per_kg as _default_cost_per_kg,
+    price_one_print,
+    run_estimate,
+)
 from backend.app.services.print_log import owns_media, runs_with_timelapse
 from backend.app.services.print_storage import (
     REASON_FTP_TRANSFER_FAILED,
@@ -315,53 +320,69 @@ def _apply_run_user_filter(conditions: list, created_by_id: int | None):
             conditions.append(PrintLogEntry.created_by_id == created_by_id)
 
 
+def _event_estimate_seconds(row) -> int | None:
+    """The slicer's time for the plate one print event printed (#3375)."""
+    estimate = run_estimate(
+        SimpleNamespace(
+            plate_estimates=row.plate_estimates,
+            print_time_seconds=row.print_time_seconds,
+            filament_used_grams=row.file_grams,
+        ),
+        row.plate_id,
+    )
+    return estimate.seconds if estimate is not None else None
+
+
 def compute_time_accuracy(archive: PrintArchive, run_aggregate: dict | None = None) -> dict:
-    """Compute actual print time and accuracy for an archive.
-
-    Returns dict with actual_time_seconds and time_accuracy.
-    time_accuracy = (estimated / actual) * 100
-    - 100% = perfect estimate
-    - >100% = print was faster than estimated
-    - <100% = print took longer than estimated
-
-    When ``run_aggregate`` indicates the archive has more than one logged
-    run (multi-plate file printed plate-by-plate, or reprints), both
-    fields are suppressed: ``archive.started_at / completed_at`` reflect
-    the LATEST run only, while ``archive.print_time_seconds`` is the
-    whole-file estimate (post-#1593 the parser sums across plates), so
-    comparing the two describes different scopes. The card-rendering
-    frontend falls through to ``archive.print_time_seconds`` for the
-    time display and hides the badge when ``time_accuracy`` is null —
-    that's the desired "show estimate, no badge" presentation for
-    multi-run archives (#1608). Single-run archives keep the original
-    badge behaviour verbatim.
+    """The time accuracy of the archive's latest print: how its time compares
+    with the slicer's estimate for the plate it printed (#3375).
+
+    Returns ``actual_time_seconds``, ``time_estimate_seconds`` and
+    ``time_accuracy`` = estimated / actual * 100 (100 % = exact, above 100 %
+    = faster than estimated). The latest print is the run aggregate's; an
+    archive printed before runs were logged is its own one print.
+
+    Measured against that plate's estimate, not the file's, whose figures
+    add every plate up (#1593). Where the file's plates are not known (not
+    read yet, not sliced, file gone) the file's figures stand for the print:
+    then an archive printed more than once shows no badge, as before (#1608),
+    since its plates may differ. A print whose times were not measured (#3310)
+    or that did not complete shows none either.
     """
-    result: dict[str, int | float | None] = {"actual_time_seconds": None, "time_accuracy": None}
-
-    # Multi-run archives: the per-run actual (started_at..completed_at on
-    # the archive row) is incommensurable with the whole-file estimate.
-    # Both fields are cleared so the card shows estimate + no badge.
-    if run_aggregate and (run_aggregate.get("run_count") or 0) > 1:
-        return result
-    # A run whose times were not measured (#3310): its end is the slicer's
-    # estimate or the reconnect. The card shows the estimate and no badge,
-    # as for multi-run archives.
-    if run_aggregate and run_aggregate.get("unmeasured_run_count"):
+    result: dict[str, int | float | None] = {
+        "actual_time_seconds": None,
+        "time_estimate_seconds": None,
+        "time_accuracy": None,
+    }
+    latest = (run_aggregate or {}).get("latest_run")
+    if latest is not None:
+        plate_id, status, unmeasured = latest.plate_id, latest.status, latest.duration_unmeasured
+        started_at, completed_at, duration = latest.started_at, latest.completed_at, latest.duration_seconds
+    else:
+        plate_id, status, unmeasured = archive.plate_id, archive.status, False
+        started_at, completed_at, duration = archive.started_at, archive.completed_at, None
+    actual_seconds = None
+    if status == "completed" and not unmeasured:
+        actual_seconds = duration
+        if not actual_seconds and started_at and completed_at:
+            actual_seconds = int((completed_at - started_at).total_seconds())
+        if actual_seconds is not None and actual_seconds <= 0:
+            actual_seconds = None
+
+    estimate = run_estimate(archive, plate_id)
+    if estimate is not None and not estimate.per_plate and ((run_aggregate or {}).get("run_count") or 0) > 1:
+        estimate = None
+    # A measured print shows its time; without an estimate of its own, no badge.
+    result["actual_time_seconds"] = actual_seconds
+    if estimate is None:
         return result
-
-    if archive.started_at and archive.completed_at and archive.status == "completed":
-        actual_seconds = int((archive.completed_at - archive.started_at).total_seconds())
-        if actual_seconds > 0:
-            result["actual_time_seconds"] = actual_seconds
-
-            if archive.print_time_seconds and archive.print_time_seconds > 0:
-                # Calculate accuracy as percentage
-                accuracy = (archive.print_time_seconds / actual_seconds) * 100
-                # Sanity check: skip unreasonable values (e.g., manually changed status)
-                # Valid range: 5% to 500% (print took 20x longer to 5x faster than estimated)
-                if 5 <= accuracy <= 500:
-                    result["time_accuracy"] = round(accuracy, 1)
-
+    result["time_estimate_seconds"] = estimate.seconds
+    if actual_seconds and estimate.seconds and estimate.seconds > 0:
+        accuracy = (estimate.seconds / actual_seconds) * 100
+        # Sanity check: skip unreasonable values (e.g., manually changed status)
+        # Valid range: 5% to 500% (print took 20x longer to 5x faster than estimated)
+        if 5 <= accuracy <= 500:
+            result["time_accuracy"] = round(accuracy, 1)
     return result
 
 
@@ -421,6 +442,12 @@ async def _load_run_aggregates(
         select(
             PrintLogEntry.archive_id,
             PrintLogEntry.id,
+            PrintLogEntry.status,
+            PrintLogEntry.plate_id,
+            PrintLogEntry.started_at,
+            PrintLogEntry.completed_at,
+            PrintLogEntry.duration_seconds,
+            PrintLogEntry.duration_unmeasured,
             func.row_number()
             .over(
                 partition_by=PrintLogEntry.archive_id,
@@ -431,11 +458,12 @@ async def _load_run_aggregates(
         .where(PrintLogEntry.archive_id.in_(archive_ids), *([scope] if scope is not None else []))
         .subquery()
     )
-    latest = await db.execute(
-        select(ranked.c.archive_id, ranked.c.id).where(ranked.c.rn == 1).execution_options(**{INCLUDE_OPEN_RUNS: True})
-    )
-    for archive_id, run_id in latest.all():
-        aggregates.setdefault(archive_id, {})["latest_run_id"] = run_id
+    latest = await db.execute(select(ranked).where(ranked.c.rn == 1).execution_options(**{INCLUDE_OPEN_RUNS: True}))
+    for row in latest.all():
+        aggregate = aggregates.setdefault(row.archive_id, {})
+        aggregate["latest_run_id"] = row.id
+        # What its time accuracy is measured from (#3375).
+        aggregate["latest_run"] = row
     return aggregates
 
 
@@ -474,6 +502,8 @@ def _with_run_outcome(data: dict, run: PrintLogEntry) -> dict:
         data["timelapse_path"] = run.timelapse_path
     if run.photos is not None:
         data["photos"] = run.photos or None
+    # The time accuracy is the latest print's (#3375), maybe another one.
+    data["actual_time_seconds"] = data["time_estimate_seconds"] = data["time_accuracy"] = None
     return data
 
 
@@ -490,7 +520,7 @@ def _scoped(data: dict, view: ScopedView | None) -> dict:
     # first prints, maybe on a printer outside the scope: the badge reads as
     # for any archive printed more than once, and the figures are the shown
     # print's own.
-    data["actual_time_seconds"] = data["time_accuracy"] = None
+    data["actual_time_seconds"] = data["time_estimate_seconds"] = data["time_accuracy"] = None
     for name in ("energy_kwh", "energy_cost", "wear_cost"):
         data[name] = getattr(view.shown, name) if view.shown is not None else None
     if view.hidden_timelapse is not None and data["timelapse_path"] == view.hidden_timelapse:
@@ -570,10 +600,7 @@ def archive_to_response(
         "created_by_username": archive.created_by.username if archive.created_by else None,
     }
 
-    # Add computed time accuracy fields. ``run_aggregate`` lets
-    # ``compute_time_accuracy`` suppress the badge for multi-run archives
-    # where the per-run actual / whole-file estimate scopes don't match
-    # (#1608).
+    # The time accuracy of the latest print, from ``run_aggregate`` (#3375).
     accuracy_data = compute_time_accuracy(archive, run_aggregate)
     data.update(accuracy_data)
 
@@ -1068,7 +1095,10 @@ async def list_archives_slim(
         select(
             PrintLogEntry.printer_id,
             PrintLogEntry.print_name,
+            PrintLogEntry.plate_id,
             PrintArchive.print_time_seconds,
+            PrintArchive.filament_used_grams.label("file_grams"),
+            PrintArchive.plate_estimates,
             PrintLogEntry.started_at,
             PrintLogEntry.completed_at,
             PrintLogEntry.duration_seconds,
@@ -1095,7 +1125,8 @@ async def list_archives_slim(
         {
             "printer_id": r.printer_id,
             "print_name": r.print_name,
-            "print_time_seconds": r.print_time_seconds,
+            # The estimate for the plate it printed (#3375), not the file's.
+            "print_time_seconds": _event_estimate_seconds(r),
             "actual_time_seconds": (
                 # Measured elapsed time for every status (#1390): failed /
                 # cancelled prints still ran for some duration, and Quick
@@ -1769,39 +1800,35 @@ async def get_archive_stats(
             PrintLogEntry.started_at,
             PrintLogEntry.completed_at,
             PrintLogEntry.printer_id,
+            PrintLogEntry.plate_id,
             PrintArchive.print_time_seconds,
+            PrintArchive.filament_used_grams,
+            PrintArchive.plate_estimates,
         )
         .join(PrintArchive, PrintArchive.id == PrintLogEntry.archive_id)
         .where(
             PrintLogEntry.status == "completed",
-            PrintArchive.print_time_seconds.isnot(None),
             # A run whose times were not measured is no sample (#3310).
             PrintLogEntry.duration_unmeasured.is_not(True),
             *base_conditions,
         )
     )
-    # Accuracy is meaningful only when the estimate roughly describes the
-    # work the run actually performed. Two shapes produce wildly-off ratios
-    # that are pure noise:
-    #   - multi-plate ``.gcode.3mf`` printed plate-by-plate: each run's
-    #     actual is one plate, the archive's estimate is the sum across
-    #     plates (post-#1593 parser fix), so the ratio is roughly N×100%
-    #     for an N-plate file. Pre-fix this shape was also broken, just
-    #     less dramatically — the estimate was plate-1-only so the ratio
-    #     was meaningless rather than N×.
-    #   - manual interventions / purge waste blowing the actual far past
-    #     the estimate.
-    # Clamp to the [50%, 200%] band so the printer-level average reflects
-    # real slicer-vs-reality drift, not multi-plate accounting or one-off
-    # outliers. Single-plate archives — the case the metric is actually
-    # designed for — stay fully included.
+    # Each run against the estimate of the plate it printed (#3375): the
+    # archive's adds every plate up (#1593). Where the file's plates are not
+    # known, the file's estimate, which for a multi-plate file printed plate
+    # by plate is about N x 100 %. Clamped to [50 %, 200 %] so the average
+    # reflects slicer-vs-reality drift, not those or one-off outliers
+    # (manual interventions, purge waste).
     _ACCURACY_BAND_LO = 50.0
     _ACCURACY_BAND_HI = 200.0
     average_accuracy = None
     accuracy_by_printer: dict[str, float] = {}
     accuracies: list[float] = []
     printer_accuracies: dict[str, list[float]] = {}
-    for duration_seconds, started_at, completed_at, run_printer_id, estimate_seconds in accuracy_rows.all():
+    for row in accuracy_rows.all():
+        duration_seconds, started_at, completed_at, run_printer_id = row[:4]
+        estimate = run_estimate(row, row.plate_id)
+        estimate_seconds = estimate.seconds if estimate is not None else None
         actual_seconds = duration_seconds
         if not actual_seconds and started_at and completed_at:
             elapsed = (completed_at - started_at).total_seconds()
@@ -2262,6 +2289,11 @@ async def list_archive_runs(
     for item in items:
         if item.photos is not None:
             item.photos = [p for p in item.photos if p in archive_photos] or None
+        # What the slicer gave the plate it printed (#3375); the file's
+        # figures only for its one print when its plates are not known.
+        estimate = run_estimate(archive, item.plate_id)
+        if estimate is not None and (estimate.per_plate or len(items) == 1):
+            item.estimate_seconds, item.estimate_grams = estimate.seconds, estimate.grams
     return PrintLogResponse(items=items, total=len(items))
 
 

+ 3 - 0
backend/app/api/routes/library.py

@@ -5266,6 +5266,9 @@ async def slice_and_persist_as_archive(
         extra_data=metadata,
         created_by_id=current_user_id,
     )
+    from backend.app.services.print_cost_estimate import refresh_plate_estimates
+
+    refresh_plate_estimates(new_archive)
     db.add(new_archive)
     await db.commit()
     await db.refresh(new_archive)

+ 37 - 19
backend/app/api/routes/print_queue.py

@@ -1398,25 +1398,7 @@ async def add_to_queue(
         start_position = await max_queue_position(db) + 1
 
     # Resolve print_time_seconds for SJF scheduling (cache on item at creation)
-    cached_print_time = None
-    if archive:
-        cached_print_time = archive.print_time_seconds
-        if data.plate_id:
-            archive_path = settings.base_dir / archive.file_path
-            if archive_path.exists():
-                plate_time = _extract_print_time_from_3mf(archive_path, data.plate_id)
-                if plate_time is not None:
-                    cached_print_time = plate_time
-    elif library_file:
-        if library_file.file_metadata:
-            cached_print_time = library_file.file_metadata.get("print_time_seconds")
-        if data.plate_id:
-            lib_path = Path(library_file.file_path)
-            library_file_path = lib_path if lib_path.is_absolute() else settings.base_dir / library_file.file_path
-            if library_file_path.exists():
-                plate_time = _extract_print_time_from_3mf(library_file_path, data.plate_id)
-                if plate_time is not None:
-                    cached_print_time = plate_time
+    cached_print_time = _cached_print_time(archive, library_file, data.plate_id)
 
     # Validate project exists before insert so a bogus ID yields 404, not an FK-constraint 500
     if data.project_id is not None:
@@ -2568,6 +2550,15 @@ async def update_queue_item(
         exclude_queue_item_id=item.id,
     )
 
+    # Another plate takes another time: shortest-job-first orders by it (#3375).
+    # An item with alternatives keeps theirs, the shortest (create).
+    if "plate_id" in update_data and update_data["plate_id"] != item.plate_id and not item.variants:
+        update_data["print_time_seconds"] = _cached_print_time(
+            await db.get(PrintArchive, item.archive_id) if item.archive_id else None,
+            await db.get(LibraryFile, item.library_file_id) if item.library_file_id else None,
+            update_data["plate_id"],
+        )
+
     # Re-check the dispatch claim right before mutating (#2615). Several awaited
     # validations ran since the guard above, and a scheduler worker may have
     # claimed the row in that gap. A fresh read (item isn't dirty yet, so no
@@ -2588,6 +2579,33 @@ async def update_queue_item(
     return _enrich_response(item)
 
 
+def _cached_print_time(
+    archive: PrintArchive | None, library_file: LibraryFile | None, plate_id: int | None
+) -> int | None:
+    """The slicer's time for the plate a queue item prints, cached on it for
+    shortest-job-first: the plate's own from the 3MF, else the whole file's."""
+    cached_print_time = None
+    if archive:
+        cached_print_time = archive.print_time_seconds
+        if plate_id:
+            archive_path = settings.base_dir / archive.file_path
+            if archive_path.exists():
+                plate_time = _extract_print_time_from_3mf(archive_path, plate_id)
+                if plate_time is not None:
+                    cached_print_time = plate_time
+    elif library_file:
+        if library_file.file_metadata:
+            cached_print_time = library_file.file_metadata.get("print_time_seconds")
+        if plate_id:
+            lib_path = Path(library_file.file_path)
+            library_file_path = lib_path if lib_path.is_absolute() else settings.base_dir / library_file.file_path
+            if library_file_path.exists():
+                plate_time = _extract_print_time_from_3mf(library_file_path, plate_id)
+                if plate_time is not None:
+                    cached_print_time = plate_time
+    return cached_print_time
+
+
 async def _remove_queue_item(db: AsyncSession, item: PrintQueueItem) -> bool:
     """Delete *item*, or cancel it when its order still needs it (#2960).
 

+ 2 - 2
backend/app/api/routes/projects.py

@@ -1000,8 +1000,8 @@ async def list_project_archives(
     from backend.app.api.routes.archives import _load_run_aggregates, _scoped, archive_to_response
     from backend.app.services.archive_scope import scoped_views
 
-    # Load run aggregates so multi-run archives' time/accuracy badge is
-    # suppressed consistently with the main archives list endpoint (#1608).
+    # Load run aggregates so the time/accuracy badge reads each archive's
+    # latest print, as the main archives list does (#3375).
     run_aggregates = await _load_run_aggregates(db, [a.id for a in archives], printer_scope)
     # As the archives list shows them to a caller limited to some printers.
     views = await scoped_views(db, archives, printer_scope)

+ 5 - 0
backend/app/core/database.py

@@ -5953,6 +5953,11 @@ async def _run_migrations(conn):
         "CREATE UNIQUE INDEX IF NOT EXISTS ix_print_log_entries_confirm_token ON print_log_entries (confirm_token)",
     )
 
+    # Migration: each plate's estimate on the archive (#3375). Nullable, no
+    # default; filled from the archived 3MF after startup
+    # (``print_cost_estimate.fill_plate_estimates``).
+    await _safe_execute(conn, "ALTER TABLE print_archives ADD COLUMN plate_estimates JSON")
+
     await _migrate_auth_datetimes_to_timestamp(conn)
     await _migrate_add_missing_set_null_fks(conn)
     await _migrate_wallet_transactions_keep_deleted_users(conn)

+ 43 - 4
backend/app/main.py

@@ -1653,6 +1653,37 @@ def _get_start_plate_id(printer_id: int, archive_id: int | None) -> int | None:
     return _print_plate_ids.get((printer_id, archive_id))
 
 
+def _run_print_time_seconds(archive, printer_id: int) -> int | None:
+    """The slicer's time for the plate this print prints, for its start
+    notification (#3375): ``archive.print_time_seconds`` adds every plate of
+    the file up (#1593). The plate is the dispatch's, else the archive's. The
+    whole file's time when its plates are not known; None for a plate not
+    known of several, and the notification takes the printer's remaining
+    time."""
+    from backend.app.services.print_cost_estimate import run_estimate
+
+    plate_id = _print_plate_ids.get((printer_id, archive.id), archive.plate_id)
+    estimate = run_estimate(archive, plate_id)
+    return estimate.seconds if estimate is not None else None
+
+
+_plate_estimates_task: asyncio.Task | None = None
+
+
+async def _fill_plate_estimates() -> None:
+    from backend.app.services.print_cost_estimate import fill_plate_estimates
+
+    try:
+        filled = await fill_plate_estimates(async_session)
+    except asyncio.CancelledError:
+        raise
+    except Exception as exc:
+        logging.getLogger(__name__).warning("[ARCHIVE] Reading plate estimates failed: %s", exc)
+        return
+    if filled:
+        logging.getLogger(__name__).info("[ARCHIVE] Read the plate estimates of %d archive(s)", filled)
+
+
 def _partial_progress_scale(progress: int | float | None) -> float:
     """Clamp ``progress / 100`` into [0.0, 1.0] for partial-print scaling.
 
@@ -5431,7 +5462,7 @@ async def on_print_start(printer_id: int, data: dict):
                     # register_expected_print (handles library-file-based queue items where
                     # the freshly-created archive has no created_by_id yet), taken above.
                     archive_data = {
-                        "print_time_seconds": archive.print_time_seconds,
+                        "print_time_seconds": _run_print_time_seconds(archive, printer_id),
                         "created_by_id": archive.created_by_id or fallback_creator,
                     }
                     await _send_print_start_notification(printer_id, data, archive_data, logger)
@@ -5564,7 +5595,7 @@ async def on_print_start(printer_id: int, data: dict):
                     await _record_energy_start(existing_archive, printer_id, db, context="subtask-resume")
                 if not notification_sent:
                     archive_data = {
-                        "print_time_seconds": existing_archive.print_time_seconds,
+                        "print_time_seconds": _run_print_time_seconds(existing_archive, printer_id),
                         "created_by_id": existing_archive.created_by_id,
                     }
                     await _send_print_start_notification(printer_id, data, archive_data, logger)
@@ -5667,7 +5698,7 @@ async def on_print_start(printer_id: int, data: dict):
                 # Send notification with archive data (existing archive)
                 if not notification_sent:
                     archive_data = {
-                        "print_time_seconds": existing_archive.print_time_seconds,
+                        "print_time_seconds": _run_print_time_seconds(existing_archive, printer_id),
                         "created_by_id": existing_archive.created_by_id,
                     }
                     await _send_print_start_notification(printer_id, data, archive_data, logger)
@@ -6510,7 +6541,7 @@ async def on_print_start(printer_id: int, data: dict):
                 # Send notification with archive data (new archive created)
                 if not notification_sent:
                     archive_data = {
-                        "print_time_seconds": archive.print_time_seconds,
+                        "print_time_seconds": _run_print_time_seconds(archive, printer_id),
                         "created_by_id": archive.created_by_id,
                     }
                     await _send_print_start_notification(printer_id, data, archive_data, logger)
@@ -11781,6 +11812,12 @@ async def lifespan(app: FastAPI):
     except Exception as exc:
         logging.warning("[BATCH] Startup status backfill failed: %s", exc)
 
+    # Each plate's estimate of archives made before they were kept (#3375),
+    # read from their 3MFs in the background: the readers use the whole
+    # file's figures until then.
+    global _plate_estimates_task
+    _plate_estimates_task = asyncio.create_task(_fill_plate_estimates())
+
     # Register an app-scoped httpx client for Bambu Cloud services so
     # per-request BambuCloudService instances reuse the same connection pool
     # (important for routes like /cloud/filament-info that chain many
@@ -12264,6 +12301,8 @@ async def lifespan(app: FastAPI):
         logging.warning("Failed to shut down camera broadcasters: %s", e)
     stop_expected_prints_cleanup()
     stop_auth_cleanup()
+    if _plate_estimates_task is not None and not _plate_estimates_task.done():
+        _plate_estimates_task.cancel()
     from backend.app.services import announcements as announcements_service
 
     announcements_service.stop()

+ 8 - 0
backend/app/models/archive.py

@@ -100,6 +100,14 @@ class PrintArchive(Base):
     # Plate 1. NULL for archives with no specific selected plate.
     plate_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
 
+    # The slicer's estimate for each plate of the file, by plate index
+    # (``threemf_tools.extract_plate_estimates_from_3mf``): what one print of
+    # a plate is measured against (#3375). The whole-file columns add the
+    # plates up. NULL until the file has been read; ``{}`` for a file
+    # without slice info. None is stored as SQL NULL, not JSON null, so the
+    # fill finds an archive unread again after a restore of its columns.
+    plate_estimates: Mapped[dict | None] = mapped_column(JSON(none_as_null=True), nullable=True)
+
     # Extended metadata (JSON blob for flexibility)
     extra_data: Mapped[dict | None] = mapped_column(JSON)
 

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

@@ -110,6 +110,9 @@ class ArchiveResponse(BaseModel):
     actual_time_seconds: int | None = _per_print(None)  # Computed from started_at/completed_at
     # Percentage: 100 = perfect, >100 = faster than estimated
     time_accuracy: float | None = _per_print(None)
+    # The slicer's time for the plate the latest print printed (#3375), what
+    # time_accuracy compares with; print_time_seconds adds every plate up.
+    time_estimate_seconds: int | None = _per_print(None)
     filament_used_grams: float | None
     filament_type: str | None
     filament_color: str | None

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

@@ -42,6 +42,10 @@ class PrintLogEntrySchema(BaseModel):
     notes: str | None = None
     photos: list[str] | None = None
     timelapse_path: str | None = None
+    # The slicer's estimate for the plate it printed (#3375); only in an
+    # archive's runs list (GET /archives/{id}/runs).
+    estimate_seconds: int | None = None
+    estimate_grams: float | None = None
     thumbnail_path: str | None = None
     created_by_id: int | None = None
     created_by_username: str | None = None

+ 6 - 0
backend/app/services/archive.py

@@ -1562,6 +1562,9 @@ class ArchiveService:
                 existing.plate_id = plate_id
             if library_file_id is not None:
                 existing.library_file_id = library_file_id
+            from backend.app.services.print_cost_estimate import refresh_plate_estimates
+
+            refresh_plate_estimates(existing)
             await self.db.commit()
             await self.db.refresh(existing)
             return existing
@@ -1611,6 +1614,9 @@ class ArchiveService:
             subtask_id=subtask_id,
             plate_id=plate_id,
         )
+        from backend.app.services.print_cost_estimate import refresh_plate_estimates
+
+        refresh_plate_estimates(archive)
 
         self.db.add(archive)
         try:

+ 5 - 0
backend/app/services/archive_merge.py

@@ -42,6 +42,7 @@ from backend.app.models.archive_alias import ArchiveAlias
 from backend.app.models.print_log import INCLUDE_OPEN_RUNS, RUN_OPEN, PrintLogEntry
 from backend.app.models.print_queue import PrintQueueItem
 from backend.app.services import archive_work
+from backend.app.services.print_cost_estimate import refresh_plate_estimates
 from backend.app.utils.archive_paths import archive_dir, archive_photos_dir, find_archive_photo
 from backend.app.utils.local_time import utcnow_naive
 from backend.app.utils.safe_path import PathTraversalError, safe_join_under
@@ -537,6 +538,10 @@ async def _merge(
                 survivor_photos.append(photos[name])
         for attr, relpath in extras.items():
             setattr(survivor, attr, relpath)
+        if "file_path" in extras:
+            # The same file (one hash), but the survivor's was missing: its
+            # plates are read from the one it takes over (#3375).
+            refresh_plate_estimates(survivor)
         for run in runs[archive.id]:
             if run.photos:
                 run.photos = [photos[p] for p in run.photos if p in photos]

+ 92 - 1
backend/app/services/print_cost_estimate.py

@@ -1,11 +1,14 @@
 """Trusted server-side cost estimates for queued prints."""
 
+import asyncio
 import json
 import logging
+from dataclasses import dataclass
 from decimal import ROUND_HALF_UP, Decimal
 from pathlib import Path
+from typing import Any
 
-from sqlalchemy import select
+from sqlalchemy import select, update
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.orm import selectinload
 
@@ -20,6 +23,94 @@ from backend.app.utils.safe_path import safe_join_under
 logger = logging.getLogger(__name__)
 
 
+@dataclass(frozen=True)
+class RunEstimate:
+    """The slicer's estimate for one print of an archive (#3375).
+
+    ``per_plate`` is False when the archive's plates are not known (the file
+    not read, not sliced, or gone): the figures are then the whole file's,
+    which describe a print only of a single-plate file.
+    """
+
+    seconds: int | None
+    grams: float | None
+    per_plate: bool
+
+
+def run_estimate(archive: Any, plate_id: int | None) -> RunEstimate | None:
+    """What one print of plate ``plate_id`` of ``archive`` was estimated at:
+    that plate's figures, or the only plate's. None for a print of a
+    multi-plate file whose plate is not known. ``archive`` is an archive, or
+    a row with its ``plate_estimates``, ``print_time_seconds`` and
+    ``filament_used_grams``."""
+    plates = archive.plate_estimates
+    if not plates:
+        return RunEstimate(archive.print_time_seconds, archive.filament_used_grams, per_plate=False)
+    if plate_id is not None and str(plate_id) in plates:
+        plate = plates[str(plate_id)]
+    elif len(plates) == 1:
+        plate = next(iter(plates.values()))
+    else:
+        return None
+    return RunEstimate(plate.get("seconds") or None, plate.get("grams") or None, per_plate=True)
+
+
+def read_plate_estimates(archive: Any) -> dict | None:
+    """The plate estimates of the archive's 3MF on disk. ``{}`` for a file that
+    is not a 3MF; None when there is no file to read or it cannot be read."""
+    if not archive.file_path:
+        return None
+    path = settings.base_dir / archive.file_path  # SEC-PATH-OK: archive.file_path is DB-stored, internally generated
+    if not path.is_file():
+        return None
+    if path.suffix.lower() != ".3mf":
+        return {}
+    return threemf_tools.extract_plate_estimates_from_3mf(path)
+
+
+def refresh_plate_estimates(archive: PrintArchive) -> None:
+    """Read the plate estimates of the archive's file into it: when the
+    archive is made, or given a file. Does not commit."""
+    estimates = read_plate_estimates(archive)
+    if estimates is not None:
+        archive.plate_estimates = estimates
+
+
+async def fill_plate_estimates(session_factory, batch: int = 100) -> int:
+    """Give every archive not yet read its plate estimates, from its 3MF.
+    An archive whose file is missing or unreadable gets ``{}``: it is read
+    once, its figures stay the whole file's. Returns how many were read.
+
+    Each is written only while it is still unread: an archive given its 3MF
+    meanwhile (``refresh_plate_estimates``) keeps what that read, and one
+    deleted meanwhile is skipped."""
+    filled = 0
+    last_id = 0
+    while True:
+        async with session_factory() as db:
+            rows = (
+                await db.execute(
+                    select(PrintArchive.id, PrintArchive.file_path)
+                    .where(PrintArchive.plate_estimates.is_(None), PrintArchive.id > last_id)
+                    .order_by(PrintArchive.id)
+                    .limit(batch)
+                )
+            ).all()
+            if not rows:
+                return filled
+            for row in rows:
+                estimates = await asyncio.to_thread(read_plate_estimates, row)
+                written = await db.execute(
+                    update(PrintArchive)
+                    .where(PrintArchive.id == row.id, PrintArchive.plate_estimates.is_(None))
+                    .values(plate_estimates=estimates if estimates is not None else {})
+                    .execution_options(synchronize_session=False)
+                )
+                filled += written.rowcount or 0
+            last_id = rows[-1].id
+            await db.commit()
+
+
 def plate_scoped_run_estimate(
     archive: PrintArchive,
     full_path: Path | None,

+ 5 - 3
backend/app/services/print_scheduler.py

@@ -10026,11 +10026,13 @@ class PrintScheduler:
             #
             # The queue item caches the print time at creation ("Cached from
             # archive/library"), which is the value this was reaching for.
+            # The item's is its plate's; the archive's adds every plate of the
+            # file up (#3375).
             estimated_time = None
-            if archive and archive.print_time_seconds:
-                estimated_time = archive.print_time_seconds
-            elif item.print_time_seconds:
+            if item.print_time_seconds:
                 estimated_time = item.print_time_seconds
+            elif archive and archive.print_time_seconds:
+                estimated_time = archive.print_time_seconds
 
             # Send job started notification
             await notification_service.on_queue_job_started(

+ 35 - 0
backend/app/utils/threemf_tools.py

@@ -1096,6 +1096,41 @@ def extract_plate_metadata_from_3mf(file_path: Path, plate_id: int | None = None
     return metadata
 
 
+def extract_plate_estimates_from_3mf(file_path: Path) -> dict[str, dict] | None:
+    """The slicer's estimate for each plate of a sliced 3MF, by plate index:
+    ``{"2": {"seconds": 5400, "grams": 41.2}}``. ``seconds`` is None when the
+    plate carries no prediction. Empty for a file without slice info (not
+    sliced); None when the file cannot be read, so a caller can try again."""
+    try:
+        with zipfile.ZipFile(file_path, "r") as zf:
+            if "Metadata/slice_info.config" not in zf.namelist():
+                return {}
+            root = ET.fromstring(zf.read("Metadata/slice_info.config").decode())
+    except Exception as e:
+        logger.warning("Failed to read plate estimates from %s: %s", file_path, e)
+        return None
+
+    estimates: dict[str, dict] = {}
+    for plate in root.findall(".//plate"):
+        meta = {m.get("key"): m.get("value") for m in plate.findall("metadata")}
+        try:
+            index = int(meta.get("index") or "")
+        except ValueError:
+            continue
+        try:
+            seconds = int(meta.get("prediction") or "") or None
+        except ValueError:
+            seconds = None
+        grams = 0.0
+        for f in plate.findall("filament"):
+            try:
+                grams += float(f.get("used_g", "0"))
+            except (ValueError, TypeError):
+                continue
+        estimates[str(index)] = {"seconds": seconds, "grams": round(grams, 2)}
+    return estimates
+
+
 def extract_filament_usage_from_3mf(file_path: Path, plate_id: int | None = None) -> list[dict]:
     """Extract per-filament total usage from 3MF slice_info.config.
 

+ 16 - 0
backend/tests/integration/test_archive_merge.py

@@ -567,6 +567,22 @@ async def test_the_survivors_lost_file_comes_from_a_duplicate(async_client, db_s
     assert (files / archive.file_path).read_bytes() == b"3mf"
 
 
+async def test_the_survivor_reads_its_plates_from_the_file_it_takes_over(async_client, db_session, files, dupes):
+    from backend.tests.integration.test_plate_estimates_3375 import PLATES, _three_mf
+
+    (dupes.x_dir / "file.3mf").unlink()
+    for folder in (dupes.y_dir, dupes.z_dir):
+        _three_mf(folder / "file.3mf")
+    survivor = await db_session.get(PrintArchive, dupes.x)
+    survivor.plate_estimates = {}  # read while its file was missing
+    await db_session.commit()
+
+    await async_client.post("/api/v1/archives/merge", json={"survivor_ids": [dupes.x]})
+
+    archive = await _row(db_session, PrintArchive, dupes.x)
+    assert archive.plate_estimates == PLATES
+
+
 async def test_a_copy_that_fails_leaves_no_partial_file(files):
     from backend.app.services.archive_merge import _Files
 

+ 356 - 0
backend/tests/integration/test_plate_estimates_3375.py

@@ -0,0 +1,356 @@
+"""Each print against the estimate of the plate it printed (#3375).
+
+A multi-plate 3MF's archive adds its plates up (#1593): one plate printed
+read as +200 % on the card and skewed the statistics. The archive keeps each
+plate's estimate, read from its 3MF, and a print is measured against its
+plate's.
+"""
+
+from __future__ import annotations
+
+import zipfile
+from datetime import datetime, timedelta
+from types import SimpleNamespace
+
+import pytest
+from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
+
+from backend.app.core.config import settings as app_settings
+from backend.app.models.archive import PrintArchive
+from backend.app.models.print_log import PrintLogEntry
+from backend.app.services.print_cost_estimate import (
+    fill_plate_estimates,
+    refresh_plate_estimates,
+    run_estimate,
+)
+from backend.app.utils.threemf_tools import extract_plate_estimates_from_3mf
+
+T1 = datetime(2026, 10, 30, 9)
+DIR = "archives/plates"
+
+SLICE_INFO = """<?xml version="1.0" encoding="UTF-8"?>
+<config>
+  <plate>
+    <metadata key="index" value="1"/>
+    <metadata key="prediction" value="3600"/>
+    <filament id="1" type="PLA" color="#FFFFFF" used_m="10" used_g="30.5"/>
+    <filament id="2" type="PLA" color="#000000" used_m="3" used_g="9.5"/>
+  </plate>
+  <plate>
+    <metadata key="index" value="2"/>
+    <metadata key="prediction" value="1800"/>
+    <filament id="1" type="PLA" color="#FFFFFF" used_m="6" used_g="20"/>
+  </plate>
+</config>
+"""
+
+
+def _three_mf(path, slice_info: str | None = SLICE_INFO):
+    path.parent.mkdir(parents=True, exist_ok=True)
+    with zipfile.ZipFile(path, "w") as zf:
+        zf.writestr("3D/3dmodel.model", "<model/>")
+        if slice_info is not None:
+            zf.writestr("Metadata/slice_info.config", slice_info)
+    return path
+
+
+@pytest.fixture
+def files(tmp_path, monkeypatch):
+    monkeypatch.setattr(app_settings, "base_dir", tmp_path)
+    monkeypatch.setattr(app_settings, "archive_dir", tmp_path / "archive")
+    _three_mf(tmp_path / DIR / "two.gcode.3mf")
+    return tmp_path
+
+
+def _file(**overrides):
+    values = {"plate_estimates": None, "print_time_seconds": 5400, "filament_used_grams": 60.0}
+    return SimpleNamespace(**{**values, **overrides})
+
+
+PLATES = {"1": {"seconds": 3600, "grams": 40.0}, "2": {"seconds": 1800, "grams": 20.0}}
+
+
+class TestReadingPlates:
+    def test_each_plate_has_its_time_and_grams(self, tmp_path):
+        assert extract_plate_estimates_from_3mf(_three_mf(tmp_path / "f.3mf")) == PLATES
+
+    def test_a_file_not_sliced_has_none(self, tmp_path):
+        assert extract_plate_estimates_from_3mf(_three_mf(tmp_path / "f.3mf", slice_info=None)) == {}
+
+    def test_an_unreadable_file_is_left_to_read_again(self, tmp_path):
+        bad = tmp_path / "f.3mf"
+        bad.write_bytes(b"not a zip")
+
+        assert extract_plate_estimates_from_3mf(bad) is None
+
+
+class TestRunEstimate:
+    def test_a_print_of_a_plate_is_that_plates(self):
+        estimate = run_estimate(_file(plate_estimates=PLATES), 2)
+
+        assert (estimate.seconds, estimate.grams, estimate.per_plate) == (1800, 20.0, True)
+
+    def test_a_print_of_a_single_plate_file_is_its_plate_whatever_its_number(self):
+        estimate = run_estimate(_file(plate_estimates={"2": PLATES["2"]}), 1)
+
+        assert (estimate.seconds, estimate.per_plate) == (1800, True)
+
+    def test_a_print_of_an_unknown_plate_of_several_has_none(self):
+        assert run_estimate(_file(plate_estimates=PLATES), None) is None
+
+    def test_plates_not_known_give_the_files_figures(self):
+        for plates in (None, {}):
+            estimate = run_estimate(_file(plate_estimates=plates), 2)
+
+            assert (estimate.seconds, estimate.grams, estimate.per_plate) == (5400, 60.0, False)
+
+
+class TestKeepingPlates:
+    def test_an_archive_reads_its_files_plates(self, files):
+        archive = PrintArchive(filename="two.gcode.3mf", file_path=f"{DIR}/two.gcode.3mf", file_size=1)
+
+        refresh_plate_estimates(archive)
+
+        assert archive.plate_estimates == PLATES
+
+    def test_a_gcode_file_has_no_plates_to_read(self, files):
+        (files / DIR / "cube.gcode").write_text("; gcode\n")
+        archive = PrintArchive(filename="cube.gcode", file_path=f"{DIR}/cube.gcode", file_size=1)
+
+        refresh_plate_estimates(archive)
+
+        assert archive.plate_estimates == {}
+
+    def test_an_archive_without_its_file_stays_unread(self, files):
+        archive = PrintArchive(filename="gone.3mf", file_path=f"{DIR}/gone.3mf", file_size=1)
+
+        refresh_plate_estimates(archive)
+
+        assert archive.plate_estimates is None
+
+    async def test_an_archived_print_keeps_its_files_plates(self, files, db_session, printer_factory):
+        from backend.app.services.archive import ArchiveService
+
+        printer = await printer_factory()
+        source = _three_mf(files / "upload" / "two.gcode.3mf")
+
+        archive = await ArchiveService(db_session).archive_print(printer.id, source)
+
+        assert archive.plate_estimates == PLATES
+
+    async def test_a_print_given_its_3mf_later_reads_its_plates(self, files, db_session, printer_factory):
+        from backend.app.services.archive import ArchiveService
+
+        printer = await printer_factory()
+        fallback = PrintArchive(printer_id=printer.id, filename="two.3mf", file_path="", file_size=0, status="printing")
+        db_session.add(fallback)
+        await db_session.commit()
+        source = _three_mf(files / "upload" / "two.gcode.3mf")
+
+        archive = await ArchiveService(db_session).archive_print(printer.id, source, update_archive_id=fallback.id)
+
+        assert (archive.id, archive.plate_estimates) == (fallback.id, PLATES)
+
+    async def test_archives_made_before_get_theirs_once(self, files, db_session, test_engine):
+        older = PrintArchive(filename="two.gcode.3mf", file_path=f"{DIR}/two.gcode.3mf", file_size=1)
+        gone = PrintArchive(filename="gone.3mf", file_path=f"{DIR}/gone.3mf", file_size=1)
+        read = PrintArchive(filename="r.3mf", file_path=f"{DIR}/two.gcode.3mf", file_size=1, plate_estimates={})
+        db_session.add_all([older, gone, read])
+        await db_session.commit()
+
+        sessions = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
+        assert await fill_plate_estimates(sessions, batch=1) == 2
+        assert await fill_plate_estimates(sessions) == 0
+
+        for row in (older, gone, read):
+            await db_session.refresh(row)
+        assert (older.plate_estimates, gone.plate_estimates, read.plate_estimates) == (PLATES, {}, {})
+
+    async def test_an_archive_read_meanwhile_keeps_what_was_read(self, files, db_session, test_engine, monkeypatch):
+        from backend.app.services import print_cost_estimate
+
+        racing = PrintArchive(filename="two.gcode.3mf", file_path="", file_size=0)
+        deleted = PrintArchive(filename="gone.3mf", file_path=f"{DIR}/gone.3mf", file_size=1)
+        db_session.add_all([racing, deleted])
+        await db_session.commit()
+        sessions = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
+
+        async def meanwhile(fn, row):
+            # Its 3MF recovered and read, the other one deleted, while the
+            # fill reads the first.
+            if row.id == racing.id:
+                async with sessions() as other:
+                    (await other.get(PrintArchive, racing.id)).plate_estimates = PLATES
+                    await other.delete(await other.get(PrintArchive, deleted.id))
+                    await other.commit()
+            return fn(row)
+
+        monkeypatch.setattr(print_cost_estimate, "asyncio", SimpleNamespace(to_thread=meanwhile))
+
+        assert await fill_plate_estimates(sessions) == 0
+        await db_session.refresh(racing)
+        assert racing.plate_estimates == PLATES
+
+    async def test_an_archive_set_back_to_unread_is_read_again(self, files, db_session, test_engine):
+        archive = PrintArchive(filename="two.gcode.3mf", file_path=f"{DIR}/two.gcode.3mf", file_size=1)
+        archive.plate_estimates = {}
+        db_session.add(archive)
+        await db_session.commit()
+        # As the print start's rollback of an adoption restores its columns.
+        archive.plate_estimates = None
+        await db_session.commit()
+
+        sessions = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
+        assert await fill_plate_estimates(sessions) == 1
+
+
+@pytest.fixture
+async def printed(db_session, printer_factory, files):
+    """Plate 1 of the file printed in its estimate, then plate 2 in 2000 s
+    of its 1800."""
+    printer = await printer_factory()
+    archive = PrintArchive(
+        printer_id=printer.id,
+        filename="two.gcode.3mf",
+        file_path=f"{DIR}/two.gcode.3mf",
+        file_size=1,
+        print_name="two",
+        print_time_seconds=5400,
+        filament_used_grams=60.0,
+        status="completed",
+        plate_estimates=PLATES,
+    )
+    db_session.add(archive)
+    await db_session.flush()
+    first = PrintLogEntry(
+        archive_id=archive.id,
+        printer_id=printer.id,
+        plate_id=1,
+        status="completed",
+        started_at=T1,
+        completed_at=T1 + timedelta(seconds=3600),
+        duration_seconds=3600,
+        filament_used_grams=40.0,
+    )
+    second = PrintLogEntry(
+        archive_id=archive.id,
+        printer_id=printer.id,
+        plate_id=2,
+        status="completed",
+        started_at=T1 + timedelta(hours=2),
+        completed_at=T1 + timedelta(hours=2, seconds=2000),
+        duration_seconds=2000,
+        filament_used_grams=21.0,
+    )
+    db_session.add_all([first, second])
+    await db_session.commit()
+    return SimpleNamespace(archive=archive, printer=printer, first=first.id, second=second.id)
+
+
+async def _card(async_client, archive_id):
+    return next(a for a in (await async_client.get("/api/v1/archives/")).json() if a["id"] == archive_id)
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+class TestReading:
+    async def test_the_card_measures_the_latest_print_against_its_plate(self, async_client, printed):
+        card = await _card(async_client, printed.archive.id)
+
+        assert (card["time_estimate_seconds"], card["actual_time_seconds"], card["time_accuracy"]) == (1800, 2000, 90.0)
+
+    async def test_a_file_whose_plates_are_unknown_shows_no_badge_once_printed_twice(
+        self, async_client, db_session, printed
+    ):
+        printed.archive.plate_estimates = None
+        await db_session.commit()
+
+        card = await _card(async_client, printed.archive.id)
+
+        assert (card["time_estimate_seconds"], card["time_accuracy"]) == (None, None)
+
+    async def test_a_print_not_completed_shows_its_estimate_and_no_badge(self, async_client, db_session, printed):
+        second = await db_session.get(PrintLogEntry, printed.second)
+        second.status = "failed"
+        await db_session.commit()
+
+        card = await _card(async_client, printed.archive.id)
+
+        assert (card["time_estimate_seconds"], card["actual_time_seconds"], card["time_accuracy"]) == (1800, None, None)
+
+    async def test_a_print_of_an_unknown_plate_shows_its_time_and_no_badge(self, async_client, db_session, printed):
+        second = await db_session.get(PrintLogEntry, printed.second)
+        second.plate_id = None
+        await db_session.commit()
+
+        card = await _card(async_client, printed.archive.id)
+
+        assert (card["time_estimate_seconds"], card["actual_time_seconds"], card["time_accuracy"]) == (None, 2000, None)
+
+    async def test_an_archive_shown_as_another_print_has_no_badge_of_the_latest(self, async_client, printed):
+        shown = (await async_client.get(f"/api/v1/archives/{printed.archive.id}?run_id={printed.first}")).json()
+
+        assert (shown["plate_id"], shown["time_estimate_seconds"], shown["time_accuracy"]) == (1, None, None)
+
+    async def test_each_print_event_has_its_plates_estimate(self, async_client, printed):
+        events = (await async_client.get("/api/v1/archives/slim")).json()
+
+        assert sorted(e["print_time_seconds"] for e in events) == [1800, 3600]
+
+    async def test_each_print_carries_its_plates_estimate(self, async_client, db_session, printed):
+        third = PrintLogEntry(archive_id=printed.archive.id, printer_id=printed.printer.id, status="failed")
+        db_session.add(third)
+        await db_session.commit()
+
+        runs = (await async_client.get(f"/api/v1/archives/{printed.archive.id}/runs")).json()["items"]
+        by_id = {r["id"]: (r["estimate_seconds"], r["estimate_grams"]) for r in runs}
+
+        assert by_id == {printed.first: (3600, 40.0), printed.second: (1800, 20.0), third.id: (None, None)}
+
+    async def test_prints_of_a_file_whose_plates_are_unknown_get_no_estimate(self, async_client, db_session, printed):
+        printed.archive.plate_estimates = None
+        await db_session.commit()
+
+        runs = (await async_client.get(f"/api/v1/archives/{printed.archive.id}/runs")).json()["items"]
+
+        # The file's 5400 s describes neither plate.
+        assert [r["estimate_seconds"] for r in runs] == [None, None]
+
+    async def test_the_statistics_measure_each_print_against_its_plate(self, async_client, printed):
+        body = (await async_client.get("/api/v1/archives/stats")).json()
+
+        # 100 % and 90 %: against the file's 5400 s they were 150 % and 270 %.
+        assert body["average_time_accuracy"] == pytest.approx(95.0)
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_a_queued_job_moved_to_another_plate_takes_its_time(async_client, db_session, printed):
+    from backend.app.models.print_queue import PrintQueueItem
+
+    item = PrintQueueItem(
+        printer_id=printed.printer.id, archive_id=printed.archive.id, position=1, plate_id=1, print_time_seconds=3600
+    )
+    db_session.add(item)
+    await db_session.commit()
+
+    response = await async_client.patch(f"/api/v1/queue/{item.id}", json={"plate_id": 2})
+
+    assert response.status_code == 200, response.text
+    await db_session.refresh(item)
+    assert item.print_time_seconds == 1800
+
+
+def test_the_start_notification_gives_the_plates_time(monkeypatch):
+    from backend.app import main
+
+    archive = SimpleNamespace(id=7, plate_id=1, plate_estimates=PLATES, print_time_seconds=5400, filament_used_grams=60)
+    monkeypatch.setattr(main, "_print_plate_ids", {(3, 7): 2})
+
+    assert main._run_print_time_seconds(archive, 3) == 1800
+    # Another printer's dispatch of it: the archive's plate.
+    assert main._run_print_time_seconds(archive, 4) == 3600
+    # Plates not known: the file's.
+    assert main._run_print_time_seconds(SimpleNamespace(**{**vars(archive), "plate_estimates": None}), 3) == 5400
+    # A plate not known of several: none, the printer's remaining time is used.
+    assert main._run_print_time_seconds(SimpleNamespace(**{**vars(archive), "plate_id": None}), 4) is None

+ 16 - 11
backend/tests/unit/test_archive_run_aggregation.py

@@ -344,6 +344,10 @@ class TestComputeTimeAccuracyMultiRun:
             started_at=started_at,
             completed_at=completed_at,
             status=status,
+            plate_id=None,
+            filament_used_grams=None,
+            # Plates not read: the file's estimate stands for the print.
+            plate_estimates=None,
         )
 
     def test_single_run_keeps_original_behaviour(self):
@@ -377,11 +381,11 @@ class TestComputeTimeAccuracyMultiRun:
         assert result["actual_time_seconds"] == 3000
         assert result["time_accuracy"] == 120.0  # 3600/3000
 
-    def test_multi_run_archive_suppresses_both_fields(self):
+    def test_multi_run_archive_suppresses_the_badge(self):
         """Reporter's case (archive #65, 3 plates, 9 logged runs): one-run
         actual (6364s) vs whole-file estimate (18354s) → +188% badge that
-        means nothing. Multi-run must clear both fields so the card falls
-        through to the estimate display with no badge."""
+        means nothing. Without the file's plates known, a multi-run archive
+        shows its latest print's time and no badge (#3375)."""
         from backend.app.api.routes.archives import compute_time_accuracy
 
         archive = self._make_archive(
@@ -391,7 +395,9 @@ class TestComputeTimeAccuracyMultiRun:
         )
         result = compute_time_accuracy(archive, run_aggregate={"run_count": 9})
 
-        assert result["actual_time_seconds"] is None
+        # The latest print's own time stands; no badge against the whole file
+        # (#3375: its plates are not known here).
+        assert result["actual_time_seconds"] == 6364
         assert result["time_accuracy"] is None
 
     def test_run_count_zero_keeps_original_behaviour(self):
@@ -420,9 +426,9 @@ async def test_archive_list_suppresses_time_accuracy_for_multi_run_archives(
     async_client: AsyncClient, archive_factory, printer_factory, db_session
 ):
     """#1608 integration: the card response from the main list endpoint
-    must report ``actual_time_seconds = null`` and ``time_accuracy = null``
-    for an archive with multiple logged runs, so the frontend renders the
-    slicer estimate without the misleading +N% badge."""
+    must report ``time_accuracy = null`` for an archive with multiple logged
+    runs whose plates are not known, so the frontend shows no misleading
+    +N% badge; its time is the latest print's own (#3375)."""
     printer = await printer_factory()
 
     # 3-plate file: estimate is the whole-file sum, latest run is one plate.
@@ -452,10 +458,9 @@ async def test_archive_list_suppresses_time_accuracy_for_multi_run_archives(
     assert response.status_code == 200
     row = next(r for r in response.json() if r["id"] == archive.id)
 
-    # Frontend renders archive.actual_time_seconds || archive.print_time_seconds —
-    # with actual cleared, it falls through to the estimate; with accuracy
-    # cleared, no badge renders.
-    assert row["actual_time_seconds"] is None, "multi-run actual is incommensurable with the estimate — must be null"
+    # The latest print's own time, and no badge: the file's plates are not
+    # known, so the whole-file estimate is no measure of one print (#3375).
+    assert row["actual_time_seconds"] == 6364
     assert row["time_accuracy"] is None, "no badge for multi-run archives — the scopes don't match"
     # The estimate itself is preserved so the card has something to display.
     assert row["print_time_seconds"] == 18354

+ 11 - 1
frontend/src/__tests__/pages/ArchiveDetailPage.test.tsx

@@ -79,7 +79,7 @@ const run = (overrides: Record<string, unknown>) => ({
 
 const runs = [
   run({ id: 12, plate_id: 2, printer_name: 'B', status: 'failed', failure_reason: 'spaghetti', started_at: '2026-10-20T09:00:00Z', timelapse_path: 'archives/p/b.mp4', photos: ['finish_b.jpg'] }),
-  run({ id: 11, plate_id: 1, user_verdict: 'good', notes: 'brim lifted', timelapse_path: 'archives/p/a.mp4', photos: ['finish_a.jpg'] }),
+  run({ id: 11, plate_id: 1, user_verdict: 'good', notes: 'brim lifted', timelapse_path: 'archives/p/a.mp4', photos: ['finish_a.jpg'], estimate_seconds: 3300, estimate_grams: 42 }),
 ];
 
 describe('ArchiveDetailPage', () => {
@@ -113,6 +113,16 @@ describe('ArchiveDetailPage', () => {
     expect(within(items[1]).getByText('good')).toBeInTheDocument();
   });
 
+  it("puts each print's figures next to its plate's estimate", async () => {
+    render(<ArchiveDetailPage />);
+    const first = await screen.findByTestId('archive-run-11');
+
+    expect(within(first).getByText('1h 0m (est. 55m)')).toBeInTheDocument();
+    expect(within(first).getByText('40.0 g (est. 42.0 g)')).toBeInTheDocument();
+    // A print without one shows its own figures alone.
+    expect(within(screen.getByTestId('archive-run-12')).getByText('40.0 g')).toBeInTheDocument();
+  });
+
   it("plays each print's own video", async () => {
     render(<ArchiveDetailPage />);
     const first = await screen.findByTestId('archive-run-11');

+ 14 - 0
frontend/src/__tests__/pages/ArchivesPage.test.tsx

@@ -137,6 +137,20 @@ describe('ArchivesPage', () => {
       });
     });
 
+    it("compares the latest print with its plate's estimate", async () => {
+      server.use(
+        http.get('/api/v1/archives/', () =>
+          HttpResponse.json([
+            { ...mockArchives[1], run_count: 2, actual_time_seconds: 2000, time_estimate_seconds: 1800, time_accuracy: 90 },
+          ])
+        )
+      );
+      render(<ArchivesPage />);
+
+      const time = (await screen.findByText('-10%')).parentElement!;
+      expect(time.getAttribute('title')).toContain('Estimated: 30m');
+    });
+
     it('shows printer name', async () => {
       render(<ArchivesPage />);
 

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

@@ -890,6 +890,7 @@ export interface Archive {
   print_time_seconds: number | null;
   actual_time_seconds: number | null;  // Computed from started_at/completed_at
   time_accuracy: number | null;  // Percentage: 100 = perfect, >100 = faster than estimated
+  time_estimate_seconds?: number | null;  // The latest print's plate estimate, what time_accuracy compares with
   filament_used_grams: number | null;
   filament_type: string | null;
   filament_color: string | null;
@@ -989,6 +990,9 @@ export interface PrintLogEntry {
   notes?: string | null;
   photos?: string[] | null;
   timelapse_path?: string | null;
+  // The slicer's estimate for the plate it printed; in an archive's runs list.
+  estimate_seconds?: number | null;
+  estimate_grams?: number | null;
   thumbnail_path: string | null;
   created_by_id: number | null;
   created_by_username: string | null;

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

@@ -6016,6 +6016,7 @@ export default {
     noteFailed: 'Notiz konnte nicht gespeichert werden',
     editNote: 'Notiz bearbeiten',
     addNote: 'Notiz hinzufügen',
+    withEstimate: '{{value}} (geschätzt {{estimate}})',
   },
 
   confirmOutcome: {

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

@@ -6069,6 +6069,7 @@ export default {
     noteFailed: 'Could not save the note',
     editNote: 'Edit note',
     addNote: 'Add note',
+    withEstimate: '{{value}} (est. {{estimate}})',
   },
 
   confirmOutcome: {

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

@@ -6023,6 +6023,7 @@ export default {
     noteFailed: 'No se pudo guardar la nota',
     editNote: 'Editar nota',
     addNote: 'Añadir nota',
+    withEstimate: '{{value}} (estimado {{estimate}})',
   },
 
   confirmOutcome: {

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

@@ -6003,6 +6003,7 @@ export default {
     noteFailed: 'Impossible d\'enregistrer la note',
     editNote: 'Modifier la note',
     addNote: 'Ajouter une note',
+    withEstimate: '{{value}} (estimé {{estimate}})',
   },
 
   confirmOutcome: {

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

@@ -6002,6 +6002,7 @@ export default {
     noteFailed: 'Impossibile salvare la nota',
     editNote: 'Modifica nota',
     addNote: 'Aggiungi nota',
+    withEstimate: '{{value}} (stimato {{estimate}})',
   },
 
   confirmOutcome: {

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

@@ -6016,6 +6016,7 @@ export default {
     noteFailed: 'メモを保存できませんでした',
     editNote: 'メモを編集',
     addNote: 'メモを追加',
+    withEstimate: '{{value}}(予測 {{estimate}})',
   },
 
   confirmOutcome: {

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

@@ -5769,6 +5769,7 @@ export default {
     noteFailed: '메모를 저장할 수 없습니다',
     editNote: '메모 편집',
     addNote: '메모 추가',
+    withEstimate: '{{value}} (예상 {{estimate}})',
   },
 
   confirmOutcome: {

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

@@ -6066,6 +6066,7 @@ export default {
     noteFailed: 'Notitie kon niet worden opgeslagen',
     editNote: 'Notitie bewerken',
     addNote: 'Notitie toevoegen',
+    withEstimate: '{{value}} (geschat {{estimate}})',
   },
 
   confirmOutcome: {

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

@@ -6002,6 +6002,7 @@ export default {
     noteFailed: 'Não foi possível salvar a nota',
     editNote: 'Editar nota',
     addNote: 'Adicionar nota',
+    withEstimate: '{{value}} (estimado {{estimate}})',
   },
 
   confirmOutcome: {

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

@@ -5757,6 +5757,7 @@ export default {
     noteFailed: 'Не удалось сохранить заметку',
     editNote: 'Изменить заметку',
     addNote: 'Добавить заметку',
+    withEstimate: '{{value}} (оценка {{estimate}})',
   },
 
   confirmOutcome: {

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

@@ -6065,6 +6065,7 @@ errors: {
     noteFailed: 'Anteckningen kunde inte sparas',
     editNote: 'Redigera anteckning',
     addNote: 'Lägg till anteckning',
+    withEstimate: '{{value}} (beräknat {{estimate}})',
   },
 
   confirmOutcome: {

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

@@ -5990,6 +5990,7 @@ export default {
     noteFailed: 'Not kaydedilemedi',
     editNote: 'Notu düzenle',
     addNote: 'Not ekle',
+    withEstimate: '{{value}} (tahmini {{estimate}})',
   },
 
   confirmOutcome: {

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

@@ -6056,6 +6056,7 @@ export default {
     noteFailed: 'Не вдалося зберегти нотатку',
     editNote: 'Редагувати нотатку',
     addNote: 'Додати нотатку',
+    withEstimate: '{{value}} (оцінка {{estimate}})',
   },
 
   confirmOutcome: {

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

@@ -6002,6 +6002,7 @@ export default {
     noteFailed: '无法保存备注',
     editNote: '编辑备注',
     addNote: '添加备注',
+    withEstimate: '{{value}}(预计 {{estimate}})',
   },
 
   confirmOutcome: {

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

@@ -6002,6 +6002,7 @@ export default {
     noteFailed: '無法儲存備註',
     editNote: '編輯備註',
     addNote: '新增備註',
+    withEstimate: '{{value}}(預估 {{estimate}})',
   },
 
   confirmOutcome: {

+ 21 - 2
frontend/src/pages/ArchiveDetailPage.tsx

@@ -548,9 +548,28 @@ function RunItem({
   });
 
   const verdictHint = verdictSourceKey(run.user_verdict_source);
+  // Next to the slicer's figures for the plate it printed (#3375).
+  const withEstimate = (value: string, estimate: string | null) =>
+    estimate ? t('archivePage.withEstimate', { value, estimate }) : value;
   const figures = [
-    run.duration_seconds ? { icon: Clock, text: `${run.duration_unmeasured ? '~' : ''}${formatDuration(run.duration_seconds)}` } : null,
-    run.filament_used_grams != null ? { icon: Package, text: `${run.filament_used_grams.toFixed(1)} g` } : null,
+    run.duration_seconds
+      ? {
+          icon: Clock,
+          text: withEstimate(
+            `${run.duration_unmeasured ? '~' : ''}${formatDuration(run.duration_seconds)}`,
+            run.estimate_seconds ? formatDuration(run.estimate_seconds) : null,
+          ),
+        }
+      : null,
+    run.filament_used_grams != null
+      ? {
+          icon: Package,
+          text: withEstimate(
+            `${run.filament_used_grams.toFixed(1)} g`,
+            run.estimate_grams ? `${run.estimate_grams.toFixed(1)} g` : null,
+          ),
+        }
+      : null,
     run.cost != null ? { icon: Coins, text: `${currency}${run.cost.toFixed(2)}` } : null,
     run.energy_kwh != null ? { icon: Zap, text: `${run.energy_kwh.toFixed(2)} kWh` } : null,
   ].filter((f): f is { icon: typeof Clock; text: string } => f !== null);

+ 1 - 1
frontend/src/pages/ArchivesPage.tsx

@@ -1305,7 +1305,7 @@ function ArchiveCard({
           {(archive.print_time_seconds || archive.actual_time_seconds) && (
             <div className="flex items-center gap-1.5 text-bambu-gray" title={
               archive.time_accuracy
-                ? `Estimated: ${formatDuration(archive.print_time_seconds || 0)}\nActual: ${formatDuration(archive.actual_time_seconds || 0)}\nAccuracy: ${archive.time_accuracy.toFixed(0)}%`
+                ? `Estimated: ${formatDuration(archive.time_estimate_seconds || archive.print_time_seconds || 0)}\nActual: ${formatDuration(archive.actual_time_seconds || 0)}\nAccuracy: ${archive.time_accuracy.toFixed(0)}%`
                 : archive.actual_time_seconds
                   ? `Actual: ${formatDuration(archive.actual_time_seconds)}`
                   : `Estimated: ${formatDuration(archive.print_time_seconds || 0)}`

Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/ArchiveDetailPage-BYgLfUI5.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/ArchiveDetailPage-CE3S5Ntb.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/ArchivesPage-spPPt0zO.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/CamWallPage-DVCStqER.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/CameraPage-DLh81q1o.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/FileManagerPage-9He60d-E.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/InventoryPage-MB8eI959.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/Layout-EsGzEfN2.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/ModelSourcesPage-CsACNSLl.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/PrintersPage-C60-15sk.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/SettingsPage-AolqHSbH.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/SliceModal-xlCAt6L1.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/SpoolBuddyInventoryPage-C2aMrffm.js


Fichier diff supprimé car celui-ci est trop grand
+ 1 - 1
static/assets/SpoolBuddySettingsPage-honRrFLn.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/de-_h-xgGmk.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/es-DLc3IFPL.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/fr-Y9m7e-OP.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/index-BLsD_TqQ.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/it-CCYNAAbJ.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/ja-Wu0jjGZB.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/ko-ttQJeZA8.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/locationSensorDefaults-BTgcf3a0.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/nl-yr9k7i88.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/pt-BR-D3rz6xfc.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/ru-Bk73u0k1.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/spoolCost-BxD5FkQf.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/sv-BYkeC-YL.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/tr-hAlhUDvw.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/uk-BUQiIynH.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/usePageFileDrop-COa1IjqH.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/zh-CN-B6eLhLb3.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/zh-TW-C27wUXZ5.js


+ 2 - 2
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-CPJxhO0I.js"></script>
+    <script type="module" crossorigin src="/assets/index-BLsD_TqQ.js"></script>
     <link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-aKtaBQYM.js">
     <link rel="modulepreload" crossorigin href="/assets/client-Bw3urp1w.js">
     <link rel="modulepreload" crossorigin href="/assets/react-dom-Bihwyis7.js">
@@ -123,7 +123,7 @@
     <link rel="modulepreload" crossorigin href="/assets/useColorCatalogVersion-D6uXNSEZ.js">
     <link rel="modulepreload" crossorigin href="/assets/circle-minus-C2rRKF7J.js">
     <link rel="modulepreload" crossorigin href="/assets/ConnectionDiagnostic-BaFc8Rjv.js">
-    <link rel="modulepreload" crossorigin href="/assets/Layout-KcUdYMIG.js">
+    <link rel="modulepreload" crossorigin href="/assets/Layout-EsGzEfN2.js">
     <link rel="stylesheet" crossorigin href="/assets/index-N-j54BEl.css">
   </head>
   <body>

Certains fichiers n'ont pas été affichés car il y a eu trop de fichiers modifiés dans ce diff