Browse Source

Remember when each spool was last dried (#2863)

maziggy 4 days ago
parent
commit
84f320b859
100 changed files with 1301 additions and 9 deletions
  1. 6 0
      CHANGELOG.md
  2. 20 0
      backend/app/api/routes/_spoolman_helpers.py
  3. 10 0
      backend/app/api/routes/inventory.py
  4. 22 2
      backend/app/api/routes/spoolman_inventory.py
  5. 7 0
      backend/app/core/database.py
  6. 17 0
      backend/app/main.py
  7. 7 0
      backend/app/models/spool.py
  8. 24 1
      backend/app/schemas/spool.py
  9. 71 0
      backend/app/services/bambu_mqtt.py
  10. 4 0
      backend/app/services/github_backup.py
  11. 5 0
      backend/app/services/github_restore.py
  12. 15 0
      backend/app/services/printer_manager.py
  13. 158 0
      backend/app/services/spool_drying.py
  14. 241 0
      backend/tests/integration/test_spool_drying_2863.py
  15. 40 0
      backend/tests/integration/test_spoolman_inventory_api.py
  16. 107 0
      backend/tests/unit/services/test_bambu_mqtt.py
  17. 19 0
      backend/tests/unit/test_github_backup_spool_fields.py
  18. 30 0
      backend/tests/unit/test_github_restore.py
  19. 72 0
      backend/tests/unit/test_spool_drying_record_2863.py
  20. 174 0
      frontend/src/__tests__/components/SpoolFormLastDried.test.tsx
  21. 7 1
      frontend/src/__tests__/pages/InventoryPageGrouping.test.ts
  22. 105 0
      frontend/src/__tests__/pages/InventoryPageLastDried.test.tsx
  23. 5 0
      frontend/src/api/client.ts
  24. 15 0
      frontend/src/components/SpoolFormModal.tsx
  25. 29 0
      frontend/src/components/spool-form/AdditionalSection.tsx
  26. 7 0
      frontend/src/components/spool-form/types.ts
  27. 3 0
      frontend/src/i18n/locales/de.ts
  28. 3 0
      frontend/src/i18n/locales/en.ts
  29. 3 0
      frontend/src/i18n/locales/es.ts
  30. 3 0
      frontend/src/i18n/locales/fr.ts
  31. 3 0
      frontend/src/i18n/locales/it.ts
  32. 3 0
      frontend/src/i18n/locales/ja.ts
  33. 3 0
      frontend/src/i18n/locales/ko.ts
  34. 3 0
      frontend/src/i18n/locales/nl.ts
  35. 3 0
      frontend/src/i18n/locales/pt-BR.ts
  36. 3 0
      frontend/src/i18n/locales/ru.ts
  37. 3 0
      frontend/src/i18n/locales/sv.ts
  38. 3 0
      frontend/src/i18n/locales/tr.ts
  39. 3 0
      frontend/src/i18n/locales/uk.ts
  40. 3 0
      frontend/src/i18n/locales/zh-CN.ts
  41. 3 0
      frontend/src/i18n/locales/zh-TW.ts
  42. 37 1
      frontend/src/pages/InventoryPage.tsx
  43. 0 0
      static/assets/AdditionalSection-3BKr-yip.js
  44. 0 0
      static/assets/AdditionalSection-B6WMblUU.js
  45. 0 0
      static/assets/AmsUnitCard-D5a7euCZ.js
  46. 0 0
      static/assets/ArchivesPage-Bey6mxdW.js
  47. 0 0
      static/assets/AssignToAmsModal-DFZcEmxU.js
  48. 0 0
      static/assets/CamWallPage-B5jO04uY.js
  49. 0 0
      static/assets/CameraDiagnoseModal-CwoB6c0T.js
  50. 0 0
      static/assets/CameraPage-CtlRTieh.js
  51. 0 0
      static/assets/CameraWall-CnBRKVc6.js
  52. 0 0
      static/assets/ColumnConfigModal-DUYMGUzS.js
  53. 0 0
      static/assets/ConfigureAmsSlotModal-DKVbw1V5.js
  54. 0 0
      static/assets/ConnectAuthorizePage-DX9Qg47q.js
  55. 0 0
      static/assets/ContextMenu-4JKs6J8U.js
  56. 0 0
      static/assets/ExternalLinkPage-Dv5Pni6e.js
  57. 0 1
      static/assets/FileManagerPage-DBtfYDIY.js
  58. 0 0
      static/assets/FileUploadModal-5XyUPp1M.js
  59. 0 0
      static/assets/FinancePage-MQM248BB.js
  60. 0 0
      static/assets/GCodeViewerPage-u7tMfK0M.js
  61. 0 0
      static/assets/GroupEditPage-B1zudt8L.js
  62. 0 0
      static/assets/ImagePreviewModal-DDwlB_X7.js
  63. 0 0
      static/assets/InventoryPage-Cjv3mgeu.js
  64. 0 0
      static/assets/InventoryPage-DbBALa1D.js
  65. 0 0
      static/assets/LibraryTrashPage-CgpeDHOU.js
  66. 0 0
      static/assets/LoginPage-Bn2FDxDx.js
  67. 0 0
      static/assets/MaintenancePage-w71H5HP4.js
  68. 0 0
      static/assets/ModelSourcesPage-CV-ikPBH.js
  69. 0 0
      static/assets/NotificationsPage-CBdOBRKH.js
  70. 0 1
      static/assets/PdfPreviewModal-D0aAUfK3.js
  71. 0 0
      static/assets/PhotoGalleryModal-BdKyjXYz.js
  72. 0 0
      static/assets/PrinterLocationsPage-Bb7lzN87.js
  73. 0 0
      static/assets/PrintersPage-DPT4cM9V.js
  74. 0 0
      static/assets/ProfilesPage-DU86ah1J.js
  75. 0 0
      static/assets/ProjectDetailPage-C-R6U56A.js
  76. 0 0
      static/assets/ProjectsPage-BJGrfpoB.js
  77. 0 0
      static/assets/QueuePage-CrMbJ9EE.js
  78. 0 0
      static/assets/RichTextEditor-DN1-jaEd.js
  79. 0 0
      static/assets/SettingsPage-BEMiqw_a.js
  80. 0 0
      static/assets/SetupPage-CIt6zcHj.js
  81. 0 0
      static/assets/SliceModal-gNieU_I9.js
  82. 0 0
      static/assets/SpoolBuddyAmsPage-PNrXLBej.js
  83. 0 0
      static/assets/SpoolBuddyCalibrationPage-D0Gs5NC7.js
  84. 0 0
      static/assets/SpoolBuddyDashboard-Bvxja7l8.js
  85. 0 0
      static/assets/SpoolBuddyInventoryPage-CwF7CwGP.js
  86. 1 1
      static/assets/SpoolBuddySettingsPage-BQgb_uO0.js
  87. 0 0
      static/assets/SpoolBuddyWriteTagPage-K8IvkOIE.js
  88. 0 0
      static/assets/SpreadsheetPreviewModal-BBW2wsFD.js
  89. 0 0
      static/assets/StatsPage-dlsv0tR5.js
  90. 0 0
      static/assets/StreamOverlayPage-B4YEyfrG.js
  91. 0 0
      static/assets/SystemInfoPage-BNb6QQrK.js
  92. 0 0
      static/assets/de-RHfkhf78.js
  93. 0 0
      static/assets/es-5tXCFuNX.js
  94. 0 0
      static/assets/fr-DF4R3Wbb.js
  95. 0 0
      static/assets/index-DQNUzRiJ.js
  96. 1 1
      static/assets/inventorySearch-CnZFjESL.js
  97. 0 0
      static/assets/it-Dd9dKQH1.js
  98. 0 0
      static/assets/ja-DkzMOqxg.js
  99. 0 0
      static/assets/ko-C2MmhZAd.js
  100. 0 0
      static/assets/locationSensorDefaults-BSW6Nk3d.js

+ 6 - 0
CHANGELOG.md

@@ -5,6 +5,12 @@ All notable changes to Bambuddy will be documented in this file.
 ## [1.2.6b1] - Unreleased
 
 ### Added
+- **Each spool remembers when it was last dried (#2863, requested by @USER19464727)** — The AMS humidity describes the unit, not the spool, so once a spool left the AMS there was no telling whether it had been dried.
+  - **Filled in by AMS drying:** when an AMS drying run ends, every spool assigned to a slot of that AMS is marked with the date, the temperature (for runs Bambuddy started; the printer doesn't report it) and how long the run actually lasted.
+  - **Only real drying counts:** the run must have lasted at least half its length. One stopped before then, from Bambuddy, on the printer or because a print took priority, leaves the earlier record alone.
+  - **Where it shows:** a **Last dried** column in the inventory table, visible by default and sortable, and a line on the spool card. **Group similar** keeps a dried spool apart from undried copies of it.
+  - **Set by hand:** **Last dried** in the spool form (**Color & Cost**, when editing) takes a date or **Now** for a spool dried in an external dryer, and clearing it removes the record.
+  - **Spoolman too:** in Spoolman mode the record is kept in three extra fields on the spool. Git backups carry it as well. Wiki updated.
 - **Failure detection can send Obico a different Bambuddy address from External URL (#3235, requested and contributed by @chrisbennight in #3255)** — Obico's ML server fetches each snapshot from Bambuddy itself, and the only address Bambuddy could give it was **External URL**. When that is a public address behind a reverse proxy with its own login, the ML server can't get through, and detection reports a snapshot error on every check.
   - **The new setting:** **Settings → Failure Detection** has an optional **Bambuddy Internal URL**, used only for those snapshots. Point it at an address the ML server reaches directly, such as a Docker service name (`http://bambuddy:8000`) or a LAN address. Notifications, sign-in redirects and the other integrations keep using External URL.
   - **Empty uses External URL,** so nothing changes until you set it, and clearing it goes back to External URL.

+ 20 - 0
backend/app/api/routes/_spoolman_helpers.py

@@ -46,6 +46,10 @@ class MappedSpoolFields(TypedDict):
     note: str | None
     added_full: None
     last_used: str | None
+    # Last drying (#2863), stored under the bambu_last_dried_* extra keys.
+    last_dried_at: str | None
+    last_dried_temp: int | None
+    last_dried_hours: float | None
     encode_time: str | None
     tag_uid: str | None
     tray_uuid: str | None
@@ -262,6 +266,14 @@ def _extract_extra_str(extra: dict, key: str) -> str:
 BAMBU_WEIGHT_USED_BASELINE_KEY = "bambu_weight_used_baseline"
 
 
+# Spool.extra keys holding when the spool was last dried, the target °C and the
+# hours the cycle ran (#2863). Written by services/spool_drying.py and the
+# spool update route; an empty string means unknown or cleared.
+BAMBU_LAST_DRIED_AT_KEY = "bambu_last_dried_at"
+BAMBU_LAST_DRIED_TEMP_KEY = "bambu_last_dried_temp"
+BAMBU_LAST_DRIED_HOURS_KEY = "bambu_last_dried_hours"
+
+
 def _extract_extra_float(extra: dict, key: str) -> float | None:
     """Extract a JSON-encoded number from a Spoolman extra dict.
 
@@ -324,6 +336,11 @@ def parse_spoolman_multi_colors(filament: dict) -> list[str]:
     return [cleaned for token in tokens if (cleaned := token.strip().lstrip("#"))]
 
 
+def _last_dried_temp(extra: dict) -> int | None:
+    value = _extract_extra_float(extra, BAMBU_LAST_DRIED_TEMP_KEY)
+    return int(round(value)) if value is not None and value > 0 else None
+
+
 def _map_spoolman_spool(spool: dict) -> MappedSpoolFields:
     """Convert a raw Spoolman spool dict to the InventorySpool-compatible format.
 
@@ -496,6 +513,9 @@ def _map_spoolman_spool(spool: dict) -> MappedSpoolFields:
         "note": spool.get("comment") or None,
         "added_full": None,
         "last_used": spool.get("last_used"),
+        "last_dried_at": _extract_extra_str(extra, BAMBU_LAST_DRIED_AT_KEY) or None,
+        "last_dried_temp": _last_dried_temp(extra),
+        "last_dried_hours": _extract_extra_float(extra, BAMBU_LAST_DRIED_HOURS_KEY),
         # encode_time semantics differ: local records NFC write time; Spoolman first_used
         # records first print use — different events; using first_used as best available proxy.
         "encode_time": spool.get("first_used"),

+ 10 - 0
backend/app/api/routes/inventory.py

@@ -1638,6 +1638,11 @@ async def update_spool(
     # Auto-lock weight when user explicitly sets weight_used
     if "weight_used" in update_data and "weight_locked" not in update_data:
         update_data["weight_locked"] = True
+    # A drying date set by hand is not the AMS cycle the temperature and
+    # hours describe, so they go with it (#2863).
+    if "last_dried_at" in update_data:
+        update_data["last_dried_temp"] = None
+        update_data["last_dried_hours"] = None
 
     for field, value in update_data.items():
         setattr(spool, field, value)
@@ -1799,6 +1804,11 @@ async def bulk_update_spools(
     # per-spool PATCH behaviour so bulk edits don't desync the lock state.
     if "weight_used" in prepared and "weight_locked" not in prepared:
         prepared["weight_locked"] = True
+    # Same rule as the per-spool PATCH: a hand-set drying date drops the
+    # temperature and hours of the AMS cycle it replaces (#2863).
+    if "last_dried_at" in prepared:
+        prepared["last_dried_temp"] = None
+        prepared["last_dried_hours"] = None
 
     result = await db.execute(select(Spool).where(Spool.id.in_(payload.ids)))
     spools = {s.id: s for s in result.scalars().all()}

+ 22 - 2
backend/app/api/routes/spoolman_inventory.py

@@ -13,6 +13,7 @@ import logging
 import re
 import time
 from contextlib import asynccontextmanager
+from datetime import datetime
 
 from fastapi import APIRouter, Body, Depends, HTTPException, Path, Query, Response
 from fastapi.responses import JSONResponse
@@ -23,6 +24,9 @@ from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.orm import selectinload
 
 from backend.app.api.routes._spoolman_helpers import (
+    BAMBU_LAST_DRIED_AT_KEY,
+    BAMBU_LAST_DRIED_HOURS_KEY,
+    BAMBU_LAST_DRIED_TEMP_KEY,
     NormalizedFilament,
     NormalizedVendorRef,
     _map_spoolman_spool,
@@ -48,7 +52,7 @@ from backend.app.models.spoolman_k_profile import SpoolmanKProfile
 from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
 from backend.app.models.supplier import SpoolmanSpoolSupplier, Supplier
 from backend.app.models.user import User
-from backend.app.schemas.spool import SpoolFilamentPresetBase, SpoolKProfileBase
+from backend.app.schemas.spool import SpoolFilamentPresetBase, SpoolKProfileBase, naive_utc
 from backend.app.schemas.spoolman import SpoolmanFilamentPatch, SpoolmanSlotAssignmentEnriched
 from backend.app.schemas.supplier import SpoolSupplierLinkInput
 from backend.app.services import slot_unlink_grace
@@ -384,6 +388,9 @@ class SpoolmanInventoryUpdate(BaseModel):
     # schema). Pass an empty string to clear; null/omitted leaves unchanged.
     slicer_filament: str | None = Field(None, max_length=128)
     slicer_filament_name: str | None = Field(None, max_length=255)
+    # Set by hand for a drying done outside an AMS; null clears it. Persisted
+    # to the spool's extra dict like the slicer preset (#2863).
+    last_dried_at: datetime | None = None
 
     @field_validator("rgba")
     @classmethod
@@ -395,6 +402,11 @@ class SpoolmanInventoryUpdate(BaseModel):
     def validate_storage_location(cls, v: str | None) -> str | None:
         return _validate_storage_location(v)
 
+    @field_validator("last_dried_at")
+    @classmethod
+    def validate_last_dried_at(cls, v: datetime | None) -> datetime | None:
+        return naive_utc(v)
+
     @model_validator(mode="after")
     def validate_tag_fields(self) -> SpoolmanInventoryUpdate:
         # null = remove tag; non-null values rejected (use /tag endpoint to write tags)
@@ -952,7 +964,8 @@ async def update_spool(
     sf_set = "slicer_filament" in data.model_fields_set
     sfn_set = "slicer_filament_name" in data.model_fields_set
     cn_set = "color_name" in data.model_fields_set
-    if sf_set or sfn_set or cn_set:
+    dried_set = "last_dried_at" in data.model_fields_set
+    if sf_set or sfn_set or cn_set or dried_set:
         # Ensure extra fields are registered (Spoolman rejects PATCHes with
         # unknown keys with HTTP 400). Idempotent if startup already ran this.
         if sf_set:
@@ -968,6 +981,13 @@ async def update_spool(
             new_extra["bambu_slicer_filament_name"] = json.dumps(data.slicer_filament_name or "")
         if cn_set:
             new_extra["bambu_color_name"] = json.dumps(data.color_name or "")
+        if dried_set:
+            # A hand-set date is not the AMS cycle the temperature and hours
+            # describe, so they are cleared with it, as in internal mode.
+            dried_at = data.last_dried_at.isoformat(timespec="seconds") if data.last_dried_at else ""
+            new_extra[BAMBU_LAST_DRIED_AT_KEY] = json.dumps(dried_at)
+            new_extra[BAMBU_LAST_DRIED_TEMP_KEY] = json.dumps("")
+            new_extra[BAMBU_LAST_DRIED_HOURS_KEY] = json.dumps("")
         async with _translate_spoolman_errors():
             updated = await client.merge_spool_extra(spool_id, new_extra)
 

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

@@ -5310,6 +5310,13 @@ async def run_migrations(conn):
     await _safe_execute(conn, f"ALTER TABLE print_log_entries ADD COLUMN wear_cost {float_type}")
     await _safe_execute(conn, f"ALTER TABLE print_archives ADD COLUMN wear_cost {float_type}")
 
+    # Migration: when each spool was last dried, at what temperature and for
+    # how long (#2863). Nullable: no spool has a drying record until one ends.
+    datetime_type = "DATETIME" if is_sqlite() else "TIMESTAMP"
+    await _safe_execute(conn, f"ALTER TABLE spool ADD COLUMN last_dried_at {datetime_type}")
+    await _safe_execute(conn, "ALTER TABLE spool ADD COLUMN last_dried_temp INTEGER")
+    await _safe_execute(conn, f"ALTER TABLE spool ADD COLUMN last_dried_hours {float_type}")
+
 
 async def _backfill_snapshot_prices(conn) -> None:
     """Give the energy snapshots taken before #1251 the price set at upgrade.

+ 17 - 0
backend/app/main.py

@@ -10084,6 +10084,23 @@ async def lifespan(app: FastAPI):
 
     printer_manager.set_drying_complete_callback(on_drying_complete)
 
+    async def on_drying_cycle_end(printer_id: int, cycle):
+        """Mark the spools in an AMS that just finished drying as dried (#2863)."""
+        from backend.app.services.spool_drying import record_drying_cycle
+
+        try:
+            async with async_session() as db:
+                await record_drying_cycle(db, printer_id, cycle)
+        except Exception as e:
+            logging.getLogger(__name__).warning(
+                "Failed to record drying for printer %d (AMS %d): %s",
+                printer_id,
+                cycle.ams_id,
+                e,
+            )
+
+    printer_manager.set_drying_cycle_end_callback(on_drying_cycle_end)
+
     async def on_assignment_verified(printer_id: int, ams_id: int, tray_id: int, verified: bool, detail: dict):
         """Surface the read-back result of a spool assignment to the UI (#2582).
 

+ 7 - 0
backend/app/models/spool.py

@@ -73,6 +73,13 @@ class Spool(Base):
     location_id: Mapped[int | None] = mapped_column(ForeignKey("locations.id"), index=True)
 
     last_used: Mapped[datetime | None] = mapped_column(DateTime)  # Last time this spool was used in a print
+    # Last drying (#2863): stamped when an AMS drying cycle that held this spool
+    # ends having run at least half its length, or set by hand in the spool
+    # form for an external dryer. Temperature and hours are only known for
+    # cycles Bambuddy watched or started, and a hand-set date clears both.
+    last_dried_at: Mapped[datetime | None] = mapped_column(DateTime)
+    last_dried_temp: Mapped[int | None] = mapped_column(Integer)  # Target °C
+    last_dried_hours: Mapped[float | None] = mapped_column(Float)  # Time actually spent drying
     encode_time: Mapped[datetime | None] = mapped_column(DateTime)  # When spool was encoded/written to tag
     tag_uid: Mapped[str | None] = mapped_column(String(32))  # RFID tag UID (up to 32 hex chars)
     tray_uuid: Mapped[str | None] = mapped_column(String(32))  # Bambu Lab spool UUID (32 hex chars)

+ 24 - 1
backend/app/schemas/spool.py

@@ -1,4 +1,4 @@
-from datetime import datetime
+from datetime import datetime, timezone
 
 from pydantic import BaseModel, Field, field_validator
 
@@ -92,6 +92,17 @@ def normalize_effect_type(value: str | None) -> str | None:
     return canonical
 
 
+def naive_utc(value: datetime | None) -> datetime | None:
+    """Store a datetime the way Bambuddy's naive ``DateTime`` columns hold it: UTC.
+
+    A browser sends local time with an offset. Dropping the offset without
+    converting would shift the stored moment by that offset (#2863).
+    """
+    if value is not None and value.tzinfo is not None:
+        return value.astimezone(timezone.utc).replace(tzinfo=None)
+    return value
+
+
 class SpoolBase(BaseModel):
     material: str = Field(..., min_length=1, max_length=50)
     subtype: str | None = None
@@ -197,6 +208,14 @@ class SpoolUpdate(BaseModel):
     tag_type: str | None = None
     cost_per_kg: float | None = Field(default=None, ge=0)
     weight_locked: bool | None = None
+    # Set by hand for a drying done outside an AMS; null clears it (#2863).
+    last_dried_at: datetime | None = None
+
+    @field_validator("last_dried_at")
+    @classmethod
+    def _validate_last_dried_at(cls, v: datetime | None) -> datetime | None:
+        return naive_utc(v)
+
     # User-defined category + per-spool low-stock threshold override (#729).
     category: str | None = Field(default=None, max_length=50)
     low_stock_threshold_pct: int | None = Field(default=None, ge=1, le=99)
@@ -268,6 +287,10 @@ class SpoolResponse(SpoolBase):
     rgba: str | None = None
     added_full: bool | None = None
     last_used: datetime | None = None
+    # Last drying (#2863). Only the date is writable, through SpoolUpdate.
+    last_dried_at: datetime | None = None
+    last_dried_temp: int | None = None
+    last_dried_hours: float | None = None
     encode_time: datetime | None = None
     tag_uid: str | None = None
     tray_uuid: str | None = None

+ 71 - 0
backend/app/services/bambu_mqtt.py

@@ -686,6 +686,26 @@ def resolve_rack_plan_mapping(
     return wire, None
 
 
+@dataclass(frozen=True)
+class DryingCycleEnd:
+    """What Bambuddy saw of one AMS drying cycle, reported when it ends (#2863).
+
+    ``peak_minutes`` is the highest ``dry_time`` observed during the cycle and
+    ``remaining_minutes`` the last one before the drop to 0. ``start_seen`` says
+    whether the countdown was watched from its first minute; when it was not
+    (Bambuddy started mid-cycle) the peak is only a lower bound on the length.
+    ``target_temp`` / ``target_hours`` are known only for cycles Bambuddy itself
+    started, because the printer never echoes them.
+    """
+
+    ams_id: int
+    remaining_minutes: int
+    peak_minutes: int
+    start_seen: bool
+    target_temp: int | None = None
+    target_hours: int | None = None
+
+
 @dataclass
 class MQTTLogEntry:
     """Log entry for MQTT message debugging."""
@@ -1240,6 +1260,7 @@ class BambuMQTTClient:
         on_print_progress: Callable[[int], None] | None = None,
         on_bed_temp_update: Callable[[float], None] | None = None,
         on_drying_complete: Callable[[int], None] | None = None,
+        on_drying_cycle_end: Callable[[DryingCycleEnd], None] | None = None,
         on_print_running_observed: Callable[[dict], None] | None = None,
         on_finish_photo_moment: Callable[[dict], None] | None = None,
         on_assignment_verified: Callable[[int, int, bool, dict], None] | None = None,
@@ -1272,6 +1293,9 @@ class BambuMQTTClient:
         # the drying cycle just finished (auto- or manually-triggered).
         # Receives the AMS id of the unit that finished drying.
         self.on_drying_complete = on_drying_complete
+        # #2863: fired on the same edge with what was seen of the cycle, so the
+        # spools in that AMS can be stamped as dried.
+        self.on_drying_cycle_end = on_drying_cycle_end
         # #1485 follow-up: fired the first time we see RUNNING state in a
         # session WHEN on_print_start was suppressed (Bambuddy started mid-
         # print, the #1304 first-push guard skipped the start event). Lets
@@ -1332,6 +1356,11 @@ class BambuMQTTClient:
         # is indistinguishable from the firmware abandoning it — so the cycle-end
         # log would otherwise blame the printer for our own decision (#2770).
         self._drying_stops_sent: set[int] = set()
+        # Per-AMS record of the cycle in progress: the highest dry_time seen,
+        # whether its first minute was observed, and the target we sent for it.
+        # The target is copied here when the countdown starts because a stop
+        # drops _drying_targets before the cycle ends (#2863).
+        self._dry_cycles: dict[int, dict[str, object]] = {}
         # Stage numbers this printer has reported that STAGE_NAMES has no entry
         # for, so each is reported once rather than on every transition into it.
         self._unnamed_stages_seen: set[int] = set()
@@ -3735,8 +3764,22 @@ class BambuMQTTClient:
                     ams_unit.get("dry_status"),
                 )
                 continue
+            seen_before = ams_id in self._previous_dry_times
             previous = self._previous_dry_times.get(ams_id, 0)
             self._previous_dry_times[ams_id] = current
+            if current > 0:
+                cycle = self._dry_cycles.get(ams_id)
+                if cycle is None or previous == 0:
+                    cycle = {
+                        "peak": current,
+                        # A rise from an observed 0 is the cycle's first minute.
+                        # A first sighting already counting down is not.
+                        "start_seen": seen_before,
+                        "target": self._drying_targets.get(ams_id),
+                    }
+                    self._dry_cycles[ams_id] = cycle
+                elif current > int(cycle["peak"]):
+                    cycle["peak"] = current
             # Stall detection: stamp value CHANGES only — a live countdown
             # decrements once a minute, so repeats of the same value within
             # the minute must not refresh the stamp, and a frame without a
@@ -3753,6 +3796,9 @@ class BambuMQTTClient:
                 self._log_drying_cycle_end(ams_id, previous, ams_unit, self._drying_targets.pop(ams_id, None))
                 if self.on_drying_complete:
                     self.on_drying_complete(ams_id)
+                cycle = self._dry_cycles.pop(ams_id, None) or {"peak": previous, "start_seen": False, "target": None}
+                if self.on_drying_cycle_end:
+                    self.on_drying_cycle_end(self._drying_cycle_end(ams_id, previous, cycle))
 
         # Create a hash of relevant AMS data to detect changes.
         # Hash the MERGED state, not the raw incoming ams_list: a removal signalled
@@ -3790,6 +3836,27 @@ class BambuMQTTClient:
         if self._pending_assignments:
             self._check_assignment_verifications()
 
+    @staticmethod
+    def _drying_cycle_end(ams_id: int, remaining: int, cycle: dict[str, object]) -> DryingCycleEnd:
+        """Build the cycle-end report from the record kept while it ran."""
+        target = cycle.get("target")
+        target_temp: int | None = None
+        target_hours: int | None = None
+        if isinstance(target, dict):
+            try:
+                target_temp = int(target.get("temp") or 0) or None
+                target_hours = int(target.get("duration_hours") or 0) or None
+            except (TypeError, ValueError):
+                target_temp = target_hours = None
+        return DryingCycleEnd(
+            ams_id=ams_id,
+            remaining_minutes=remaining,
+            peak_minutes=max(int(cycle.get("peak") or 0), remaining),
+            start_seen=bool(cycle.get("start_seen")),
+            target_temp=target_temp,
+            target_hours=target_hours,
+        )
+
     def _log_drying_cycle_end(
         self,
         ams_id: int,
@@ -6674,6 +6741,10 @@ class BambuMQTTClient:
                 "duration_hours": int(duration),
             }
             self._drying_stops_sent.discard(ams_id)
+            # A start sent while a cycle is still counting down replaces it
+            # without dry_time passing through 0; begin a fresh record so the
+            # new target and length are the ones reported (#2863).
+            self._dry_cycles.pop(ams_id, None)
         else:
             self._drying_targets.pop(ams_id, None)
             # Remember that this cycle's end is ours, so the cycle-end log

+ 4 - 0
backend/app/services/github_backup.py

@@ -788,6 +788,10 @@ class GitHubBackupService:
                 "category": s.category,
                 "low_stock_threshold_pct": s.low_stock_threshold_pct,
                 "storage_location": s.storage_location,
+                # Last drying (#2863).
+                "last_dried_at": str(s.last_dried_at) if s.last_dried_at else None,
+                "last_dried_temp": s.last_dried_temp,
+                "last_dried_hours": s.last_dried_hours,
                 "tag_uid": s.tag_uid,
                 "tray_uuid": s.tray_uuid,
                 "data_origin": s.data_origin,

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

@@ -1324,6 +1324,11 @@ class GitHubRestoreService:
             for late_field in ("material_number", "category", "low_stock_threshold_pct", "storage_location"):
                 if late_field in entry:
                     fields[late_field] = entry[late_field]
+            # Last drying (#2863), same late-field rule.
+            if "last_dried_at" in entry:
+                fields["last_dried_at"] = _parse_dt(entry.get("last_dried_at"))
+                fields["last_dried_temp"] = entry.get("last_dried_temp")
+                fields["last_dried_hours"] = entry.get("last_dried_hours")
 
             if existing is not None:
                 if old_id is not None:

+ 15 - 0
backend/app/services/printer_manager.py

@@ -11,6 +11,7 @@ from backend.app.models.printer import Printer
 from backend.app.services.bambu_mqtt import (
     STAGE_NAMES,
     BambuMQTTClient,
+    DryingCycleEnd,
     MQTTLogEntry,
     PrinterState,
     get_stage_name,
@@ -406,6 +407,7 @@ class PrinterManager:
         self._on_print_progress: Callable[[int, int], None] | None = None
         self._on_bed_temp_update: Callable[[int, float], None] | None = None
         self._on_drying_complete: Callable[[int, int], None] | None = None
+        self._on_drying_cycle_end: Callable[[int, DryingCycleEnd], None] | None = None
         self._on_assignment_verified: Callable[[int, int, int, bool, dict], None] | None = None
         self._on_tray_change: Callable[[int, int, int], None] | None = None
         self._loop: asyncio.AbstractEventLoop | None = None
@@ -678,6 +680,14 @@ class PrinterManager:
         """
         self._on_drying_complete = callback
 
+    def set_drying_cycle_end_callback(self, callback: Callable[[int, DryingCycleEnd], None]):
+        """Set callback for the end of an AMS drying cycle (#2863).
+
+        Receives ``(printer_id, DryingCycleEnd)`` on the same edge as the
+        drying-complete callback, with what was seen of the cycle.
+        """
+        self._on_drying_cycle_end = callback
+
     def set_assignment_verified_callback(self, callback: Callable[[int, int, int, bool, dict], None]):
         """Set callback for spool-assignment read-back verification (#2582).
 
@@ -795,6 +805,10 @@ class PrinterManager:
             if self._on_drying_complete:
                 self._schedule_async(self._on_drying_complete(printer_id, ams_id))
 
+        def on_drying_cycle_end(cycle: DryingCycleEnd):
+            if self._on_drying_cycle_end:
+                self._schedule_async(self._on_drying_cycle_end(printer_id, cycle))
+
         def on_assignment_verified(ams_id: int, tray_id: int, verified: bool, detail: dict):
             if self._on_assignment_verified:
                 self._schedule_async(self._on_assignment_verified(printer_id, ams_id, tray_id, verified, detail))
@@ -817,6 +831,7 @@ class PrinterManager:
             on_print_progress=on_print_progress,
             on_bed_temp_update=on_bed_temp_update,
             on_drying_complete=on_drying_complete,
+            on_drying_cycle_end=on_drying_cycle_end,
             on_print_running_observed=on_print_running_observed,
             on_finish_photo_moment=on_finish_photo_moment,
             on_assignment_verified=on_assignment_verified,

+ 158 - 0
backend/app/services/spool_drying.py

@@ -0,0 +1,158 @@
+"""Record on each spool when it was last dried (#2863).
+
+An AMS drying cycle dries every spool in that unit, but the AMS humidity only
+describes the unit, so once a spool is moved the fact is lost. When a cycle
+ends, every spool assigned to a slot of that AMS is stamped with the time, the
+target temperature and the hours it actually ran, in whichever inventory holds
+the slot assignments.
+
+A cycle counts when it ran at least ``DRIED_MIN_FRACTION`` of its length. One
+stopped a few minutes in, by the user, the firmware or a print taking
+priority, has not dried anything and must not overwrite an earlier, real
+drying.
+"""
+
+from __future__ import annotations
+
+import json
+import logging
+from dataclasses import dataclass
+
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.api.routes._spoolman_helpers import (
+    BAMBU_LAST_DRIED_AT_KEY,
+    BAMBU_LAST_DRIED_HOURS_KEY,
+    BAMBU_LAST_DRIED_TEMP_KEY,
+)
+from backend.app.services.bambu_mqtt import DryingCycleEnd
+from backend.app.utils.local_time import utcnow_naive
+
+logger = logging.getLogger(__name__)
+
+# Share of a cycle's length that must have run for its spools to count as dried.
+DRIED_MIN_FRACTION = 0.5
+
+# A cycle that runs to term ends with its countdown all but exhausted; the last
+# dry_time seen can trail by a push or two. Same margin the MQTT client uses to
+# tell a finished cycle from an aborted one.
+RAN_TO_TERM_MINUTES = 5
+
+
+@dataclass(frozen=True)
+class DryingRecord:
+    temp: int | None
+    hours: float | None
+
+
+def drying_record(cycle: DryingCycleEnd) -> DryingRecord | None:
+    """What a finished cycle says about its spools, or None when it does not count.
+
+    The cycle's length is the requested duration when Bambuddy started it, else
+    the highest countdown seen. When the start was not watched and nothing was
+    requested, that peak understates the length, so the elapsed share is a
+    lower bound and the hours are left unknown rather than understated. A cycle
+    that ran to term counts either way.
+    """
+    requested = (cycle.target_hours or 0) * 60
+    length = max(cycle.peak_minutes, requested)
+    elapsed = max(0, length - cycle.remaining_minutes)
+    ran_to_term = cycle.remaining_minutes <= RAN_TO_TERM_MINUTES
+    if not ran_to_term and (length <= 0 or elapsed / length < DRIED_MIN_FRACTION):
+        return None
+    length_known = cycle.start_seen or requested > 0
+    hours = round(elapsed / 60, 1) if length_known and elapsed > 0 else None
+    return DryingRecord(temp=cycle.target_temp, hours=hours)
+
+
+async def _spoolman_spool_ids(db: AsyncSession, printer_id: int, ams_id: int) -> list[int]:
+    from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
+
+    result = await db.execute(
+        select(SpoolmanSlotAssignment.spoolman_spool_id).where(
+            SpoolmanSlotAssignment.printer_id == printer_id,
+            SpoolmanSlotAssignment.ams_id == ams_id,
+        )
+    )
+    return sorted(set(result.scalars().all()))
+
+
+async def _stamp_internal(db: AsyncSession, printer_id: int, ams_id: int, record: DryingRecord) -> int:
+    from backend.app.models.spool import Spool
+    from backend.app.models.spool_assignment import SpoolAssignment
+
+    result = await db.execute(
+        select(Spool)
+        .join(SpoolAssignment, SpoolAssignment.spool_id == Spool.id)
+        .where(SpoolAssignment.printer_id == printer_id, SpoolAssignment.ams_id == ams_id)
+    )
+    spools = list(result.scalars().unique().all())
+    now = utcnow_naive()
+    for spool in spools:
+        spool.last_dried_at = now
+        spool.last_dried_temp = record.temp
+        spool.last_dried_hours = record.hours
+    if spools:
+        await db.commit()
+    return len(spools)
+
+
+async def _stamp_spoolman(spool_ids: list[int], record: DryingRecord) -> int:
+    from backend.app.services.spoolman import get_spoolman_client
+
+    client = await get_spoolman_client()
+    if client is None:
+        logger.info("Drying not recorded on Spoolman spools %s: Spoolman is not connected", spool_ids)
+        return 0
+    # JSON strings: the form Spoolman's default "text" extra fields accept.
+    fields = {
+        BAMBU_LAST_DRIED_AT_KEY: json.dumps(utcnow_naive().isoformat(timespec="seconds")),
+        # An empty string clears a value left by an earlier cycle.
+        BAMBU_LAST_DRIED_TEMP_KEY: json.dumps("" if record.temp is None else str(record.temp)),
+        BAMBU_LAST_DRIED_HOURS_KEY: json.dumps("" if record.hours is None else str(record.hours)),
+    }
+    stamped = 0
+    for spool_id in spool_ids:
+        try:
+            await client.merge_spool_extra(spool_id, fields)
+            stamped += 1
+        except Exception as exc:  # noqa: BLE001 — one unreachable spool must not skip the rest
+            logger.warning("Could not record drying on Spoolman spool %d: %s", spool_id, exc)
+    return stamped
+
+
+async def record_drying_cycle(db: AsyncSession, printer_id: int, cycle: DryingCycleEnd) -> int:
+    """Stamp the spools of the AMS that just finished drying. Returns how many."""
+    from backend.app.services.inventory_mode import spoolman_owns_assignments
+
+    record = drying_record(cycle)
+    if record is None:
+        logger.info(
+            "Printer %d AMS %d drying ended with %d of %d minutes left: too short to mark its spools as dried",
+            printer_id,
+            cycle.ams_id,
+            cycle.remaining_minutes,
+            max(cycle.peak_minutes, (cycle.target_hours or 0) * 60),
+        )
+        return 0
+
+    if await spoolman_owns_assignments(db):
+        spool_ids = await _spoolman_spool_ids(db, printer_id, cycle.ams_id)
+        stamped = await _stamp_spoolman(spool_ids, record) if spool_ids else 0
+    else:
+        stamped = await _stamp_internal(db, printer_id, cycle.ams_id, record)
+
+    logger.info(
+        "Printer %d AMS %d drying finished (%s °C, %s h): marked %d spool(s) as dried",
+        printer_id,
+        cycle.ams_id,
+        record.temp if record.temp is not None else "?",
+        record.hours if record.hours is not None else "?",
+        stamped,
+    )
+    if stamped:
+        from backend.app.core.websocket import ws_manager
+
+        await ws_manager.broadcast({"type": "inventory_changed"})
+    return stamped

+ 241 - 0
backend/tests/integration/test_spool_drying_2863.py

@@ -0,0 +1,241 @@
+"""Each spool remembers when it was last dried (#2863).
+
+An AMS drying cycle that ran at least half its length stamps every spool
+assigned to that AMS, in whichever inventory holds the slot assignments. The
+date can also be set by hand, which clears the temperature and hours that
+described the AMS cycle.
+"""
+
+import json
+from datetime import datetime
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+from httpx import AsyncClient
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.api.routes._spoolman_helpers import _map_spoolman_spool
+from backend.app.models.settings import Settings
+from backend.app.models.spool import Spool
+from backend.app.models.spool_assignment import SpoolAssignment
+from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
+from backend.app.services.bambu_mqtt import DryingCycleEnd
+from backend.app.services.spool_drying import record_drying_cycle
+
+FULL_CYCLE = DryingCycleEnd(
+    ams_id=0, remaining_minutes=1, peak_minutes=480, start_seen=True, target_temp=55, target_hours=8
+)
+SHORT_CYCLE = DryingCycleEnd(
+    ams_id=0, remaining_minutes=470, peak_minutes=480, start_seen=True, target_temp=55, target_hours=8
+)
+
+
+async def _assign(db: AsyncSession, printer_id: int, ams_id: int, tray_id: int) -> Spool:
+    spool = Spool(material="PLA", rgba="FF0000FF")
+    db.add(spool)
+    await db.flush()
+    db.add(SpoolAssignment(spool_id=spool.id, printer_id=printer_id, ams_id=ams_id, tray_id=tray_id))
+    await db.commit()
+    return spool
+
+
+@pytest.fixture
+def no_broadcast():
+    with patch("backend.app.core.websocket.ws_manager.broadcast", AsyncMock()) as broadcast:
+        yield broadcast
+
+
+class TestBuiltInInventory:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_full_cycle_stamps_only_the_spools_in_that_ams(self, printer_factory, db_session, no_broadcast):
+        printer = await printer_factory(name="H2D")
+        in_ams = [await _assign(db_session, printer.id, 0, tray) for tray in (0, 1)]
+        other_ams = await _assign(db_session, printer.id, 1, 0)
+
+        assert await record_drying_cycle(db_session, printer.id, FULL_CYCLE) == 2
+
+        for spool in in_ams:
+            await db_session.refresh(spool)
+            assert spool.last_dried_at is not None
+            assert spool.last_dried_temp == 55
+            assert spool.last_dried_hours == 8.0
+        await db_session.refresh(other_ams)
+        assert other_ams.last_dried_at is None
+        no_broadcast.assert_awaited_once_with({"type": "inventory_changed"})
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_cycle_stopped_early_keeps_the_earlier_drying(self, printer_factory, db_session, no_broadcast):
+        printer = await printer_factory(name="H2D")
+        spool = await _assign(db_session, printer.id, 0, 0)
+        earlier = datetime(2026, 9, 1, 12, 0)
+        spool.last_dried_at = earlier
+        spool.last_dried_temp = 65
+        spool.last_dried_hours = 12.0
+        await db_session.commit()
+
+        assert await record_drying_cycle(db_session, printer.id, SHORT_CYCLE) == 0
+
+        await db_session.refresh(spool)
+        assert spool.last_dried_at == earlier
+        assert spool.last_dried_temp == 65
+        no_broadcast.assert_not_awaited()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_spoolman_mode_leaves_built_in_rows_alone(self, printer_factory, db_session, no_broadcast):
+        """The built-in assignments are preserved while Spoolman is active
+        (#2812); they do not describe what is in the AMS."""
+        printer = await printer_factory(name="H2D")
+        spool = await _assign(db_session, printer.id, 0, 0)
+        db_session.add(Settings(key="spoolman_enabled", value="true"))
+        await db_session.commit()
+
+        with patch("backend.app.services.spoolman.get_spoolman_client", AsyncMock(return_value=None)):
+            await record_drying_cycle(db_session, printer.id, FULL_CYCLE)
+
+        await db_session.refresh(spool)
+        assert spool.last_dried_at is None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_hand_set_date_clears_temperature_and_hours(self, async_client: AsyncClient, db_session):
+        spool = Spool(
+            material="PLA",
+            last_dried_at=datetime(2026, 9, 1, 12, 0),
+            last_dried_temp=55,
+            last_dried_hours=8.0,
+        )
+        db_session.add(spool)
+        await db_session.commit()
+
+        response = await async_client.patch(
+            f"/api/v1/inventory/spools/{spool.id}", json={"last_dried_at": "2026-10-06T14:30:00+02:00"}
+        )
+
+        assert response.status_code == 200
+        body = response.json()
+        # Stored as UTC, the way every naive DateTime column holds it.
+        assert body["last_dried_at"].startswith("2026-10-06T12:30:00")
+        assert body["last_dried_temp"] is None
+        assert body["last_dried_hours"] is None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_null_clears_the_date(self, async_client: AsyncClient, db_session):
+        spool = Spool(material="PLA", last_dried_at=datetime(2026, 9, 1, 12, 0), last_dried_temp=55)
+        db_session.add(spool)
+        await db_session.commit()
+
+        response = await async_client.patch(f"/api/v1/inventory/spools/{spool.id}", json={"last_dried_at": None})
+
+        assert response.status_code == 200
+        assert response.json()["last_dried_at"] is None
+        assert response.json()["last_dried_temp"] is None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_unrelated_edit_keeps_the_record(self, async_client: AsyncClient, db_session):
+        spool = Spool(material="PLA", last_dried_at=datetime(2026, 9, 1, 12, 0), last_dried_temp=55)
+        db_session.add(spool)
+        await db_session.commit()
+
+        response = await async_client.patch(f"/api/v1/inventory/spools/{spool.id}", json={"note": "shelf 2"})
+
+        assert response.status_code == 200
+        assert response.json()["last_dried_at"].startswith("2026-09-01T12:00:00")
+        assert response.json()["last_dried_temp"] == 55
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_bulk_set_date_clears_temperature_and_hours(self, async_client: AsyncClient, db_session):
+        spools = [Spool(material="PLA", last_dried_temp=55, last_dried_hours=8.0) for _ in range(2)]
+        db_session.add_all(spools)
+        await db_session.commit()
+
+        response = await async_client.post(
+            "/api/v1/inventory/spools/bulk-update",
+            json={"ids": [s.id for s in spools], "update": {"last_dried_at": "2026-10-06T12:30:00Z"}},
+        )
+
+        assert response.status_code == 200
+        for spool in spools:
+            await db_session.refresh(spool)
+            assert spool.last_dried_at == datetime(2026, 10, 6, 12, 30)
+            assert spool.last_dried_temp is None
+            assert spool.last_dried_hours is None
+
+
+class TestSpoolmanInventory:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_full_cycle_writes_the_extra_fields(self, printer_factory, db_session, no_broadcast):
+        printer = await printer_factory(name="H2D")
+        db_session.add(Settings(key="spoolman_enabled", value="true"))
+        for tray, spool_id in ((0, 11), (1, 12)):
+            db_session.add(
+                SpoolmanSlotAssignment(printer_id=printer.id, ams_id=0, tray_id=tray, spoolman_spool_id=spool_id)
+            )
+        db_session.add(SpoolmanSlotAssignment(printer_id=printer.id, ams_id=1, tray_id=0, spoolman_spool_id=13))
+        await db_session.commit()
+        client = MagicMock()
+        client.merge_spool_extra = AsyncMock(return_value={})
+
+        with patch("backend.app.services.spoolman.get_spoolman_client", AsyncMock(return_value=client)):
+            assert await record_drying_cycle(db_session, printer.id, FULL_CYCLE) == 2
+
+        assert [c.args[0] for c in client.merge_spool_extra.call_args_list] == [11, 12]
+        fields = client.merge_spool_extra.call_args.args[1]
+        assert json.loads(fields["bambu_last_dried_temp"]) == "55"
+        assert json.loads(fields["bambu_last_dried_hours"]) == "8.0"
+        assert datetime.fromisoformat(json.loads(fields["bambu_last_dried_at"]))
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_one_failing_spool_does_not_skip_the_rest(self, printer_factory, db_session, no_broadcast):
+        printer = await printer_factory(name="H2D")
+        db_session.add(Settings(key="spoolman_enabled", value="true"))
+        for tray, spool_id in ((0, 11), (1, 12)):
+            db_session.add(
+                SpoolmanSlotAssignment(printer_id=printer.id, ams_id=0, tray_id=tray, spoolman_spool_id=spool_id)
+            )
+        await db_session.commit()
+        client = MagicMock()
+        client.merge_spool_extra = AsyncMock(side_effect=[RuntimeError("Spoolman down"), {}])
+
+        with patch("backend.app.services.spoolman.get_spoolman_client", AsyncMock(return_value=client)):
+            assert await record_drying_cycle(db_session, printer.id, FULL_CYCLE) == 1
+
+        assert client.merge_spool_extra.await_count == 2
+
+    def test_mapping_reads_the_extra_fields(self):
+        spool = {
+            "id": 5,
+            "filament": {"id": 1, "name": "PLA", "material": "PLA"},
+            "extra": {
+                "bambu_last_dried_at": json.dumps("2026-10-06T12:30:00"),
+                "bambu_last_dried_temp": json.dumps("55"),
+                "bambu_last_dried_hours": json.dumps("7.5"),
+            },
+        }
+        mapped = _map_spoolman_spool(spool)
+        assert mapped["last_dried_at"] == "2026-10-06T12:30:00"
+        assert mapped["last_dried_temp"] == 55
+        assert mapped["last_dried_hours"] == 7.5
+
+    def test_mapping_reads_cleared_and_absent_fields_as_unknown(self):
+        base = {"id": 5, "filament": {"id": 1, "name": "PLA", "material": "PLA"}}
+        cleared = {
+            **base,
+            "extra": {
+                "bambu_last_dried_at": json.dumps(""),
+                "bambu_last_dried_temp": json.dumps(""),
+                "bambu_last_dried_hours": json.dumps(""),
+            },
+        }
+        for spool in (base, cleared):
+            mapped = _map_spoolman_spool(spool)
+            assert mapped["last_dried_at"] is None
+            assert mapped["last_dried_temp"] is None
+            assert mapped["last_dried_hours"] is None

+ 40 - 0
backend/tests/integration/test_spoolman_inventory_api.py

@@ -3071,3 +3071,43 @@ class TestPerSpoolCoreWeight:
 
         assert response.status_code == 200
         assert mock_spoolman_client.update_spool_full.call_args.kwargs["remaining_weight"] == 620.0
+
+
+class TestSpoolmanLastDried:
+    """#2863 — a drying date set by hand round-trips through spool.extra."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_hand_set_date_writes_utc_and_clears_temperature_and_hours(
+        self,
+        async_client: AsyncClient,
+        spoolman_settings,
+        mock_spoolman_client,
+    ):
+        response = await async_client.patch(
+            "/api/v1/spoolman/inventory/spools/42", json={"last_dried_at": "2026-10-06T14:30:00+02:00"}
+        )
+
+        assert response.status_code == 200
+        mock_spoolman_client.merge_spool_extra.assert_called_once_with(
+            42,
+            {
+                "bambu_last_dried_at": json.dumps("2026-10-06T12:30:00"),
+                "bambu_last_dried_temp": json.dumps(""),
+                "bambu_last_dried_hours": json.dumps(""),
+            },
+        )
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_null_clears_the_date(
+        self,
+        async_client: AsyncClient,
+        spoolman_settings,
+        mock_spoolman_client,
+    ):
+        response = await async_client.patch("/api/v1/spoolman/inventory/spools/42", json={"last_dried_at": None})
+
+        assert response.status_code == 200
+        fields = mock_spoolman_client.merge_spool_extra.call_args.args[1]
+        assert fields["bambu_last_dried_at"] == json.dumps("")

+ 107 - 0
backend/tests/unit/services/test_bambu_mqtt.py

@@ -8446,3 +8446,110 @@ class TestAmsFilamentSettingRefusalLogging:
 
         assert self._refusals(caplog) == []
         assert "extrusion_cali_sel" not in caplog.text
+
+
+class TestDryingCycleEndReport:
+    """#2863 — the cycle-end report carries what was seen of the cycle, so the
+    spools in that AMS can be stamped as dried."""
+
+    @pytest.fixture
+    def mqtt_client(self):
+        from backend.app.services.bambu_mqtt import BambuMQTTClient
+
+        reports: list = []
+        client = BambuMQTTClient(
+            ip_address="192.168.1.100",
+            serial_number="TEST-DRYING-END",
+            access_code="12345678",
+            on_drying_cycle_end=reports.append,
+        )
+        client._client = MagicMock()
+        client._cycle_reports = reports
+        return client
+
+    @staticmethod
+    def _push(client, dry_time, info=None):
+        unit = {"id": "0", "dry_time": dry_time, "tray": []}
+        if info is not None:
+            unit["info"] = info
+        client._handle_ams_data({"ams": [unit]})
+
+    def test_cycle_watched_from_its_start(self, mqtt_client):
+        self._push(mqtt_client, 0)
+        self._push(mqtt_client, 360)
+        self._push(mqtt_client, 200)
+        self._push(mqtt_client, 1)
+        self._push(mqtt_client, 0)
+
+        [report] = mqtt_client._cycle_reports
+        assert report.ams_id == 0
+        assert report.peak_minutes == 360
+        assert report.remaining_minutes == 1
+        assert report.start_seen is True
+        assert report.target_temp is None
+
+    def test_first_sighting_mid_cycle_is_not_the_start(self, mqtt_client):
+        """Bambuddy came up while the AMS was already drying."""
+        self._push(mqtt_client, 200)
+        self._push(mqtt_client, 0)
+
+        [report] = mqtt_client._cycle_reports
+        assert report.start_seen is False
+        assert report.peak_minutes == 200
+
+    def test_target_survives_a_stop_sent_by_bambuddy(self, mqtt_client):
+        """A stop drops _drying_targets before the countdown reaches 0; the
+        report must still carry the temperature the cycle ran at."""
+        self._push(mqtt_client, 0)
+        mqtt_client.send_drying_command(0, 55, 8, mode=1, filament="PLA")
+        self._push(mqtt_client, 480)
+        self._push(mqtt_client, 100)
+        mqtt_client.send_drying_command(0, 0, 0, mode=0)
+        self._push(mqtt_client, 0)
+
+        [report] = mqtt_client._cycle_reports
+        assert report.target_temp == 55
+        assert report.target_hours == 8
+        assert report.remaining_minutes == 100
+
+    def test_transient_zero_does_not_split_the_cycle(self, mqtt_client):
+        """#2759's 720 → 0 → 719 blip while Checking is one cycle, not two."""
+        self._push(mqtt_client, 0)
+        self._push(mqtt_client, 720, info="11402113")
+        self._push(mqtt_client, 0, info="11402113")
+        self._push(mqtt_client, 719, info="11402123")
+        self._push(mqtt_client, 0, info="11402103")
+
+        [report] = mqtt_client._cycle_reports
+        assert report.peak_minutes == 720
+        assert report.start_seen is True
+
+    def test_next_cycle_starts_fresh(self, mqtt_client):
+        self._push(mqtt_client, 0)
+        mqtt_client.send_drying_command(0, 55, 8, mode=1, filament="PLA")
+        self._push(mqtt_client, 480)
+        self._push(mqtt_client, 0)
+        # Started from the printer screen this time: no target of ours.
+        self._push(mqtt_client, 120)
+        self._push(mqtt_client, 0)
+
+        first, second = mqtt_client._cycle_reports
+        assert first.target_temp == 55
+        assert second.target_temp is None
+        assert second.peak_minutes == 120
+
+    def test_new_start_during_a_live_cycle_begins_a_fresh_record(self, mqtt_client):
+        """A start sent while a cycle still counts down replaces it without
+        dry_time passing through 0; the report is about the new run."""
+        self._push(mqtt_client, 0)
+        mqtt_client.send_drying_command(0, 55, 8, mode=1, filament="PLA")
+        self._push(mqtt_client, 480)
+        self._push(mqtt_client, 400)
+        mqtt_client.send_drying_command(0, 65, 2, mode=1, filament="PETG")
+        self._push(mqtt_client, 120)
+        self._push(mqtt_client, 0)
+
+        [report] = mqtt_client._cycle_reports
+        assert report.target_temp == 65
+        assert report.target_hours == 2
+        assert report.peak_minutes == 120

+ 19 - 0
backend/tests/unit/test_github_backup_spool_fields.py

@@ -45,3 +45,22 @@ async def test_location_id_is_left_out(db_session):
     await GitHubBackupService()._collect_spools(db_session, files)
 
     assert "location_id" not in files["spools/inventory.json"]["spools"][0]
+
+
+@pytest.mark.asyncio
+async def test_collects_the_drying_record(db_session):
+    """#2863 — written with str(), the form _parse_dt reads back."""
+    from datetime import datetime
+
+    db_session.add(
+        Spool(material="PLA", last_dried_at=datetime(2026, 10, 6, 12, 30), last_dried_temp=55, last_dried_hours=7.5)
+    )
+    await db_session.commit()
+
+    files: dict = {}
+    await GitHubBackupService()._collect_spools(db_session, files)
+
+    entry = files["spools/inventory.json"]["spools"][0]
+    assert entry["last_dried_at"] == "2026-10-06 12:30:00"
+    assert entry["last_dried_temp"] == 55
+    assert entry["last_dried_hours"] == 7.5

+ 30 - 0
backend/tests/unit/test_github_restore.py

@@ -855,6 +855,36 @@ class TestRestoreSpools:
         assert restored.id != 1
         assert restored.material == "PLA"
 
+    @pytest.mark.asyncio
+    async def test_restores_the_drying_record(self, db_session):
+        """#2863 — the date the backup writes with str() parses back."""
+        tally = _CategoryTally()
+        entry = self._spool_entry(last_dried_at="2026-10-06 12:30:00", last_dried_temp=55, last_dried_hours=7.5)
+
+        await _service()._restore_spools(db_session, {"spools": [entry]}, None, False, tally, {})
+        await db_session.commit()
+
+        restored = (await db_session.execute(select(Spool))).scalars().one()
+        assert restored.last_dried_at == datetime(2026, 10, 6, 12, 30)
+        assert restored.last_dried_temp == 55
+        assert restored.last_dried_hours == 7.5
+
+    @pytest.mark.asyncio
+    async def test_older_backup_keeps_the_live_drying_record(self, db_session):
+        """A backup written before #2863 has no drying keys and must not wipe them."""
+        db_session.add(
+            Spool(material="PLA", tag_uid="AABBCCDD", last_dried_at=datetime(2026, 9, 1, 8, 0), last_dried_temp=55)
+        )
+        await db_session.commit()
+        tally = _CategoryTally()
+
+        await _service()._restore_spools(db_session, {"spools": [self._spool_entry()]}, None, True, tally, {})
+        await db_session.commit()
+
+        spool = (await db_session.execute(select(Spool))).scalars().one()
+        assert spool.last_dried_at == datetime(2026, 9, 1, 8, 0)
+        assert spool.last_dried_temp == 55
+
     @pytest.mark.asyncio
     async def test_matches_existing_spool_by_tag_uid(self, db_session):
         db_session.add(Spool(material="PLA", tag_uid="AABBCCDD", color_name="Old"))

+ 72 - 0
backend/tests/unit/test_spool_drying_record_2863.py

@@ -0,0 +1,72 @@
+"""Which drying cycles mark their spools as dried, and for how long (#2863)."""
+
+import pytest
+
+from backend.app.services.bambu_mqtt import DryingCycleEnd
+from backend.app.services.spool_drying import drying_record
+
+
+def _cycle(remaining: int, peak: int, *, start_seen: bool = True, temp: int | None = None, hours: int | None = None):
+    return DryingCycleEnd(
+        ams_id=0,
+        remaining_minutes=remaining,
+        peak_minutes=peak,
+        start_seen=start_seen,
+        target_temp=temp,
+        target_hours=hours,
+    )
+
+
+def test_full_cycle_started_by_bambuddy():
+    record = drying_record(_cycle(1, 480, temp=55, hours=8))
+    assert record is not None
+    assert record.temp == 55
+    assert record.hours == 8.0
+
+
+@pytest.mark.parametrize(
+    ("remaining", "counts"),
+    [
+        (240, True),  # exactly half of 8 hours ran
+        (241, False),  # one minute short of half
+        (470, False),  # stopped ten minutes in
+    ],
+)
+def test_half_the_length_must_have_run(remaining, counts):
+    assert (drying_record(_cycle(remaining, 480, hours=8)) is not None) is counts
+
+
+def test_hours_are_the_time_actually_spent():
+    record = drying_record(_cycle(180, 480, temp=55, hours=8))
+    assert record is not None
+    assert record.hours == 5.0
+
+
+def test_requested_length_wins_over_a_late_peak():
+    """Bambuddy started the cycle but its first countdown push arrived late."""
+    record = drying_record(_cycle(1, 300, hours=8))
+    assert record is not None
+    assert record.hours == 8.0
+
+
+def test_cycle_started_on_the_printer_keeps_its_hours():
+    """Watched from its first minute, so the peak is the real length."""
+    record = drying_record(_cycle(0, 360))
+    assert record is not None
+    assert record.temp is None
+    assert record.hours == 6.0
+
+
+def test_unwatched_start_leaves_hours_unknown():
+    """Bambuddy came up mid-cycle: the peak understates the length."""
+    record = drying_record(_cycle(2, 200, start_seen=False))
+    assert record is not None
+    assert record.hours is None
+
+
+def test_unwatched_cycle_that_ran_to_term_counts_even_with_one_sighting():
+    assert drying_record(_cycle(3, 3, start_seen=False)) is not None
+
+
+def test_unwatched_cycle_stopped_early_does_not_count():
+    assert drying_record(_cycle(150, 200, start_seen=False)) is None

+ 174 - 0
frontend/src/__tests__/components/SpoolFormLastDried.test.tsx

@@ -0,0 +1,174 @@
+/**
+ * The "Last dried" field in the spool form (#2863).
+ *
+ * An AMS drying run stamps the date in the background, so the form must only
+ * send it when the user changed it — otherwise saving an unrelated edit would
+ * put back the date the form loaded with.
+ */
+
+import React from 'react';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { screen, waitFor, fireEvent } from '@testing-library/react';
+import { render } from '../utils';
+import { SpoolFormModal } from '../../components/SpoolFormModal';
+import type { InventorySpool } from '../../api/client';
+
+vi.mock('../../api/client', () => ({
+  api: {
+    getSettings: vi.fn().mockResolvedValue({}),
+    getAuthStatus: vi.fn().mockResolvedValue({ auth_enabled: false }),
+    getCloudStatus: vi.fn().mockResolvedValue({ is_authenticated: false }),
+    orcaCloudStatus: vi.fn().mockResolvedValue({ connected: false }),
+    orcaCloudListProfiles: vi.fn().mockResolvedValue({ filament: [] }),
+    getFilamentPresets: vi.fn().mockResolvedValue([]),
+    getSpoolCatalog: vi.fn().mockResolvedValue([]),
+    getLocations: vi.fn().mockResolvedValue([]),
+    // Elegoo is only known for PLA here — the pairing that used to hide it
+    // from the brand list as soon as ASA was selected.
+    getColorCatalog: vi.fn().mockResolvedValue([
+      { manufacturer: 'Elegoo', color_name: 'Red', hex_color: 'FF0000', material: 'PLA' },
+      { manufacturer: 'Polymaker', color_name: 'Blue', hex_color: '0000FF', material: 'ASA' },
+    ]),
+    getLocalPresets: vi.fn().mockResolvedValue({ filament: [] }),
+    getBuiltinFilaments: vi.fn().mockResolvedValue([
+      { filament_id: 'GFA05', name: 'Generic ASA' },
+    ]),
+    getPrinters: vi.fn().mockResolvedValue([]),
+    getPrinterStatus: vi.fn().mockResolvedValue(null),
+    getSpoolUsageHistory: vi.fn().mockResolvedValue([]),
+    createSpool: vi.fn().mockResolvedValue({ id: 99 }),
+    updateSpool: vi.fn().mockResolvedValue({ id: 7 }),
+    saveSpoolKProfiles: vi.fn().mockResolvedValue([]),
+    getSpoolFilamentPresets: vi.fn().mockResolvedValue([]),
+    saveSpoolFilamentPresets: vi.fn().mockResolvedValue([]),
+    getSpoolmanFilamentPresets: vi.fn().mockResolvedValue([]),
+    saveSpoolmanFilamentPresets: vi.fn().mockResolvedValue([]),
+    getSpoolmanInventoryFilaments: vi.fn().mockResolvedValue([]),
+    getAssignments: vi.fn().mockResolvedValue([]),
+    unassignSpool: vi.fn().mockResolvedValue({}),
+  },
+  ApiError: class ApiError extends Error {
+    status: number;
+    constructor(message: string, status: number) {
+      super(message);
+      this.status = status;
+    }
+  },
+}));
+
+const mockShowToast = vi.fn();
+vi.mock('../../contexts/ToastContext', async (importOriginal) => {
+  const actual = await importOriginal<typeof import('../../contexts/ToastContext')>();
+  return {
+    ...actual,
+    useToast: () => ({ showToast: mockShowToast }),
+  };
+});
+
+import { api } from '../../api/client';
+
+const driedSpool: InventorySpool = {
+  id: 7,
+  material: 'ASA',
+  subtype: null,
+  brand: null,
+  color_name: null,
+  rgba: '808080FF',
+  extra_colors: null,
+  effect_type: null,
+  label_weight: 1000,
+  core_weight: 250,
+  core_weight_catalog_id: null,
+  weight_used: 0,
+  slicer_filament: null,
+  slicer_filament_name: null,
+  nozzle_temp_min: null,
+  nozzle_temp_max: null,
+  note: null,
+  added_full: null,
+  last_used: null,
+  encode_time: null,
+  tag_uid: null,
+  tray_uuid: null,
+  data_origin: null,
+  tag_type: null,
+  archived_at: null,
+  created_at: '2025-01-01T00:00:00Z',
+  updated_at: '2025-01-01T00:00:00Z',
+  k_profiles: [],
+  last_dried_at: '2026-09-01T12:00:00',
+  last_dried_temp: 55,
+  last_dried_hours: 8,
+} as unknown as InventorySpool;
+
+async function openAdditional(mode: 'edit' | 'copy' | 'create' = 'edit') {
+  render(
+    <SpoolFormModal
+      isOpen={true}
+      onClose={vi.fn()}
+      spool={mode === 'create' ? undefined : driedSpool}
+      mode={mode}
+      currencySymbol="$"
+    />,
+  );
+  await waitFor(() => expect(screen.getAllByRole('heading').length).toBeGreaterThan(0));
+  fireEvent.click(screen.getByRole('button', { name: /Color & Cost/ }));
+}
+
+describe('SpoolFormModal last dried (#2863)', () => {
+  beforeEach(() => {
+    vi.clearAllMocks();
+  });
+
+  it('loads the stored date into the field', async () => {
+    await openAdditional();
+    const input = await screen.findByLabelText('Last dried');
+    const expected = new Date('2026-09-01T12:00:00Z');
+    const pad = (n: number) => String(n).padStart(2, '0');
+    expect(input).toHaveValue(
+      `${expected.getFullYear()}-${pad(expected.getMonth() + 1)}-${pad(expected.getDate())}T${pad(expected.getHours())}:${pad(expected.getMinutes())}`,
+    );
+  });
+
+  it('does not send the date when it was not changed', async () => {
+    await openAdditional();
+    await screen.findByLabelText('Last dried');
+    fireEvent.click(screen.getByRole('button', { name: /save/i }));
+
+    await waitFor(() => expect(api.updateSpool).toHaveBeenCalled());
+    const payload = vi.mocked(api.updateSpool).mock.calls[0][1] as Record<string, unknown>;
+    expect(payload).not.toHaveProperty('last_dried_at');
+  });
+
+  it('sends the time "Now" sets, as UTC', async () => {
+    await openAdditional();
+    await screen.findByLabelText('Last dried');
+    const before = Date.now();
+    fireEvent.click(screen.getByRole('button', { name: 'Now' }));
+    fireEvent.click(screen.getByRole('button', { name: /save/i }));
+
+    await waitFor(() => expect(api.updateSpool).toHaveBeenCalled());
+    const payload = vi.mocked(api.updateSpool).mock.calls[0][1] as Record<string, unknown>;
+    const sent = new Date(payload.last_dried_at as string).getTime();
+    expect(payload.last_dried_at as string).toMatch(/Z$/);
+    // datetime-local has minute precision.
+    expect(Math.abs(sent - before)).toBeLessThan(61_000);
+  });
+
+  it('sends null when the field is cleared', async () => {
+    await openAdditional();
+    const input = await screen.findByLabelText('Last dried');
+    fireEvent.change(input, { target: { value: '' } });
+    fireEvent.click(screen.getByRole('button', { name: /save/i }));
+
+    await waitFor(() => expect(api.updateSpool).toHaveBeenCalled());
+    const payload = vi.mocked(api.updateSpool).mock.calls[0][1] as Record<string, unknown>;
+    expect(payload.last_dried_at).toBeNull();
+  });
+
+  it('is not offered when copying a spool', async () => {
+    await openAdditional('copy');
+    await screen.findByText('Note');
+    expect(screen.queryByLabelText('Last dried')).not.toBeInTheDocument();
+  });
+});

+ 7 - 1
frontend/src/__tests__/pages/InventoryPageGrouping.test.ts

@@ -17,7 +17,7 @@ import { aggregateGroupSpool } from '../../utils/inventoryGrouping';
 // and effect_type are part of the key (#1154) so multi-colour / effect
 // variants don't get collapsed under "Group similar".
 function spoolGroupKey(s: InventorySpool): string {
-  return `${s.material}|${s.subtype || ''}|${s.brand || ''}|${s.color_name || ''}|${s.rgba || ''}|${s.extra_colors || ''}|${s.effect_type || ''}|${s.label_weight}`;
+  return `${s.material}|${s.subtype || ''}|${s.brand || ''}|${s.color_name || ''}|${s.rgba || ''}|${s.extra_colors || ''}|${s.effect_type || ''}|${s.label_weight}|${s.last_dried_at || ''}`;
 }
 
 type DisplayItem =
@@ -104,6 +104,12 @@ describe('spoolGroupKey', () => {
     expect(spoolGroupKey(a)).toBe(spoolGroupKey(b));
   });
 
+  it('keeps a dried spool out of a group with an undried one (#2863)', () => {
+    const a = makeSpool({ id: 1, last_dried_at: '2026-10-06T12:30:00' });
+    const b = makeSpool({ id: 2 });
+    expect(spoolGroupKey(a)).not.toBe(spoolGroupKey(b));
+  });
+
   it('generates different key when material differs', () => {
     const a = makeSpool({ id: 1, material: 'PLA' });
     const b = makeSpool({ id: 2, material: 'PETG' });

+ 105 - 0
frontend/src/__tests__/pages/InventoryPageLastDried.test.tsx

@@ -0,0 +1,105 @@
+/**
+ * The "Last dried" column (#2863): shown by default, with the temperature and
+ * hours of the run when they are known.
+ */
+
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import { screen, waitFor } from '@testing-library/react';
+import { render } from '../utils';
+import InventoryPageRouter from '../../pages/InventoryPage';
+import { http, HttpResponse } from 'msw';
+import { server } from '../mocks/server';
+
+const BASE = {
+  material: 'PLA',
+  subtype: 'Basic',
+  color_name: 'Red',
+  rgba: 'FF0000FF',
+  label_weight: 1000,
+  core_weight: 250,
+  weight_used: 100,
+  slicer_filament: null,
+  slicer_filament_name: null,
+  nozzle_temp_min: 220,
+  nozzle_temp_max: 240,
+  note: null,
+  added_full: null,
+  last_used: null,
+  encode_time: null,
+  tag_uid: null,
+  tray_uuid: null,
+  data_origin: null,
+  tag_type: null,
+  archived_at: null,
+  created_at: '2025-01-01T00:00:00Z',
+  updated_at: '2025-01-01T00:00:00Z',
+  k_profiles: [],
+  cost_per_kg: null,
+  last_scale_weight: null,
+  last_weighed_at: null,
+  storage_location: null,
+  category: null,
+  low_stock_threshold_pct: null,
+  weight_locked: false,
+};
+
+const SPOOLS = [
+  { ...BASE, id: 1, brand: 'AlphaBrand', last_dried_at: '2026-09-01T12:00:00', last_dried_temp: 55, last_dried_hours: 7.5 },
+  { ...BASE, id: 2, brand: 'BetaBrand', last_dried_at: '2026-09-02T12:00:00', last_dried_temp: null, last_dried_hours: null },
+  { ...BASE, id: 3, brand: 'GammaBrand', last_dried_at: null },
+];
+
+function setupHandlers() {
+  server.use(
+    http.get('/api/v1/settings/', () => HttpResponse.json({ currency: 'USD', low_stock_threshold: 20.0, language: 'en' })),
+    http.get('/api/v1/settings/spoolman', () =>
+      HttpResponse.json({ spoolman_enabled: 'false', spoolman_url: '' })
+    ),
+    http.get('/api/v1/inventory/spools', () => HttpResponse.json(SPOOLS)),
+    http.get('/api/v1/inventory/assignments', () => HttpResponse.json([])),
+    http.get('/api/v1/inventory/catalog', () => HttpResponse.json([])),
+    http.get('/api/v1/inventory/color-catalog', () => HttpResponse.json([])),
+    http.get('/api/v1/inventory/colors', () => HttpResponse.json([])),
+    http.get('/api/v1/inventory/spool-catalog', () => HttpResponse.json([])),
+    http.get('/api/v1/inventory/locations', () => HttpResponse.json([])),
+    http.get('/api/v1/printers/', () => HttpResponse.json([])),
+  );
+}
+
+function row(brand: string): HTMLElement {
+  const found = Array.from(document.querySelectorAll('tbody tr')).find((r) => r.textContent?.includes(brand));
+  if (!found) throw new Error(`no row for ${brand}`);
+  return found as HTMLElement;
+}
+
+describe('InventoryPage last dried column', () => {
+  beforeEach(() => {
+    setupHandlers();
+    vi.mocked(localStorage.getItem).mockReturnValue(null);
+  });
+
+  it('is visible by default', async () => {
+    render(<InventoryPageRouter />);
+    await waitFor(() => expect(screen.getByRole('columnheader', { name: /Last dried/ })).toBeInTheDocument());
+  });
+
+  it('shows the temperature and hours of the run when known', async () => {
+    render(<InventoryPageRouter />);
+    await waitFor(() => expect(row('AlphaBrand')).toHaveTextContent('55°C · 7.5h'));
+    expect(row('BetaBrand')).not.toHaveTextContent('°C');
+  });
+
+  it('sorts by the date', async () => {
+    vi.mocked(localStorage.getItem).mockImplementation((key) =>
+      key === 'bambuddy-inventory-sort' ? '{"column":"last_dried","direction":"desc"}' : null,
+    );
+    render(<InventoryPageRouter />);
+
+    await waitFor(() => {
+      const brands = Array.from(document.querySelectorAll('tbody tr')).map((r) => r.textContent ?? '');
+      expect(brands.findIndex((t) => t.includes('BetaBrand'))).toBeLessThan(
+        brands.findIndex((t) => t.includes('AlphaBrand')),
+      );
+    });
+  });
+});

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

@@ -3850,6 +3850,11 @@ export interface InventorySpool {
   note: string | null;
   added_full: boolean | null;
   last_used: string | null;
+  // Last drying (#2863): stamped when an AMS drying run of at least half its
+  // length ends, or set by hand. Temperature and hours are null when unknown.
+  last_dried_at?: string | null;
+  last_dried_temp?: number | null;
+  last_dried_hours?: number | null;
   encode_time: string | null;
   tag_uid: string | null;
   tray_uuid: string | null;

+ 15 - 0
frontend/src/components/SpoolFormModal.tsx

@@ -24,6 +24,7 @@ import { SupplierSection, type SupplierLinkDraft } from './spool-form/SupplierSe
 import { SpoolmanFilamentPicker } from './spool-form/SpoolmanFilamentPicker';
 import { PrinterProfilesSection } from './spool-form/PrinterProfilesSection';
 import { normaliseFlow } from '../utils/nozzleFlow';
+import { parseUTCDate, toDateTimeLocalValue } from '../utils/date';
 import { SpoolUsageHistory } from './SpoolUsageHistory';
 import { AssignToAmsModal } from './spoolbuddy/AssignToAmsModal';
 import {
@@ -83,6 +84,9 @@ export function SpoolFormModal({
   // through core_weight.
   const [coreWeightTouched, setCoreWeightTouched] = useState(false);
   const [locationIdTouched, setLocationIdTouched] = useState(false);
+  // Sent only when changed, so saving an unrelated edit cannot put back a
+  // drying date that an AMS run stamped while the form was open (#2863).
+  const [lastDriedTouched, setLastDriedTouched] = useState(false);
   // Supplier assignments (#2988). Held outside SpoolFormData — they are
   // relational and saved through their own replace-all endpoint. An untouched
   // create does not send them, so it keeps the backend's inherited
@@ -430,6 +434,10 @@ export function SpoolFormModal({
           low_stock_threshold_pct: spool.low_stock_threshold_pct ?? null,
           material_number: spool.material_number || '',
           location_id: spool.location_id ?? null,
+          last_dried_at: (() => {
+            const dried = isCopying ? null : parseUTCDate(spool.last_dried_at);
+            return dried ? toDateTimeLocalValue(dried) : '';
+          })(),
           spoolman_filament_id: null,
         });
         setPresetInputValue(spool.slicer_filament_name || spool.slicer_filament || '');
@@ -494,6 +502,7 @@ export function SpoolFormModal({
       // save) A's per-model overrides on B. Refilled by the fetch below.
       setModelPresets(new Map());
       setWeightTouched(false);
+      setLastDriedTouched(false);
       // A copy of a Spoolman spool with its own tare carries that tare, as it
       // would any other field shown in the form; one that inherits keeps
       // inheriting. Only Spoolman spools report the flag (#2908).
@@ -566,6 +575,7 @@ export function SpoolFormModal({
     if (key === 'weight_used') setWeightTouched(true);
     if (key === 'core_weight') setCoreWeightTouched(true);
     if (key === 'location_id') setLocationIdTouched(true);
+    if (key === 'last_dried_at') setLastDriedTouched(true);
     if (errors[key]) {
       setErrors(prev => ({ ...prev, [key]: undefined }));
     }
@@ -995,6 +1005,10 @@ export function SpoolFormModal({
       data.location_id = formData.location_id;
     }
 
+    if (isEditing && lastDriedTouched) {
+      data.last_dried_at = formData.last_dried_at ? new Date(formData.last_dried_at).toISOString() : null;
+    }
+
     if (isEditing) {
       updateMutation.mutate(data);
     } else if (quantity > 1) {
@@ -1188,6 +1202,7 @@ export function SpoolFormModal({
                   availableCategories={availableCategories}
                   availableMaterialNumbers={availableMaterialNumbers}
                   availableLocations={storageLocations}
+                  showLastDried={isEditing}
                   onCreateLocation={async (name) => {
                     try {
                       const created = await api.createLocation({ name });

+ 29 - 0
frontend/src/components/spool-form/AdditionalSection.tsx

@@ -2,6 +2,7 @@ import { useState, useRef, useEffect, useMemo } from 'react';
 import { Scale } from 'lucide-react';
 import { useTranslation } from 'react-i18next';
 import { useToast } from '../../contexts/ToastContext';
+import { toDateTimeLocalValue } from '../../utils/date';
 import type { AdditionalSectionProps } from './types';
 
 function SpoolWeightPicker({
@@ -179,6 +180,7 @@ export function AdditionalSection({
   onCreateLocation,
   globalLowStockThreshold,
   spoolmanMode = false,
+  showLastDried = false,
 }: AdditionalSectionProps) {
   const { t } = useTranslation();
   const { showToast } = useToast();
@@ -464,6 +466,33 @@ export function AdditionalSection({
         )}
       </div>
 
+      {/* Last dried (#2863). Stamped by AMS drying runs; set here for a
+          spool dried in an external dryer. */}
+      {showLastDried && (
+        <div>
+          <label className="block text-sm font-medium text-bambu-gray mb-1" htmlFor="spool-last-dried">
+            {t('inventory.lastDried')}
+          </label>
+          <div className="flex gap-2">
+            <input
+              id="spool-last-dried"
+              type="datetime-local"
+              className="flex-1 min-w-0 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:outline-none focus:border-bambu-green"
+              value={formData.last_dried_at}
+              onChange={(e) => updateField('last_dried_at', e.target.value)}
+            />
+            <button
+              type="button"
+              className="px-3 py-2 text-sm rounded-lg bg-bambu-dark-tertiary text-white hover:bg-bambu-gray-dark"
+              onClick={() => updateField('last_dried_at', toDateTimeLocalValue(new Date()))}
+            >
+              {t('inventory.lastDriedNow')}
+            </button>
+          </div>
+          <p className="text-xs text-bambu-gray mt-1">{t('inventory.lastDriedHelp')}</p>
+        </div>
+      )}
+
       {/* Note */}
       <div className="sm:col-span-2">
         <label className="block text-sm font-medium text-bambu-gray mb-1">{t('inventory.note')}</label>

+ 7 - 0
frontend/src/components/spool-form/types.ts

@@ -45,6 +45,9 @@ export interface SpoolFormData {
   // shared by all spools of the same product. Free text.
   material_number: string;
   location_id: number | null;
+  // Last drying (#2863), as a datetime-local value in the browser's time zone.
+  // Empty means never dried. Only shown and sent when editing.
+  last_dried_at: string;
   // When set the spool is linked to a specific Spoolman filament catalog entry;
   // the backend skips find_or_create_filament() and uses this ID directly.
   spoolman_filament_id: number | null;
@@ -69,6 +72,7 @@ export const defaultFormData: SpoolFormData = {
   low_stock_threshold_pct: null,
   material_number: '',
   location_id: null,
+  last_dried_at: '',
   spoolman_filament_id: null,
 };
 
@@ -219,6 +223,9 @@ export interface AdditionalSectionProps extends SectionProps {
   globalLowStockThreshold: number;
   availableLocations?: { id: number; name: string }[];
   onCreateLocation?: (name: string) => Promise<{ id: number; name: string } | null>;
+  // When true the "Last dried" field is shown. Edit mode only: a new spool
+  // has no drying record to correct (#2863).
+  showLastDried?: boolean;
   // When true the material number input is hidden: in Spoolman mode the
   // number is Spoolman's filament-level article_number, maintained in
   // Spoolman itself and shown read-only in the list (#2870).

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

@@ -5135,6 +5135,9 @@ export default {
     materialNumber: 'Material-Nr.',
     materialNumberPlaceholder: 'z. B. 15',
     materialNumberHelp: 'Interne Einkaufsnummer - wird von allen Spulen dieses Produkts geteilt. Neue Spulen desselben Produkts übernehmen sie.',
+    lastDried: 'Zuletzt getrocknet',
+    lastDriedNow: 'Jetzt',
+    lastDriedHelp: 'Wird eingetragen, wenn ein AMS-Trocknungslauf mindestens zur Hälfte gelaufen ist. Für eine anderswo getrocknete Spule hier setzen.',
     materialNumberNone: 'Keine Materialnummer',
     // Suppliers (#2988): the master list and the per-spool assignments.
     suppliers: {

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

@@ -5177,6 +5177,9 @@ export default {
     materialNumber: 'Material No.',
     materialNumberPlaceholder: 'e.g. 15',
     materialNumberHelp: 'Internal purchasing number — shared by all spools of this product. New spools of the same product inherit it.',
+    lastDried: 'Last dried',
+    lastDriedNow: 'Now',
+    lastDriedHelp: 'Filled in when an AMS drying run of at least half its length ends. Set it here for a spool dried elsewhere.',
     materialNumberNone: 'No material number',
     // Suppliers (#2988): the master list and the per-spool assignments.
     suppliers: {

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

@@ -5138,6 +5138,9 @@ export default {
     materialNumber: 'N.º de material',
     materialNumberPlaceholder: 'p. ej. 15',
     materialNumberHelp: 'Número interno de compras: compartido por todas las bobinas de este producto. Las bobinas nuevas del mismo producto lo heredan.',
+    lastDried: 'Último secado',
+    lastDriedNow: 'Ahora',
+    lastDriedHelp: 'Se rellena cuando termina un secado en el AMS que ha durado al menos la mitad de su duración. Indícalo aquí para una bobina secada en otro sitio.',
     materialNumberNone: 'Sin número de material',
     // Suppliers (#2988): the master list and the per-spool assignments.
     suppliers: {

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

@@ -5123,6 +5123,9 @@ export default {
     materialNumber: 'N° matière',
     materialNumberPlaceholder: 'ex. 15',
     materialNumberHelp: 'Numéro d\'achat interne - partagé par toutes les bobines de ce produit. Les nouvelles bobines du même produit en héritent.',
+    lastDried: 'Dernier séchage',
+    lastDriedNow: 'Maintenant',
+    lastDriedHelp: 'Renseigné à la fin d\'un séchage AMS ayant duré au moins la moitié de sa durée. Indiquez-le ici pour une bobine séchée ailleurs.',
     materialNumberNone: 'Aucun numéro de matière',
     suppliers: {
       label: 'Fournisseurs',

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

@@ -5122,6 +5122,9 @@ export default {
     materialNumber: 'N. materiale',
     materialNumberPlaceholder: 'es. 15',
     materialNumberHelp: 'Numero interno di acquisto - condiviso da tutte le bobine di questo prodotto. Le nuove bobine dello stesso prodotto lo ereditano.',
+    lastDried: 'Ultima essiccazione',
+    lastDriedNow: 'Ora',
+    lastDriedHelp: 'Compilato quando termina un\'essiccazione AMS durata almeno metà del tempo previsto. Impostalo qui per una bobina essiccata altrove.',
     materialNumberNone: 'Nessun numero materiale',
     suppliers: {
       label: 'Fornitori',

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

@@ -5135,6 +5135,9 @@ export default {
     materialNumber: '資材番号',
     materialNumberPlaceholder: '例:15',
     materialNumberHelp: '社内の購買番号です。同じ製品のすべてのスプールで共有され、同じ製品の新しいスプールに自動的に引き継がれます。',
+    lastDried: '最終乾燥',
+    lastDriedNow: '今',
+    lastDriedHelp: 'AMS の乾燥が予定時間の半分以上行われて終了すると記録されます。他の場所で乾燥したスプールはここで設定してください。',
     materialNumberNone: '資材番号なし',
     // Suppliers (#2988): the master list and the per-spool assignments.
     suppliers: {

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

@@ -4920,6 +4920,9 @@ export default {
     materialNumber: '자재 번호',
     materialNumberPlaceholder: '예: 15',
     materialNumberHelp: '내부 구매 번호 - 이 제품의 모든 스풀이 공유합니다. 같은 제품의 새 스풀은 이 번호를 이어받습니다.',
+    lastDried: '마지막 건조',
+    lastDriedNow: '지금',
+    lastDriedHelp: 'AMS 건조가 예정 시간의 절반 이상 진행된 뒤 끝나면 기록됩니다. 다른 곳에서 건조한 스풀은 여기에서 설정하세요.',
     materialNumberNone: '자재 번호 없음',
     suppliers: {
       label: '공급업체',

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

@@ -5175,6 +5175,9 @@ export default {
     materialNumber: 'Materiaalnr.',
     materialNumberPlaceholder: 'bijv. 15',
     materialNumberHelp: 'Intern inkoopnummer - gedeeld door alle spoelen van dit product. Nieuwe spoelen van hetzelfde product nemen het over.',
+    lastDried: 'Laatst gedroogd',
+    lastDriedNow: 'Nu',
+    lastDriedHelp: 'Wordt ingevuld wanneer een AMS-droogcyclus van minstens de helft van zijn duur eindigt. Stel het hier in voor een elders gedroogde spoel.',
     materialNumberNone: 'Geen materiaalnummer',
     // Suppliers (#2988): the master list and the per-spool assignments.
     suppliers: {

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

@@ -5122,6 +5122,9 @@ export default {
     materialNumber: 'Nº do Material',
     materialNumberPlaceholder: 'ex. 15',
     materialNumberHelp: 'Número interno de compra - compartilhado por todos os carretéis deste produto. Novos carretéis do mesmo produto o herdam.',
+    lastDried: 'Última secagem',
+    lastDriedNow: 'Agora',
+    lastDriedHelp: 'Preenchido quando termina uma secagem no AMS que durou pelo menos metade do tempo previsto. Defina aqui para um carretel secado em outro lugar.',
     materialNumberNone: 'Sem número de material',
     suppliers: {
       label: 'Fornecedores',

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

@@ -4911,6 +4911,9 @@ export default {
     materialNumber: 'Артикул',
     materialNumberPlaceholder: 'напр. 15',
     materialNumberHelp: 'Внутренний закупочный номер — общий для всех катушек этого товара. Новые катушки того же товара наследуют его.',
+    lastDried: 'Последняя сушка',
+    lastDriedNow: 'Сейчас',
+    lastDriedHelp: 'Заполняется, когда сушка в AMS завершается, проработав не меньше половины заданного времени. Для катушки, высушенной в другом месте, укажите здесь.',
     materialNumberNone: 'Без артикула',
     suppliers: {
       label: "Поставщики",

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

@@ -5174,6 +5174,9 @@ errors: {
     materialNumber: 'Materialnr',
     materialNumberPlaceholder: 't.ex. 15',
     materialNumberHelp: 'Internt inköpsnummer — delas av alla rullar av denna produkt. Nya rullar av samma produkt ärver det.',
+    lastDried: 'Senast torkad',
+    lastDriedNow: 'Nu',
+    lastDriedHelp: 'Fylls i när en AMS-torkning som pågått minst halva sin tid avslutas. Ange det här för en rulle som torkats någon annanstans.',
     materialNumberNone: 'Inget materialnummer',
     suppliers: {
       label: 'Leverantörer',

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

@@ -5122,6 +5122,9 @@ export default {
     materialNumber: 'Malzeme No.',
     materialNumberPlaceholder: 'örn. 15',
     materialNumberHelp: 'Dahili satın alma numarası - bu ürünün tüm makaraları tarafından paylaşılır. Aynı ürünün yeni makaraları bu numarayı devralır.',
+    lastDried: 'Son kurutma',
+    lastDriedNow: 'Şimdi',
+    lastDriedHelp: 'Süresinin en az yarısı kadar çalışan bir AMS kurutması bittiğinde doldurulur. Başka yerde kurutulan bir makara için buradan ayarlayın.',
     materialNumberNone: 'Malzeme numarası yok',
     suppliers: {
       label: 'Tedarikçiler',

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

@@ -5171,6 +5171,9 @@ export default {
     materialNumber: 'Мат. №',
     materialNumberPlaceholder: 'напр. 15',
     materialNumberHelp: 'Внутрішній закупівельний номер — спільний для всіх котушок цього продукту. Нові котушки того самого продукту успадковують його.',
+    lastDried: 'Остання сушка',
+    lastDriedNow: 'Зараз',
+    lastDriedHelp: 'Заповнюється, коли сушіння в AMS завершується, пропрацювавши щонайменше половину заданого часу. Для котушки, висушеної деінде, вкажіть тут.',
     materialNumberNone: 'Без номера матеріалу',
     suppliers: {
       label: "Постачальники",

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

@@ -5122,6 +5122,9 @@ export default {
     materialNumber: '物料号',
     materialNumberPlaceholder: '例如 15',
     materialNumberHelp: '内部采购编号 — 同一产品的所有料盘共用。同一产品的新料盘会自动继承。',
+    lastDried: '上次烘干',
+    lastDriedNow: '现在',
+    lastDriedHelp: 'AMS 烘干运行至少一半时长后结束时自动填写。在其他地方烘干的料盘请在此设置。',
     materialNumberNone: '无物料号',
     suppliers: {
       label: '供应商',

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

@@ -5122,6 +5122,9 @@ export default {
     materialNumber: '料號',
     materialNumberPlaceholder: '例如:15',
     materialNumberHelp: '內部採購編號 — 同一產品的所有料盤共用。同一產品的新料盤會自動繼承。',
+    lastDried: '上次烘乾',
+    lastDriedNow: '現在',
+    lastDriedHelp: 'AMS 烘乾執行至少一半時長後結束時自動填寫。在其他地方烘乾的料盤請在此設定。',
     materialNumberNone: '無料號',
     suppliers: {
       label: '供應商',

+ 37 - 1
frontend/src/pages/InventoryPage.tsx

@@ -69,7 +69,9 @@ function spoolGroupKey(s: InventorySpool): string {
   // Include extra_colors + effect_type so the "Group similar" toggle does
   // not collapse two spools that share the base colour but differ on
   // gradient stops or visual effect (#1154).
-  return `${s.material}|${s.subtype || ''}|${s.brand || ''}|${s.color_name || ''}|${s.rgba || ''}|${s.extra_colors || ''}|${s.effect_type || ''}|${s.label_weight}`;
+  // last_dried_at too: a dried spool is not interchangeable with an undried
+  // one, and folding it into a group would hide the date (#2863).
+  return `${s.material}|${s.subtype || ''}|${s.brand || ''}|${s.color_name || ''}|${s.rgba || ''}|${s.extra_colors || ''}|${s.effect_type || ''}|${s.label_weight}|${s.last_dried_at || ''}`;
 }
 
 // Column definitions for the inventory table
@@ -104,6 +106,7 @@ const DEFAULT_COLUMNS: ColumnConfig[] = [
   { id: 'storage_location', label: 'Storage Location', visible: false },
   { id: 'temperature', label: 'Temperature', visible: false },
   { id: 'humidity', label: 'Humidity', visible: false },
+  { id: 'last_dried', label: 'Last Dried', visible: true },
   { id: 'battery', label: 'Battery', visible: false },
   { id: 'label_weight', label: 'Label', visible: true },
   { id: 'net', label: 'Net', visible: true },
@@ -184,6 +187,15 @@ const MATERIAL_COLORS: Record<string, string> = {
 
 type TFn = (key: string, opts?: Record<string, unknown>) => string;
 
+// "55°C · 8h" for the last drying (#2863); empty when neither is known, which
+// is the case for a date set by hand or a run Bambuddy did not start or watch.
+function formatLastDriedDetails(spool: InventorySpool): string {
+  const parts: string[] = [];
+  if (spool.last_dried_temp != null) parts.push(`${spool.last_dried_temp}°C`);
+  if (spool.last_dried_hours != null) parts.push(`${Number(spool.last_dried_hours.toFixed(1))}h`);
+  return parts.join(' · ');
+}
+
 function formatInventoryDate(dateStr: string | null, dateFormat: DateFormat = 'system'): string {
   if (!dateStr) return '-';
   const date = parseUTCDate(dateStr);
@@ -235,6 +247,7 @@ const columnHeaders: Record<string, (t: TFn) => string> = {
   storage_location: (t) => t('inventory.storageLocation'),
   temperature: (t) => t('inventory.temperature'),
   humidity: (t) => t('inventory.humidity'),
+  last_dried: (t) => t('inventory.lastDried'),
   battery: (t) => t('inventory.battery'),
   label_weight: (t) => t('inventory.labelWeight'),
   net: (t) => t('inventory.net'),
@@ -271,6 +284,16 @@ const columnCells: Record<string, (ctx: CellCtx) => ReactNode> = {
   last_used_time: ({ spool, dateFormat }) => (
     <span className="text-sm text-bambu-gray">{spool.last_used ? formatInventoryDate(spool.last_used, dateFormat) : 'Never'}</span>
   ),
+  last_dried: ({ spool, dateFormat }) => {
+    if (!spool.last_dried_at) return <span className="text-sm text-bambu-gray">-</span>;
+    const details = formatLastDriedDetails(spool);
+    return (
+      <div className="text-sm text-bambu-gray">
+        <div>{formatInventoryDate(spool.last_dried_at, dateFormat)}</div>
+        {details && <div className="text-xs text-bambu-gray/60">{details}</div>}
+      </div>
+    );
+  },
   rgba: ({ spool }) => (
     <div className="flex items-center justify-center">
       <FilamentSwatch
@@ -536,6 +559,7 @@ const columnSortValues: Record<
   added_time: (s) => s.created_at || '',
   encode_time: (s) => s.encode_time || '',
   last_used_time: (s) => s.last_used || '',
+  last_dried: (s) => s.last_dried_at || '',
   material: (s) => (s.material || '').toLowerCase(),
   subtype: (s) => (s.subtype || '').toLowerCase(),
   color_name: (s) => (s.color_name || '').toLowerCase(),
@@ -2266,6 +2290,7 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
                                 locationSensorAboveColor={locationSensorAboveColor}
                                 locationSensorBelowColor={locationSensorBelowColor}
                                 locationSensorOptimalColor={locationSensorOptimalColor}
+                                dateFormat={dateFormat}
                               />
                             );
                           })}
@@ -2291,6 +2316,7 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
                     locationSensorAboveColor={locationSensorAboveColor}
                     locationSensorBelowColor={locationSensorBelowColor}
                     locationSensorOptimalColor={locationSensorOptimalColor}
+                    dateFormat={dateFormat}
                   />
                 );
               })}
@@ -2763,6 +2789,7 @@ function PaginationBar({
 function SpoolCard({
   spool, remaining, pct, onClick, onPrintLabel, onCopy, t,
   colorizeLocationSensors, locationSensorAboveColor, locationSensorBelowColor, locationSensorOptimalColor,
+  dateFormat,
 }: {
   spool: InventorySpool;
   remaining: number;
@@ -2775,6 +2802,7 @@ function SpoolCard({
   locationSensorAboveColor: LocationSensorAlertColor;
   locationSensorBelowColor: LocationSensorAlertColor;
   locationSensorOptimalColor: LocationSensorAlertColor;
+  dateFormat: DateFormat;
 }) {
   const bannerStyle = buildFilamentBackground({
     rgba: spool.rgba,
@@ -2845,6 +2873,14 @@ function SpoolCard({
           </div>
         </div>
         <div className="grid grid-cols-2 gap-2 text-xs">
+          {spool.last_dried_at && (
+            <div className="col-span-2">
+              <span className="text-bambu-gray/60">{t('inventory.lastDried')}: </span>
+              <span className="text-bambu-gray">
+                {[formatInventoryDate(spool.last_dried_at, dateFormat), formatLastDriedDetails(spool)].filter(Boolean).join(' · ')}
+              </span>
+            </div>
+          )}
           <div>
             <span className="text-bambu-gray/60">{t('inventory.labelWeight')}: </span>
             <span className="text-bambu-gray">{formatWeight(spool.label_weight)}</span>

File diff suppressed because it is too large
+ 0 - 0
static/assets/AdditionalSection-3BKr-yip.js


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


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


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


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


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


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


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


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


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


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


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


File diff suppressed because it is too large
+ 0 - 0
static/assets/ContextMenu-4JKs6J8U.js


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


File diff suppressed because it is too large
+ 0 - 1
static/assets/FileManagerPage-DBtfYDIY.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/FileUploadModal-5XyUPp1M.js


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


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


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


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


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


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


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


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


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


File diff suppressed because it is too large
+ 0 - 0
static/assets/ModelSourcesPage-CV-ikPBH.js


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


File diff suppressed because it is too large
+ 0 - 1
static/assets/PdfPreviewModal-D0aAUfK3.js


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


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


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


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


File diff suppressed because it is too large
+ 0 - 0
static/assets/ProjectDetailPage-C-R6U56A.js


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


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


File diff suppressed because it is too large
+ 0 - 0
static/assets/RichTextEditor-DN1-jaEd.js


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


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


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


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


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


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


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


File diff suppressed because it is too large
+ 1 - 1
static/assets/SpoolBuddySettingsPage-BQgb_uO0.js


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


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


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


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


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


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


File diff suppressed because it is too large
+ 0 - 0
static/assets/es-5tXCFuNX.js


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


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


+ 1 - 1
static/assets/inventorySearch-zXUnjogp.js → static/assets/inventorySearch-CnZFjESL.js

@@ -1 +1 @@
-import{ft as e}from"./index-DCvCEjg1.js";function t(t,n){let r=n.trim().toLowerCase();if(!r)return!0;let i=r.match(/^#(\d+)$/);return i?t.id===Number(i[1]):String(t.id).includes(r)||t.material.toLowerCase().includes(r)||(t.brand?.toLowerCase().includes(r)??!1)||(t.color_name?.toLowerCase().includes(r)??!1)||(e(t.color_name,t.rgba,t.color_name_is_synthesized)?.toLowerCase().includes(r)??!1)||(t.subtype?.toLowerCase().includes(r)??!1)||(t.note?.toLowerCase().includes(r)??!1)||(t.slicer_filament_name?.toLowerCase().includes(r)??!1)||(t.storage_location?.toLowerCase().includes(r)??!1)||(t.material_number?.toLowerCase().includes(r)??!1)||(t.suppliers?.some(e=>e.supplier_name.toLowerCase().includes(r)||(e.supplier_article_number?.toLowerCase().includes(r)??!1))??!1)}function n(e,n){return n?e.filter(e=>t(e,n)):e}export{n as t};
+import{ft as e}from"./index-DQNUzRiJ.js";function t(t,n){let r=n.trim().toLowerCase();if(!r)return!0;let i=r.match(/^#(\d+)$/);return i?t.id===Number(i[1]):String(t.id).includes(r)||t.material.toLowerCase().includes(r)||(t.brand?.toLowerCase().includes(r)??!1)||(t.color_name?.toLowerCase().includes(r)??!1)||(e(t.color_name,t.rgba,t.color_name_is_synthesized)?.toLowerCase().includes(r)??!1)||(t.subtype?.toLowerCase().includes(r)??!1)||(t.note?.toLowerCase().includes(r)??!1)||(t.slicer_filament_name?.toLowerCase().includes(r)??!1)||(t.storage_location?.toLowerCase().includes(r)??!1)||(t.material_number?.toLowerCase().includes(r)??!1)||(t.suppliers?.some(e=>e.supplier_name.toLowerCase().includes(r)||(e.supplier_article_number?.toLowerCase().includes(r)??!1))??!1)}function n(e,n){return n?e.filter(e=>t(e,n)):e}export{n as t};

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


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


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


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


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