فهرست منبع

Add staggered batch start for multi-printer queue jobs (#752)

  When adding a print to the queue for multiple printers, users can now
  enable "Stagger printer starts" to avoid power spikes from simultaneous
  bed heating. Configurable group size and interval — first group starts
  immediately (ASAP) or at scheduled time, subsequent groups get offset
  scheduled_time values. No backend queue/scheduler changes — leverages
  existing scheduled_time field.

  Also adds a dedicated Queue tab in Settings (stagger defaults + auto-
  drying moved from Filament tab), i18n keys for all 7 locales, frontend
  and backend tests.
maziggy 5 ماه پیش
والد
کامیت
68dce24cc9

+ 4 - 0
CHANGELOG.md

@@ -4,6 +4,10 @@ All notable changes to Bambuddy will be documented in this file.
 
 
 ## [0.2.3b1] - Unreleased
 ## [0.2.3b1] - Unreleased
 
 
+### New Features
+- **Staggered Batch Start for Multi-Printer Jobs** ([#752](https://github.com/maziggy/bambuddy/issues/752)) — When sending a print to multiple printers via the queue, you can now stagger the starts to avoid power spikes from simultaneous bed heating. Enable "Stagger printer starts" in the schedule options to define a group size (how many printers start at once) and interval (minutes between groups). For example, 10 printers with group size 2 and interval 5 min will start in 5 waves over 25 minutes. Default group size and interval are configurable in Settings → Queue. Works with both ASAP and Scheduled timing — ASAP starts the first group immediately, subsequent groups get computed scheduled times. Requested by @maziggy.
+- **Settings Queue Tab** — New dedicated Queue tab in Settings consolidates queue-related settings: staggered start defaults and auto-drying configuration (moved from the Filament tab).
+
 ### Fixed
 ### Fixed
 - **H2D External Spool Uses Wrong Nozzle** ([#836](https://github.com/maziggy/bambuddy/issues/836)) — Prints sent from Bambuddy to dual-nozzle printers (H2D, H2D Pro) with external spools always routed to the wrong nozzle. The old `ams_mapping2` format used a shared `ams_id: 255` with `slot_id: 0/1` to differentiate external slots, but the firmware interpreted slot_id as the nozzle index (0=main/right, 1=deputy/left), routing filament to the opposite nozzle. Already fixed by the #797 `ams_mapping2` format change (per-tray `ams_id` instead of shared unit), but users on older builds still experience this. Printing the same file directly from the slicer worked correctly. Reported by @NoahTingey.
 - **H2D External Spool Uses Wrong Nozzle** ([#836](https://github.com/maziggy/bambuddy/issues/836)) — Prints sent from Bambuddy to dual-nozzle printers (H2D, H2D Pro) with external spools always routed to the wrong nozzle. The old `ams_mapping2` format used a shared `ams_id: 255` with `slot_id: 0/1` to differentiate external slots, but the firmware interpreted slot_id as the nozzle index (0=main/right, 1=deputy/left), routing filament to the opposite nozzle. Already fixed by the #797 `ams_mapping2` format change (per-tray `ams_id` instead of shared unit), but users on older builds still experience this. Printing the same file directly from the slicer worked correctly. Reported by @NoahTingey.
 - **SpoolBuddy "Add to Inventory" Failed Silently** — The quick-add button on the SpoolBuddy kiosk did nothing when tapped. The scale weight was sent as a float but the backend requires an integer, causing a Pydantic validation error. The error was silently caught with no user feedback, leaving the confirmation modal stuck open. Fixed by rounding the weight before sending, moving the modal close to a `finally` block, and adding an error toast with the actual API message.
 - **SpoolBuddy "Add to Inventory" Failed Silently** — The quick-add button on the SpoolBuddy kiosk did nothing when tapped. The scale weight was sent as a float but the backend requires an integer, causing a Pydantic validation error. The error was silently caught with no user feedback, leaving the confirmation modal stuck open. Fixed by rounding the weight before sending, moving the modal close to a `finally` block, and adding an error toast with the actual API message.

+ 1 - 0
README.md

@@ -106,6 +106,7 @@ Perfect for remote print farms, traveling makers, or accessing your home printer
 - **Background print dispatch** — FTP uploads and print-start commands run in the background with real-time WebSocket progress toasts (per-job upload bars, status badges, cancel button)
 - **Background print dispatch** — FTP uploads and print-start commands run in the background with real-time WebSocket progress toasts (per-job upload bars, status badges, cancel button)
 - Print queue with drag-and-drop
 - Print queue with drag-and-drop
 - Multi-printer selection (send to multiple printers at once)
 - Multi-printer selection (send to multiple printers at once)
+- Staggered batch start (start printers in groups with configurable interval to avoid power spikes)
 - Model-based queue assignment (send to "any X1C" for load balancing) with location filtering
 - Model-based queue assignment (send to "any X1C" for load balancing) with location filtering
 - Filament override for model-based queue (swap filament colors/types before scheduling)
 - Filament override for model-based queue (swap filament colors/types before scheduling)
 - Filament validation (only assign to printers with required filaments)
 - Filament validation (only assign to printers with required filaments)

+ 10 - 0
backend/app/schemas/settings.py

@@ -186,6 +186,14 @@ class AppSettings(BaseModel):
         description="Enable user email notifications for print job events (requires Advanced Authentication)",
         description="Enable user email notifications for print job events (requires Advanced Authentication)",
     )
     )
 
 
+    # Staggered batch start for multi-printer jobs
+    stagger_group_size: int = Field(
+        default=2, ge=1, le=50, description="Number of printers to start simultaneously in staggered mode"
+    )
+    stagger_interval_minutes: int = Field(
+        default=5, ge=1, le=60, description="Minutes between staggered printer groups"
+    )
+
     # Default sidebar order (admin-set for all users)
     # Default sidebar order (admin-set for all users)
     default_sidebar_order: str = Field(
     default_sidebar_order: str = Field(
         default="",
         default="",
@@ -260,6 +268,8 @@ class AppSettingsUpdate(BaseModel):
     prometheus_token: str | None = None
     prometheus_token: str | None = None
     low_stock_threshold: float | None = Field(default=None, ge=0.1, le=99.9)
     low_stock_threshold: float | None = Field(default=None, ge=0.1, le=99.9)
     user_notifications_enabled: bool | None = None
     user_notifications_enabled: bool | None = None
+    stagger_group_size: int | None = Field(default=None, ge=1, le=50)
+    stagger_interval_minutes: int | None = Field(default=None, ge=1, le=60)
     default_sidebar_order: str | None = None
     default_sidebar_order: str | None = None
 
 
     @field_validator("default_sidebar_order")
     @field_validator("default_sidebar_order")

+ 58 - 0
backend/tests/integration/test_settings_api.py

@@ -416,6 +416,64 @@ class TestSettingsAPI:
         # Default is False as defined in schema
         # Default is False as defined in schema
         assert isinstance(result["per_printer_mapping_expanded"], bool)
         assert isinstance(result["per_printer_mapping_expanded"], bool)
 
 
+    # ========================================================================
+    # Stagger settings tests
+    # ========================================================================
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_stagger_settings_defaults(self, async_client: AsyncClient):
+        """Verify stagger settings have correct defaults."""
+        response = await async_client.get("/api/v1/settings/")
+        result = response.json()
+
+        assert result["stagger_group_size"] == 2
+        assert result["stagger_interval_minutes"] == 5
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_stagger_settings(self, async_client: AsyncClient):
+        """Verify stagger settings can be updated."""
+        response = await async_client.put(
+            "/api/v1/settings/",
+            json={"stagger_group_size": 3, "stagger_interval_minutes": 10},
+        )
+
+        assert response.status_code == 200
+        result = response.json()
+        assert result["stagger_group_size"] == 3
+        assert result["stagger_interval_minutes"] == 10
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_stagger_settings_persist(self, async_client: AsyncClient):
+        """Verify stagger settings persist after update."""
+        await async_client.put(
+            "/api/v1/settings/",
+            json={"stagger_group_size": 4, "stagger_interval_minutes": 15},
+        )
+
+        response = await async_client.get("/api/v1/settings/")
+        result = response.json()
+        assert result["stagger_group_size"] == 4
+        assert result["stagger_interval_minutes"] == 15
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_stagger_settings_validation(self, async_client: AsyncClient):
+        """Verify stagger settings reject out-of-range values."""
+        response = await async_client.put("/api/v1/settings/", json={"stagger_group_size": 0})
+        assert response.status_code == 422
+
+        response = await async_client.put("/api/v1/settings/", json={"stagger_group_size": 51})
+        assert response.status_code == 422
+
+        response = await async_client.put("/api/v1/settings/", json={"stagger_interval_minutes": 0})
+        assert response.status_code == 422
+
+        response = await async_client.put("/api/v1/settings/", json={"stagger_interval_minutes": 61})
+        assert response.status_code == 422
+
     # ========================================================================
     # ========================================================================
     # Home Assistant environment variable tests
     # Home Assistant environment variable tests
     # ========================================================================
     # ========================================================================

+ 127 - 0
frontend/src/__tests__/components/PrintModal.test.tsx

@@ -713,6 +713,133 @@ describe('PrintModal', () => {
     });
     });
   });
   });
 
 
+  describe('stagger start', () => {
+    it('does not show stagger option with single printer in queue mode', async () => {
+      const user = userEvent.setup();
+      render(
+        <PrintModal
+          mode="add-to-queue"
+          archiveId={1}
+          archiveName="Test Print"
+          onClose={mockOnClose}
+        />
+      );
+
+      await waitFor(() => {
+        expect(screen.getByText('X1 Carbon')).toBeInTheDocument();
+      });
+
+      // Select single printer
+      await user.click(screen.getByText('X1 Carbon'));
+
+      expect(screen.queryByText('Stagger printer starts')).not.toBeInTheDocument();
+    });
+
+    it('shows stagger option when multiple printers selected in queue mode', async () => {
+      const user = userEvent.setup();
+      render(
+        <PrintModal
+          mode="add-to-queue"
+          archiveId={1}
+          archiveName="Test Print"
+          onClose={mockOnClose}
+        />
+      );
+
+      await waitFor(() => {
+        expect(screen.getByText('Select all')).toBeInTheDocument();
+      });
+
+      await user.click(screen.getByText('Select all'));
+
+      await waitFor(() => {
+        expect(screen.getByText('Stagger printer starts')).toBeInTheDocument();
+      });
+    });
+
+    it('does not show stagger option in reprint mode', async () => {
+      const user = userEvent.setup();
+      render(
+        <PrintModal
+          mode="reprint"
+          archiveId={1}
+          archiveName="Test Print"
+          onClose={mockOnClose}
+        />
+      );
+
+      await waitFor(() => {
+        expect(screen.getByText('Select all')).toBeInTheDocument();
+      });
+
+      await user.click(screen.getByText('Select all'));
+
+      await waitFor(() => {
+        expect(screen.getByText(/2 printers selected|3 printers selected/)).toBeInTheDocument();
+      });
+
+      expect(screen.queryByText('Stagger printer starts')).not.toBeInTheDocument();
+    });
+
+    it('shows stagger inputs when stagger checkbox is enabled', async () => {
+      const user = userEvent.setup();
+      render(
+        <PrintModal
+          mode="add-to-queue"
+          archiveId={1}
+          archiveName="Test Print"
+          onClose={mockOnClose}
+        />
+      );
+
+      await waitFor(() => {
+        expect(screen.getByText('Select all')).toBeInTheDocument();
+      });
+
+      await user.click(screen.getByText('Select all'));
+
+      await waitFor(() => {
+        expect(screen.getByText('Stagger printer starts')).toBeInTheDocument();
+      });
+
+      await user.click(screen.getByLabelText('Stagger printer starts'));
+
+      await waitFor(() => {
+        expect(screen.getByText('Group size')).toBeInTheDocument();
+        expect(screen.getByText('Interval (min)')).toBeInTheDocument();
+      });
+    });
+
+    it('shows stagger preview with printer count', async () => {
+      const user = userEvent.setup();
+      render(
+        <PrintModal
+          mode="add-to-queue"
+          archiveId={1}
+          archiveName="Test Print"
+          onClose={mockOnClose}
+        />
+      );
+
+      await waitFor(() => {
+        expect(screen.getByText('Select all')).toBeInTheDocument();
+      });
+
+      await user.click(screen.getByText('Select all'));
+
+      await waitFor(() => {
+        expect(screen.getByText('Stagger printer starts')).toBeInTheDocument();
+      });
+
+      await user.click(screen.getByLabelText('Stagger printer starts'));
+
+      await waitFor(() => {
+        // Default: 3 printers, group size 2 = 2 groups — preview text includes printer count
+        expect(screen.getByText(/3 printers.*2 groups/)).toBeInTheDocument();
+      });
+    });
+  });
+
   describe('multi-plate selection', () => {
   describe('multi-plate selection', () => {
     const multiPlateResponse = {
     const multiPlateResponse = {
       is_multi_plate: true,
       is_multi_plate: true,

+ 49 - 0
frontend/src/__tests__/pages/SettingsPage.test.tsx

@@ -222,6 +222,55 @@ describe('SettingsPage', () => {
     });
     });
   });
   });
 
 
+  describe('Queue tab', () => {
+    it('can switch to Queue tab', async () => {
+      const user = userEvent.setup();
+      render(<SettingsPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Queue')).toBeInTheDocument();
+      });
+
+      await user.click(screen.getByText('Queue'));
+
+      await waitFor(() => {
+        expect(screen.getByText('Staggered Start')).toBeInTheDocument();
+      });
+    });
+
+    it('shows stagger settings on Queue tab', async () => {
+      const user = userEvent.setup();
+      render(<SettingsPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Queue')).toBeInTheDocument();
+      });
+
+      await user.click(screen.getByText('Queue'));
+
+      await waitFor(() => {
+        expect(screen.getByText('Staggered Start')).toBeInTheDocument();
+        expect(screen.getByText('Group size')).toBeInTheDocument();
+        expect(screen.getByText('Interval (minutes)')).toBeInTheDocument();
+      });
+    });
+
+    it('shows auto-drying settings on Queue tab', async () => {
+      const user = userEvent.setup();
+      render(<SettingsPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Queue')).toBeInTheDocument();
+      });
+
+      await user.click(screen.getByText('Queue'));
+
+      await waitFor(() => {
+        expect(screen.getByText('Queue Auto-Drying')).toBeInTheDocument();
+      });
+    });
+  });
+
   describe('API Keys tab', () => {
   describe('API Keys tab', () => {
     it('can switch to API Keys tab', async () => {
     it('can switch to API Keys tab', async () => {
       const user = userEvent.setup();
       const user = userEvent.setup();

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

@@ -886,6 +886,9 @@ export interface AppSettings {
   low_stock_threshold: number;
   low_stock_threshold: number;
   // User email notifications toggle
   // User email notifications toggle
   user_notifications_enabled: boolean;
   user_notifications_enabled: boolean;
+  // Staggered batch start defaults
+  stagger_group_size: number;
+  stagger_interval_minutes: number;
   // Default sidebar order (admin-set for all users)
   // Default sidebar order (admin-set for all users)
   default_sidebar_order: string;
   default_sidebar_order: string;
 }
 }

+ 74 - 1
frontend/src/components/PrintModal/ScheduleOptions.tsx

@@ -1,5 +1,6 @@
 import { useState, useEffect, useRef } from 'react';
 import { useState, useEffect, useRef } from 'react';
-import { Calendar, Clock, Hand, Power } from 'lucide-react';
+import { useTranslation } from 'react-i18next';
+import { Calendar, Clock, Hand, Power, Layers } from 'lucide-react';
 import type { ScheduleOptionsProps, ScheduleType } from './types';
 import type { ScheduleOptionsProps, ScheduleType } from './types';
 import {
 import {
   formatDateInput,
   formatDateInput,
@@ -24,7 +25,10 @@ export function ScheduleOptionsPanel({
   dateFormat = 'system',
   dateFormat = 'system',
   timeFormat = 'system',
   timeFormat = 'system',
   canControlPrinter = true,
   canControlPrinter = true,
+  showStagger = false,
+  printerCount = 0,
 }: ScheduleOptionsProps) {
 }: ScheduleOptionsProps) {
+  const { t } = useTranslation();
   const [dateValue, setDateValue] = useState('');
   const [dateValue, setDateValue] = useState('');
   const [timeValue, setTimeValue] = useState('');
   const [timeValue, setTimeValue] = useState('');
   const [isDateValid, setIsDateValid] = useState(true);
   const [isDateValid, setIsDateValid] = useState(true);
@@ -247,6 +251,75 @@ export function ScheduleOptionsPanel({
         </label>
         </label>
       </div>
       </div>
 
 
+      {/* Stagger start */}
+      {showStagger && options.scheduleType !== 'manual' && (
+        <div className="space-y-3">
+          <div className="flex items-center gap-2">
+            <input
+              type="checkbox"
+              id="staggerEnabled"
+              checked={options.staggerEnabled}
+              onChange={(e) => onChange({ ...options, staggerEnabled: e.target.checked })}
+              className="rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green"
+            />
+            <label htmlFor="staggerEnabled" className="text-sm flex items-center gap-1 text-bambu-gray">
+              <Layers className="w-3.5 h-3.5" />
+              {t('printModal.staggerPrinterStarts', 'Stagger printer starts')}
+            </label>
+          </div>
+
+          {options.staggerEnabled && (
+            <div className="ml-6 space-y-3">
+              <div className="flex gap-3">
+                <div className="flex-1">
+                  <label className="block text-xs text-bambu-gray mb-1">{t('printModal.staggerGroupSize', 'Group size')}</label>
+                  <input
+                    type="number"
+                    min={1}
+                    max={printerCount}
+                    value={options.staggerGroupSize}
+                    onChange={(e) => onChange({ ...options, staggerGroupSize: Math.max(1, parseInt(e.target.value) || 1) })}
+                    className="w-full 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"
+                  />
+                </div>
+                <div className="flex-1">
+                  <label className="block text-xs text-bambu-gray mb-1">{t('printModal.staggerInterval', 'Interval (min)')}</label>
+                  <input
+                    type="number"
+                    min={1}
+                    max={60}
+                    value={options.staggerIntervalMinutes}
+                    onChange={(e) => onChange({ ...options, staggerIntervalMinutes: Math.max(1, parseInt(e.target.value) || 1) })}
+                    className="w-full 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"
+                  />
+                </div>
+              </div>
+              {printerCount > 0 && (() => {
+                const groupCount = Math.ceil(printerCount / options.staggerGroupSize);
+                const lastGroupSize = printerCount % options.staggerGroupSize;
+                const totalMinutes = (groupCount - 1) * options.staggerIntervalMinutes;
+                return (
+                  <p className="text-xs text-bambu-gray">
+                    {t('printModal.staggerPreview', '{{printers}} printers → {{groups}} groups of {{size}}, starting every {{interval}} min', {
+                      printers: printerCount,
+                      groups: groupCount,
+                      size: options.staggerGroupSize,
+                      interval: options.staggerIntervalMinutes,
+                    })}
+                    {lastGroupSize !== 0 && options.staggerGroupSize < printerCount
+                      ? ` (${t('printModal.staggerLastGroup', 'last group: {{count}}', { count: lastGroupSize })})`
+                      : ''}
+                    {groupCount > 1
+                      ? ` (${t('printModal.staggerTotal', 'total: {{minutes}} min', { minutes: totalMinutes })})`
+                      : ''}
+                  </p>
+                );
+              })()}
+            </div>
+          )}
+        </div>
+      )}
+
       {/* Help text */}
       {/* Help text */}
       <p className="text-xs text-bambu-gray">
       <p className="text-xs text-bambu-gray">
         {options.scheduleType === 'asap'
         {options.scheduleType === 'asap'

+ 40 - 2
frontend/src/components/PrintModal/index.tsx

@@ -1,6 +1,6 @@
 import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
 import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
 import { AlertCircle, AlertTriangle, Calendar, Loader2, Pencil, Printer, X } from 'lucide-react';
 import { AlertCircle, AlertTriangle, Calendar, Loader2, Pencil, Printer, X } from 'lucide-react';
-import { useEffect, useMemo, useState } from 'react';
+import { useEffect, useMemo, useRef, useState } from 'react';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 import type { PrintQueueItemCreate, PrintQueueItemUpdate, SpoolAssignment } from '../../api/client';
 import type { PrintQueueItemCreate, PrintQueueItemUpdate, SpoolAssignment } from '../../api/client';
 import { api } from '../../api/client';
 import { api } from '../../api/client';
@@ -121,6 +121,9 @@ export function PrintModal({
         scheduledTime,
         scheduledTime,
         requirePreviousSuccess: queueItem.require_previous_success,
         requirePreviousSuccess: queueItem.require_previous_success,
         autoOffAfter: queueItem.auto_off_after,
         autoOffAfter: queueItem.auto_off_after,
+        staggerEnabled: false,
+        staggerGroupSize: DEFAULT_SCHEDULE_OPTIONS.staggerGroupSize,
+        staggerIntervalMinutes: DEFAULT_SCHEDULE_OPTIONS.staggerIntervalMinutes,
       };
       };
     }
     }
     return DEFAULT_SCHEDULE_OPTIONS;
     return DEFAULT_SCHEDULE_OPTIONS;
@@ -217,6 +220,18 @@ export function PrintModal({
     queryFn: api.getSettings,
     queryFn: api.getSettings,
   });
   });
 
 
+  // Sync stagger defaults from settings once available
+  const staggerDefaultsApplied = useRef(false);
+  useEffect(() => {
+    if (!settings || staggerDefaultsApplied.current || mode === 'edit-queue-item') return;
+    staggerDefaultsApplied.current = true;
+    setScheduleOptions((prev) => ({
+      ...prev,
+      staggerGroupSize: settings.stagger_group_size ?? prev.staggerGroupSize,
+      staggerIntervalMinutes: settings.stagger_interval_minutes ?? prev.staggerIntervalMinutes,
+    }));
+  }, [settings, mode]);
+
   const currencySymbol = getCurrencySymbol(settings?.currency || 'USD');
   const currencySymbol = getCurrencySymbol(settings?.currency || 'USD');
   const defaultCostPerKg = settings?.default_filament_cost ?? 0;
   const defaultCostPerKg = settings?.default_filament_cost ?? 0;
 
 
@@ -665,6 +680,16 @@ export function PrintModal({
       }
       }
     } else {
     } else {
       // Printer-based assignment: loop through plates × printers
       // Printer-based assignment: loop through plates × printers
+      // Compute stagger base time once before the loop
+      const useStagger = scheduleOptions.staggerEnabled
+        && mode === 'add-to-queue'
+        && selectedPrinters.length > 1;
+      const staggerBaseTime = useStagger
+        ? (scheduleOptions.scheduleType === 'scheduled' && scheduleOptions.scheduledTime
+          ? new Date(scheduleOptions.scheduledTime).getTime()
+          : Date.now())
+        : 0;
+
       let progressCounter = 0;
       let progressCounter = 0;
       for (const plate of platesToQueue) {
       for (const plate of platesToQueue) {
         const plateId = plate ? plate.index : selectedPlate;
         const plateId = plate ? plate.index : selectedPlate;
@@ -713,7 +738,18 @@ export function PrintModal({
               await updateQueueMutation.mutateAsync(updateData);
               await updateQueueMutation.mutateAsync(updateData);
             } else {
             } else {
               // Add-to-queue mode OR edit mode with additional entries
               // Add-to-queue mode OR edit mode with additional entries
-              await addToQueueMutation.mutateAsync(getQueueData(printerId, plateId));
+              const queueData = getQueueData(printerId, plateId);
+              // Apply stagger offset for groups after the first
+              if (useStagger) {
+                const groupIndex = Math.floor(i / scheduleOptions.staggerGroupSize);
+                if (groupIndex > 0) {
+                  const offsetMs = groupIndex * scheduleOptions.staggerIntervalMinutes * 60_000;
+                  queueData.scheduled_time = new Date(staggerBaseTime + offsetMs).toISOString();
+                }
+                // Group 0 with ASAP: no scheduled_time (start immediately)
+                // Group 0 with scheduled: keeps the scheduled_time from getQueueData
+              }
+              await addToQueueMutation.mutateAsync(queueData);
             }
             }
             results.success++;
             results.success++;
           } catch (error) {
           } catch (error) {
@@ -986,6 +1022,8 @@ export function PrintModal({
                 dateFormat={settings?.date_format || 'system'}
                 dateFormat={settings?.date_format || 'system'}
                 timeFormat={settings?.time_format || 'system'}
                 timeFormat={settings?.time_format || 'system'}
                 canControlPrinter={hasPermission('printers:control')}
                 canControlPrinter={hasPermission('printers:control')}
+                showStagger={mode === 'add-to-queue' && assignmentMode === 'printer' && selectedPrinters.length > 1}
+                printerCount={selectedPrinters.length}
               />
               />
             )}
             )}
 
 

+ 10 - 0
frontend/src/components/PrintModal/types.ts

@@ -69,6 +69,9 @@ export interface ScheduleOptions {
   scheduledTime: string;
   scheduledTime: string;
   requirePreviousSuccess: boolean;
   requirePreviousSuccess: boolean;
   autoOffAfter: boolean;
   autoOffAfter: boolean;
+  staggerEnabled: boolean;
+  staggerGroupSize: number;
+  staggerIntervalMinutes: number;
 }
 }
 
 
 /**
 /**
@@ -79,6 +82,9 @@ export const DEFAULT_SCHEDULE_OPTIONS: ScheduleOptions = {
   scheduledTime: '',
   scheduledTime: '',
   requirePreviousSuccess: false,
   requirePreviousSuccess: false,
   autoOffAfter: false,
   autoOffAfter: false,
+  staggerEnabled: false,
+  staggerGroupSize: 2,
+  staggerIntervalMinutes: 5,
 };
 };
 
 
 /**
 /**
@@ -204,4 +210,8 @@ export interface ScheduleOptionsProps {
   timeFormat?: 'system' | '12h' | '24h';
   timeFormat?: 'system' | '12h' | '24h';
   /** Whether the user has permission to control printers (for auto power off) */
   /** Whether the user has permission to control printers (for auto power off) */
   canControlPrinter?: boolean;
   canControlPrinter?: boolean;
+  /** Show stagger options (only when multiple printers selected in queue mode) */
+  showStagger?: boolean;
+  /** Number of selected printers (for stagger preview) */
+  printerCount?: number;
 }
 }

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

@@ -1215,6 +1215,7 @@ export default {
       general: 'Allgemein',
       general: 'Allgemein',
       smartPlugs: 'Smart Plugs',
       smartPlugs: 'Smart Plugs',
       notifications: 'Benachrichtigungen',
       notifications: 'Benachrichtigungen',
+      queue: 'Queue',
       filament: 'Filament',
       filament: 'Filament',
       network: 'Netzwerk',
       network: 'Netzwerk',
       apiKeys: 'API-Schlüssel',
       apiKeys: 'API-Schlüssel',
@@ -1513,6 +1514,12 @@ export default {
     historyRetention: 'Verlaufsaufbewahrung',
     historyRetention: 'Verlaufsaufbewahrung',
     keepSensorHistory: 'Sensorverlauf behalten für',
     keepSensorHistory: 'Sensorverlauf behalten für',
     historyRetentionDescription: 'Ältere Feuchtigkeits- und Temperaturdaten werden automatisch gelöscht',
     historyRetentionDescription: 'Ältere Feuchtigkeits- und Temperaturdaten werden automatisch gelöscht',
+    staggeredStart: 'Staggered Start',
+    staggeredStartDescription: 'Default group size and interval when staggering multi-printer batch starts. Can be overridden per batch in the print modal.',
+    staggerGroupSize: 'Group size',
+    staggerGroupSizeHelp: 'Printers to start simultaneously per group',
+    staggerInterval: 'Interval (minutes)',
+    staggerIntervalHelp: 'Delay between each group starting',
     queueDrying: 'Automatische Trocknung',
     queueDrying: 'Automatische Trocknung',
     queueDryingDescription: 'AMS-Filament automatisch trocknen, wenn der Drucker zwischen Warteschlangen-Drucken im Leerlauf ist. Verwendet den Feuchtigkeitsschwellenwert oben.',
     queueDryingDescription: 'AMS-Filament automatisch trocknen, wenn der Drucker zwischen Warteschlangen-Drucken im Leerlauf ist. Verwendet den Feuchtigkeitsschwellenwert oben.',
     queueDryingEnabled: 'Automatische Trocknung aktivieren',
     queueDryingEnabled: 'Automatische Trocknung aktivieren',
@@ -3108,6 +3115,12 @@ export default {
     insufficientFilamentLine: '{{printer}} - {{slot}}: benötigt {{required}}g, verbleibend {{remaining}}g',
     insufficientFilamentLine: '{{printer}} - {{slot}}: benötigt {{required}}g, verbleibend {{remaining}}g',
     printAnyway: 'Trotzdem drucken',
     printAnyway: 'Trotzdem drucken',
     forceColorMatch: 'Farbe erzwingen',
     forceColorMatch: 'Farbe erzwingen',
+    staggerPrinterStarts: 'Stagger printer starts',
+    staggerGroupSize: 'Group size',
+    staggerInterval: 'Interval (min)',
+    staggerPreview: '{{printers}} printers → {{groups}} groups of {{size}}, starting every {{interval}} min',
+    staggerLastGroup: 'last group: {{count}}',
+    staggerTotal: 'total: {{minutes}} min',
   },
   },
 
 
   // Backup
   // Backup

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

@@ -1215,6 +1215,7 @@ export default {
       general: 'General',
       general: 'General',
       smartPlugs: 'Smart Plugs',
       smartPlugs: 'Smart Plugs',
       notifications: 'Notifications',
       notifications: 'Notifications',
+      queue: 'Queue',
       filament: 'Filament',
       filament: 'Filament',
       network: 'Network',
       network: 'Network',
       apiKeys: 'API Keys',
       apiKeys: 'API Keys',
@@ -1513,6 +1514,12 @@ export default {
     historyRetention: 'History Retention',
     historyRetention: 'History Retention',
     keepSensorHistory: 'Keep sensor history for',
     keepSensorHistory: 'Keep sensor history for',
     historyRetentionDescription: 'Older humidity and temperature data will be automatically deleted',
     historyRetentionDescription: 'Older humidity and temperature data will be automatically deleted',
+    staggeredStart: 'Staggered Start',
+    staggeredStartDescription: 'Default group size and interval when staggering multi-printer batch starts. Can be overridden per batch in the print modal.',
+    staggerGroupSize: 'Group size',
+    staggerGroupSizeHelp: 'Printers to start simultaneously per group',
+    staggerInterval: 'Interval (minutes)',
+    staggerIntervalHelp: 'Delay between each group starting',
     queueDrying: 'Queue Auto-Drying',
     queueDrying: 'Queue Auto-Drying',
     queueDryingDescription: 'Automatically dry AMS filament when printer is idle between queued prints. Uses humidity threshold above to trigger drying.',
     queueDryingDescription: 'Automatically dry AMS filament when printer is idle between queued prints. Uses humidity threshold above to trigger drying.',
     queueDryingEnabled: 'Enable auto-drying',
     queueDryingEnabled: 'Enable auto-drying',
@@ -3112,6 +3119,12 @@ export default {
     insufficientFilamentLine: '{{printer}} - {{slot}}: needs {{required}}g, remaining {{remaining}}g',
     insufficientFilamentLine: '{{printer}} - {{slot}}: needs {{required}}g, remaining {{remaining}}g',
     printAnyway: 'Print anyway',
     printAnyway: 'Print anyway',
     forceColorMatch: 'Force color match',
     forceColorMatch: 'Force color match',
+    staggerPrinterStarts: 'Stagger printer starts',
+    staggerGroupSize: 'Group size',
+    staggerInterval: 'Interval (min)',
+    staggerPreview: '{{printers}} printers → {{groups}} groups of {{size}}, starting every {{interval}} min',
+    staggerLastGroup: 'last group: {{count}}',
+    staggerTotal: 'total: {{minutes}} min',
   },
   },
 
 
   // Backup
   // Backup

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

@@ -1215,6 +1215,7 @@ export default {
       general: 'Général',
       general: 'Général',
       smartPlugs: 'Prises connectées',
       smartPlugs: 'Prises connectées',
       notifications: 'Notifications',
       notifications: 'Notifications',
+      queue: 'Queue',
       filament: 'Filament',
       filament: 'Filament',
       network: 'Réseau',
       network: 'Réseau',
       apiKeys: 'Clés API',
       apiKeys: 'Clés API',
@@ -1513,6 +1514,12 @@ export default {
     historyRetention: 'Rétention d\'historique',
     historyRetention: 'Rétention d\'historique',
     keepSensorHistory: 'Garder l\'historique pendant',
     keepSensorHistory: 'Garder l\'historique pendant',
     historyRetentionDescription: 'Les anciennes données seront supprimées.',
     historyRetentionDescription: 'Les anciennes données seront supprimées.',
+    staggeredStart: 'Staggered Start',
+    staggeredStartDescription: 'Default group size and interval when staggering multi-printer batch starts. Can be overridden per batch in the print modal.',
+    staggerGroupSize: 'Group size',
+    staggerGroupSizeHelp: 'Printers to start simultaneously per group',
+    staggerInterval: 'Interval (minutes)',
+    staggerIntervalHelp: 'Delay between each group starting',
     queueDrying: 'Séchage automatique',
     queueDrying: 'Séchage automatique',
     queueDryingDescription: 'Sécher automatiquement le filament AMS lorsque l\'imprimante est inactive entre les impressions. Utilise le seuil d\'humidité ci-dessus.',
     queueDryingDescription: 'Sécher automatiquement le filament AMS lorsque l\'imprimante est inactive entre les impressions. Utilise le seuil d\'humidité ci-dessus.',
     queueDryingEnabled: 'Activer le séchage automatique',
     queueDryingEnabled: 'Activer le séchage automatique',
@@ -3099,6 +3106,12 @@ export default {
     insufficientFilamentLine: '{{printer}} - {{slot}} : nécessite {{required}}g, restant {{remaining}}g',
     insufficientFilamentLine: '{{printer}} - {{slot}} : nécessite {{required}}g, restant {{remaining}}g',
     printAnyway: 'Imprimer quand même',
     printAnyway: 'Imprimer quand même',
     forceColorMatch: 'Forcer correspondance des couleurs',
     forceColorMatch: 'Forcer correspondance des couleurs',
+    staggerPrinterStarts: 'Stagger printer starts',
+    staggerGroupSize: 'Group size',
+    staggerInterval: 'Interval (min)',
+    staggerPreview: '{{printers}} printers → {{groups}} groups of {{size}}, starting every {{interval}} min',
+    staggerLastGroup: 'last group: {{count}}',
+    staggerTotal: 'total: {{minutes}} min',
   },
   },
 
 
   // Backup
   // Backup

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

@@ -1215,6 +1215,7 @@ export default {
       general: 'Generale',
       general: 'Generale',
       smartPlugs: 'Prese smart',
       smartPlugs: 'Prese smart',
       notifications: 'Notifiche',
       notifications: 'Notifiche',
+      queue: 'Queue',
       filament: 'Filamento',
       filament: 'Filamento',
       network: 'Rete',
       network: 'Rete',
       apiKeys: 'Chiavi API',
       apiKeys: 'Chiavi API',
@@ -1513,6 +1514,12 @@ export default {
     historyRetention: 'Conservazione cronologia',
     historyRetention: 'Conservazione cronologia',
     keepSensorHistory: 'Mantieni cronologia sensori per',
     keepSensorHistory: 'Mantieni cronologia sensori per',
     historyRetentionDescription: 'I dati più vecchi saranno eliminati automaticamente',
     historyRetentionDescription: 'I dati più vecchi saranno eliminati automaticamente',
+    staggeredStart: 'Staggered Start',
+    staggeredStartDescription: 'Default group size and interval when staggering multi-printer batch starts. Can be overridden per batch in the print modal.',
+    staggerGroupSize: 'Group size',
+    staggerGroupSizeHelp: 'Printers to start simultaneously per group',
+    staggerInterval: 'Interval (minutes)',
+    staggerIntervalHelp: 'Delay between each group starting',
     queueDrying: 'Asciugatura automatica',
     queueDrying: 'Asciugatura automatica',
     queueDryingDescription: 'Asciugare automaticamente il filamento AMS quando la stampante è inattiva tra le stampe in coda. Usa la soglia di umidità sopra.',
     queueDryingDescription: 'Asciugare automaticamente il filamento AMS quando la stampante è inattiva tra le stampe in coda. Usa la soglia di umidità sopra.',
     queueDryingEnabled: 'Abilita asciugatura automatica',
     queueDryingEnabled: 'Abilita asciugatura automatica',
@@ -3098,6 +3105,12 @@ export default {
     insufficientFilamentLine: '{{printer}} - {{slot}}: necessita di {{required}}g, rimanenti {{remaining}}g',
     insufficientFilamentLine: '{{printer}} - {{slot}}: necessita di {{required}}g, rimanenti {{remaining}}g',
     printAnyway: 'Stampa comunque',
     printAnyway: 'Stampa comunque',
     forceColorMatch: 'Forza corrispondenza colore',
     forceColorMatch: 'Forza corrispondenza colore',
+    staggerPrinterStarts: 'Stagger printer starts',
+    staggerGroupSize: 'Group size',
+    staggerInterval: 'Interval (min)',
+    staggerPreview: '{{printers}} printers → {{groups}} groups of {{size}}, starting every {{interval}} min',
+    staggerLastGroup: 'last group: {{count}}',
+    staggerTotal: 'total: {{minutes}} min',
   },
   },
 
 
   // Backup
   // Backup

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

@@ -1214,6 +1214,7 @@ export default {
       general: '一般',
       general: '一般',
       smartPlugs: 'スマートプラグ',
       smartPlugs: 'スマートプラグ',
       notifications: '通知',
       notifications: '通知',
+      queue: 'Queue',
       filament: 'フィラメント',
       filament: 'フィラメント',
       network: 'ネットワーク',
       network: 'ネットワーク',
       apiKeys: 'APIキー',
       apiKeys: 'APIキー',
@@ -1512,6 +1513,12 @@ export default {
     historyRetention: '履歴の保持',
     historyRetention: '履歴の保持',
     keepSensorHistory: 'センサー履歴の保持期間',
     keepSensorHistory: 'センサー履歴の保持期間',
     historyRetentionDescription: '古い湿度と温度データは自動的に削除されます',
     historyRetentionDescription: '古い湿度と温度データは自動的に削除されます',
+    staggeredStart: 'Staggered Start',
+    staggeredStartDescription: 'Default group size and interval when staggering multi-printer batch starts. Can be overridden per batch in the print modal.',
+    staggerGroupSize: 'Group size',
+    staggerGroupSizeHelp: 'Printers to start simultaneously per group',
+    staggerInterval: 'Interval (minutes)',
+    staggerIntervalHelp: 'Delay between each group starting',
     queueDrying: '自動乾燥',
     queueDrying: '自動乾燥',
     queueDryingDescription: 'キュー印刷の合間にプリンターがアイドル状態の時、AMSフィラメントを自動的に乾燥します。上記の湿度しきい値を使用します。',
     queueDryingDescription: 'キュー印刷の合間にプリンターがアイドル状態の時、AMSフィラメントを自動的に乾燥します。上記の湿度しきい値を使用します。',
     queueDryingEnabled: '自動乾燥を有効にする',
     queueDryingEnabled: '自動乾燥を有効にする',
@@ -3111,6 +3118,12 @@ export default {
     insufficientFilamentLine: '{{printer}} - {{slot}}: 必要 {{required}}g、残り {{remaining}}g',
     insufficientFilamentLine: '{{printer}} - {{slot}}: 必要 {{required}}g、残り {{remaining}}g',
     printAnyway: 'それでも印刷',
     printAnyway: 'それでも印刷',
     forceColorMatch: 'カラーマッチを強制',
     forceColorMatch: 'カラーマッチを強制',
+    staggerPrinterStarts: 'Stagger printer starts',
+    staggerGroupSize: 'Group size',
+    staggerInterval: 'Interval (min)',
+    staggerPreview: '{{printers}} printers → {{groups}} groups of {{size}}, starting every {{interval}} min',
+    staggerLastGroup: 'last group: {{count}}',
+    staggerTotal: 'total: {{minutes}} min',
   },
   },
 
 
   // Backup
   // Backup

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

@@ -1215,6 +1215,7 @@ export default {
       general: 'Geral',
       general: 'Geral',
       smartPlugs: 'Tomadas Inteligentes',
       smartPlugs: 'Tomadas Inteligentes',
       notifications: 'Notificações',
       notifications: 'Notificações',
+      queue: 'Queue',
       filament: 'Filamento',
       filament: 'Filamento',
       network: 'Rede',
       network: 'Rede',
       apiKeys: 'Chaves API',
       apiKeys: 'Chaves API',
@@ -1513,6 +1514,12 @@ export default {
     historyRetention: 'Retenção de Histórico',
     historyRetention: 'Retenção de Histórico',
     keepSensorHistory: 'Manter histórico do sensor por',
     keepSensorHistory: 'Manter histórico do sensor por',
     historyRetentionDescription: 'Dados antigos de umidade e temperatura serão automaticamente excluídos',
     historyRetentionDescription: 'Dados antigos de umidade e temperatura serão automaticamente excluídos',
+    staggeredStart: 'Staggered Start',
+    staggeredStartDescription: 'Default group size and interval when staggering multi-printer batch starts. Can be overridden per batch in the print modal.',
+    staggerGroupSize: 'Group size',
+    staggerGroupSizeHelp: 'Printers to start simultaneously per group',
+    staggerInterval: 'Interval (minutes)',
+    staggerIntervalHelp: 'Delay between each group starting',
     queueDrying: 'Secagem Automática',
     queueDrying: 'Secagem Automática',
     queueDryingDescription: 'Secar automaticamente o filamento AMS quando a impressora estiver ociosa entre impressões na fila. Usa o limite de umidade acima.',
     queueDryingDescription: 'Secar automaticamente o filamento AMS quando a impressora estiver ociosa entre impressões na fila. Usa o limite de umidade acima.',
     queueDryingEnabled: 'Ativar secagem automática',
     queueDryingEnabled: 'Ativar secagem automática',
@@ -3098,6 +3105,12 @@ export default {
     insufficientFilamentLine: '{{printer}} - {{slot}}: necessário {{required}}g, restante {{remaining}}g',
     insufficientFilamentLine: '{{printer}} - {{slot}}: necessário {{required}}g, restante {{remaining}}g',
     printAnyway: 'Imprimir mesmo assim',
     printAnyway: 'Imprimir mesmo assim',
     forceColorMatch: 'Forçar correspondência de cor',
     forceColorMatch: 'Forçar correspondência de cor',
+    staggerPrinterStarts: 'Stagger printer starts',
+    staggerGroupSize: 'Group size',
+    staggerInterval: 'Interval (min)',
+    staggerPreview: '{{printers}} printers → {{groups}} groups of {{size}}, starting every {{interval}} min',
+    staggerLastGroup: 'last group: {{count}}',
+    staggerTotal: 'total: {{minutes}} min',
   },
   },
 
 
   // Backup
   // Backup

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

@@ -1215,6 +1215,7 @@ export default {
       general: '通用',
       general: '通用',
       smartPlugs: '智能插座',
       smartPlugs: '智能插座',
       notifications: '通知',
       notifications: '通知',
+      queue: 'Queue',
       filament: '耗材',
       filament: '耗材',
       network: '网络',
       network: '网络',
       apiKeys: 'API 密钥',
       apiKeys: 'API 密钥',
@@ -1513,6 +1514,12 @@ export default {
     historyRetention: '历史保留',
     historyRetention: '历史保留',
     keepSensorHistory: '保留传感器历史',
     keepSensorHistory: '保留传感器历史',
     historyRetentionDescription: '较旧的湿度和温度数据将被自动删除',
     historyRetentionDescription: '较旧的湿度和温度数据将被自动删除',
+    staggeredStart: 'Staggered Start',
+    staggeredStartDescription: 'Default group size and interval when staggering multi-printer batch starts. Can be overridden per batch in the print modal.',
+    staggerGroupSize: 'Group size',
+    staggerGroupSizeHelp: 'Printers to start simultaneously per group',
+    staggerInterval: 'Interval (minutes)',
+    staggerIntervalHelp: 'Delay between each group starting',
     queueDrying: '自动干燥',
     queueDrying: '自动干燥',
     queueDryingDescription: '在队列打印之间,打印机空闲时自动干燥AMS耗材。使用上方的湿度阈值触发干燥。',
     queueDryingDescription: '在队列打印之间,打印机空闲时自动干燥AMS耗材。使用上方的湿度阈值触发干燥。',
     queueDryingEnabled: '启用自动干燥',
     queueDryingEnabled: '启用自动干燥',
@@ -3098,6 +3105,12 @@ export default {
     insufficientFilamentLine: '{{printer}} - {{slot}}:需要 {{required}}g,剩余 {{remaining}}g',
     insufficientFilamentLine: '{{printer}} - {{slot}}:需要 {{required}}g,剩余 {{remaining}}g',
     printAnyway: '仍然打印',
     printAnyway: '仍然打印',
     forceColorMatch: '强制颜色匹配',
     forceColorMatch: '强制颜色匹配',
+    staggerPrinterStarts: 'Stagger printer starts',
+    staggerGroupSize: 'Group size',
+    staggerInterval: 'Interval (min)',
+    staggerPreview: '{{printers}} printers → {{groups}} groups of {{size}}, starting every {{interval}} min',
+    staggerLastGroup: 'last group: {{count}}',
+    staggerTotal: 'total: {{minutes}} min',
   },
   },
 
 
   // Backup
   // Backup

+ 232 - 177
frontend/src/pages/SettingsPage.tsx

@@ -1,5 +1,5 @@
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
-import { Loader2, Plus, Plug, AlertTriangle, RotateCcw, Bell, Download, RefreshCw, ExternalLink, Globe, Droplets, Thermometer, FileText, Edit2, Send, CheckCircle, XCircle, History, Trash2, Zap, TrendingUp, Calendar, DollarSign, Power, PowerOff, Key, Copy, Database, X, Shield, Printer, Cylinder, Wifi, Home, Video, Users, Lock, Unlock, ChevronDown, Save, Mail, Flame } from 'lucide-react';
+import { Loader2, Plus, Plug, AlertTriangle, RotateCcw, Bell, Download, RefreshCw, ExternalLink, Globe, Droplets, Thermometer, FileText, Edit2, Send, CheckCircle, XCircle, History, Trash2, Zap, TrendingUp, Calendar, DollarSign, Power, PowerOff, Key, Copy, Database, X, Shield, Printer, Cylinder, Wifi, Home, Video, Users, Lock, Unlock, ChevronDown, Save, Mail, Flame, Layers, ListOrdered } from 'lucide-react';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 import { useNavigate, useSearchParams } from 'react-router-dom';
 import { useNavigate, useSearchParams } from 'react-router-dom';
 import { api } from '../api/client';
 import { api } from '../api/client';
@@ -34,7 +34,7 @@ import { useTheme, type ThemeStyle, type DarkBackground, type LightBackground, t
 import { useState, useEffect, useRef, useCallback } from 'react';
 import { useState, useEffect, useRef, useCallback } from 'react';
 import { Palette } from 'lucide-react';
 import { Palette } from 'lucide-react';
 
 
-const validTabs = ['general', 'network', 'plugs', 'notifications', 'filament', 'apikeys', 'virtual-printer', 'users', 'backup'] as const;
+const validTabs = ['general', 'plugs', 'notifications', 'queue', 'filament', 'network', 'apikeys', 'virtual-printer', 'users', 'backup'] as const;
 type TabType = typeof validTabs[number];
 type TabType = typeof validTabs[number];
 type UsersSubTab = 'users' | 'email';
 type UsersSubTab = 'users' | 'email';
 
 
@@ -770,7 +770,9 @@ export function SettingsPage() {
       (settings.preferred_slicer ?? 'bambu_studio') !== (localSettings.preferred_slicer ?? 'bambu_studio') ||
       (settings.preferred_slicer ?? 'bambu_studio') !== (localSettings.preferred_slicer ?? 'bambu_studio') ||
       settings.prometheus_enabled !== localSettings.prometheus_enabled ||
       settings.prometheus_enabled !== localSettings.prometheus_enabled ||
       settings.prometheus_token !== localSettings.prometheus_token ||
       settings.prometheus_token !== localSettings.prometheus_token ||
-      (settings.user_notifications_enabled ?? true) !== (localSettings.user_notifications_enabled ?? true);
+      (settings.user_notifications_enabled ?? true) !== (localSettings.user_notifications_enabled ?? true) ||
+      (settings.stagger_group_size ?? 2) !== (localSettings.stagger_group_size ?? 2) ||
+      (settings.stagger_interval_minutes ?? 5) !== (localSettings.stagger_interval_minutes ?? 5);
 
 
     if (!hasChanges) {
     if (!hasChanges) {
       return;
       return;
@@ -843,6 +845,8 @@ export function SettingsPage() {
         prometheus_enabled: localSettings.prometheus_enabled,
         prometheus_enabled: localSettings.prometheus_enabled,
         prometheus_token: localSettings.prometheus_token,
         prometheus_token: localSettings.prometheus_token,
         user_notifications_enabled: localSettings.user_notifications_enabled,
         user_notifications_enabled: localSettings.user_notifications_enabled,
+        stagger_group_size: localSettings.stagger_group_size,
+        stagger_interval_minutes: localSettings.stagger_interval_minutes,
       };
       };
       updateMutation.mutate(settingsToSave);
       updateMutation.mutate(settingsToSave);
     }, 500);
     }, 500);
@@ -989,6 +993,17 @@ export function SettingsPage() {
             </span>
             </span>
           )}
           )}
         </button>
         </button>
+        <button
+          onClick={() => handleTabChange('queue')}
+          className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px flex items-center gap-2 ${
+            activeTab === 'queue'
+              ? 'text-bambu-green border-bambu-green'
+              : 'text-bambu-gray hover:text-gray-900 dark:hover:text-white border-transparent'
+          }`}
+        >
+          <ListOrdered className="w-4 h-4" />
+          {t('settings.tabs.queue', 'Queue')}
+        </button>
         <button
         <button
           onClick={() => handleTabChange('filament')}
           onClick={() => handleTabChange('filament')}
           className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px flex items-center gap-2 ${
           className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px flex items-center gap-2 ${
@@ -3319,6 +3334,220 @@ export function SettingsPage() {
       )}
       )}
 
 
       {/* Filament Tab */}
       {/* Filament Tab */}
+      {/* Queue Tab */}
+      {activeTab === 'queue' && localSettings && (
+        <div className="max-w-2xl space-y-6">
+          {/* Staggered Batch Start */}
+          <Card>
+            <CardHeader>
+              <h3 className="text-base font-semibold text-white flex items-center gap-2">
+                <Layers className="w-4 h-4 text-bambu-green" />
+                {t('settings.staggeredStart', 'Staggered Start')}
+              </h3>
+            </CardHeader>
+            <CardContent className="space-y-4">
+              <p className="text-xs text-bambu-gray">
+                {t('settings.staggeredStartDescription', 'Default group size and interval when staggering multi-printer batch starts. Can be overridden per batch in the print modal.')}
+              </p>
+              <div className="flex gap-4">
+                <div className="flex-1">
+                  <label className="block text-xs text-bambu-gray mb-1">
+                    {t('settings.staggerGroupSize', 'Group size')}
+                  </label>
+                  <input
+                    type="number"
+                    min={1}
+                    max={50}
+                    value={localSettings.stagger_group_size ?? 2}
+                    onChange={(e) => updateSetting('stagger_group_size', Math.max(1, Math.min(50, parseInt(e.target.value) || 1)))}
+                    className="w-full 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"
+                  />
+                  <p className="text-xs text-bambu-gray mt-1">
+                    {t('settings.staggerGroupSizeHelp', 'Printers to start simultaneously per group')}
+                  </p>
+                </div>
+                <div className="flex-1">
+                  <label className="block text-xs text-bambu-gray mb-1">
+                    {t('settings.staggerInterval', 'Interval (minutes)')}
+                  </label>
+                  <input
+                    type="number"
+                    min={1}
+                    max={60}
+                    value={localSettings.stagger_interval_minutes ?? 5}
+                    onChange={(e) => updateSetting('stagger_interval_minutes', Math.max(1, Math.min(60, parseInt(e.target.value) || 1)))}
+                    className="w-full 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"
+                  />
+                  <p className="text-xs text-bambu-gray mt-1">
+                    {t('settings.staggerIntervalHelp', 'Delay between each group starting')}
+                  </p>
+                </div>
+              </div>
+            </CardContent>
+          </Card>
+
+          {/* Auto-Drying */}
+          <Card>
+            <CardHeader>
+              <h3 className="text-base font-semibold text-white flex items-center gap-2">
+                <Flame className="w-4 h-4 text-amber-400" />
+                {t('settings.queueDrying')}
+              </h3>
+            </CardHeader>
+            <CardContent className="space-y-4">
+              <p className="text-xs text-bambu-gray">
+                {t('settings.queueDryingDescription')}
+              </p>
+              <div className="flex items-center justify-between">
+                <div>
+                  <label className="block text-sm text-white">
+                    {t('settings.queueDryingEnabled')}
+                  </label>
+                  <p className="text-xs text-bambu-gray mt-0.5">
+                    {t('settings.queueDryingEnabledDescription')}
+                  </p>
+                </div>
+                <label className="relative inline-flex items-center cursor-pointer">
+                  <input
+                    type="checkbox"
+                    checked={localSettings.queue_drying_enabled ?? false}
+                    onChange={(e) => updateSetting('queue_drying_enabled', e.target.checked)}
+                    className="sr-only peer"
+                  />
+                  <div className="w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green"></div>
+                </label>
+              </div>
+              {localSettings.queue_drying_enabled && (
+                <div className="flex items-center justify-between">
+                  <div>
+                    <label className="block text-sm text-white">
+                      {t('settings.queueDryingBlock')}
+                    </label>
+                    <p className="text-xs text-bambu-gray mt-0.5">
+                      {t('settings.queueDryingBlockDescription')}
+                    </p>
+                  </div>
+                  <label className="relative inline-flex items-center cursor-pointer">
+                    <input
+                      type="checkbox"
+                      checked={localSettings.queue_drying_block ?? false}
+                      onChange={(e) => updateSetting('queue_drying_block', e.target.checked)}
+                      className="sr-only peer"
+                    />
+                    <div className="w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-amber-500"></div>
+                  </label>
+                </div>
+              )}
+              <div className="flex items-center justify-between">
+                <div>
+                  <label className="block text-sm text-white">
+                    {t('settings.ambientDryingEnabled')}
+                  </label>
+                  <p className="text-xs text-bambu-gray mt-0.5">
+                    {t('settings.ambientDryingEnabledDescription')}
+                  </p>
+                </div>
+                <label className="relative inline-flex items-center cursor-pointer">
+                  <input
+                    type="checkbox"
+                    checked={localSettings.ambient_drying_enabled ?? false}
+                    onChange={(e) => updateSetting('ambient_drying_enabled', e.target.checked)}
+                    className="sr-only peer"
+                  />
+                  <div className="w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green"></div>
+                </label>
+              </div>
+              {/* Drying Presets Table */}
+              <div className="space-y-2">
+                <p className="text-sm text-white font-medium">{t('settings.dryingPresets')}</p>
+                <p className="text-xs text-bambu-gray">{t('settings.dryingPresetsDescription')}</p>
+                <div className="overflow-x-auto">
+                  <table className="w-full text-xs">
+                    <thead>
+                      <tr className="text-bambu-gray border-b border-bambu-dark-tertiary">
+                        <th className="text-left py-1.5">{t('settings.dryingFilament')}</th>
+                        <th className="text-center py-1.5" colSpan={2}>AMS 2 Pro</th>
+                        <th className="text-center py-1.5" colSpan={2}>AMS-HT</th>
+                      </tr>
+                    </thead>
+                    <tbody>
+                      {(() => {
+                        const defaults: Record<string, { n3f: number; n3s: number; n3f_hours: number; n3s_hours: number }> = {
+                          PLA: { n3f: 45, n3s: 45, n3f_hours: 12, n3s_hours: 12 },
+                          PETG: { n3f: 65, n3s: 65, n3f_hours: 12, n3s_hours: 12 },
+                          TPU: { n3f: 65, n3s: 75, n3f_hours: 12, n3s_hours: 18 },
+                          ABS: { n3f: 65, n3s: 80, n3f_hours: 12, n3s_hours: 8 },
+                          ASA: { n3f: 65, n3s: 80, n3f_hours: 12, n3s_hours: 8 },
+                          PA: { n3f: 65, n3s: 85, n3f_hours: 12, n3s_hours: 12 },
+                          PC: { n3f: 65, n3s: 80, n3f_hours: 12, n3s_hours: 8 },
+                          PVA: { n3f: 65, n3s: 85, n3f_hours: 12, n3s_hours: 18 },
+                        };
+                        let presets = { ...defaults };
+                        try {
+                          if (localSettings.drying_presets) {
+                            const parsed = JSON.parse(localSettings.drying_presets);
+                            if (typeof parsed === 'object' && parsed !== null) {
+                              presets = { ...defaults, ...parsed };
+                            }
+                          }
+                        } catch { /* use defaults */ }
+
+                        const updatePreset = (fil: string, key: string, value: number) => {
+                          const updated = { ...presets, [fil]: { ...presets[fil], [key]: value } };
+                          updateSetting('drying_presets', JSON.stringify(updated));
+                        };
+
+                        return Object.entries(presets).map(([fil, preset]) => (
+                          <tr key={fil} className="border-b border-bambu-dark-tertiary/50">
+                            <td className="py-1.5 pr-2 text-white font-medium">{fil}</td>
+                            <td className="py-1 px-1">
+                              <div className="flex items-center justify-end gap-1">
+                                <input type="number" min={30} max={65} value={preset.n3f}
+                                  onChange={e => updatePreset(fil, 'n3f', Math.max(1, parseInt(e.target.value) || 0))}
+                                  className="w-14 px-1.5 py-1 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-center text-xs focus:border-amber-500/50 focus:outline-none [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
+                                />
+                                <span className="text-bambu-gray">°C</span>
+                              </div>
+                            </td>
+                            <td className="py-1 px-1">
+                              <div className="flex items-center gap-1">
+                                <input type="number" min={1} max={24} value={preset.n3f_hours}
+                                  onChange={e => updatePreset(fil, 'n3f_hours', Math.max(1, parseInt(e.target.value) || 0))}
+                                  className="w-14 px-1.5 py-1 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-center text-xs focus:border-amber-500/50 focus:outline-none [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
+                                />
+                                <span className="text-bambu-gray">h</span>
+                              </div>
+                            </td>
+                            <td className="py-1 px-1">
+                              <div className="flex items-center justify-end gap-1">
+                                <input type="number" min={30} max={85} value={preset.n3s}
+                                  onChange={e => updatePreset(fil, 'n3s', Math.max(1, parseInt(e.target.value) || 0))}
+                                  className="w-14 px-1.5 py-1 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-center text-xs focus:border-amber-500/50 focus:outline-none [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
+                                />
+                                <span className="text-bambu-gray">°C</span>
+                              </div>
+                            </td>
+                            <td className="py-1 px-1">
+                              <div className="flex items-center gap-1">
+                                <input type="number" min={1} max={24} value={preset.n3s_hours}
+                                  onChange={e => updatePreset(fil, 'n3s_hours', Math.max(1, parseInt(e.target.value) || 0))}
+                                  className="w-14 px-1.5 py-1 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-center text-xs focus:border-amber-500/50 focus:outline-none [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
+                                />
+                                <span className="text-bambu-gray">h</span>
+                              </div>
+                            </td>
+                          </tr>
+                        ));
+                      })()}
+                    </tbody>
+                  </table>
+                </div>
+              </div>
+            </CardContent>
+          </Card>
+        </div>
+      )}
+
       {activeTab === 'filament' && localSettings && (
       {activeTab === 'filament' && localSettings && (
         <>
         <>
         <div className="flex flex-col lg:flex-row gap-6 lg:gap-8">
         <div className="flex flex-col lg:flex-row gap-6 lg:gap-8">
@@ -3482,180 +3711,6 @@ export function SettingsPage() {
                   </p>
                   </p>
                 </div>
                 </div>
 
 
-                {/* Queue Auto-Drying */}
-                <div className="space-y-3 pt-4 border-t border-bambu-dark-tertiary">
-                  <div className="flex items-center gap-2 text-white">
-                    <Flame className="w-4 h-4 text-amber-400" />
-                    <span className="font-medium">{t('settings.queueDrying')}</span>
-                  </div>
-                  <p className="text-xs text-bambu-gray">
-                    {t('settings.queueDryingDescription')}
-                  </p>
-                  <div className="flex items-center justify-between">
-                    <div>
-                      <label className="block text-sm text-white">
-                        {t('settings.queueDryingEnabled')}
-                      </label>
-                      <p className="text-xs text-bambu-gray mt-0.5">
-                        {t('settings.queueDryingEnabledDescription')}
-                      </p>
-                    </div>
-                    <label className="relative inline-flex items-center cursor-pointer">
-                      <input
-                        type="checkbox"
-                        checked={localSettings.queue_drying_enabled ?? false}
-                        onChange={(e) => updateSetting('queue_drying_enabled', e.target.checked)}
-                        className="sr-only peer"
-                      />
-                      <div className="w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green"></div>
-                    </label>
-                  </div>
-                  {localSettings.queue_drying_enabled && (
-                    <>
-                      <div className="flex items-center justify-between">
-                        <div>
-                          <label className="block text-sm text-white">
-                            {t('settings.queueDryingBlock')}
-                          </label>
-                          <p className="text-xs text-bambu-gray mt-0.5">
-                            {t('settings.queueDryingBlockDescription')}
-                          </p>
-                        </div>
-                        <label className="relative inline-flex items-center cursor-pointer">
-                          <input
-                            type="checkbox"
-                            checked={localSettings.queue_drying_block ?? false}
-                            onChange={(e) => updateSetting('queue_drying_block', e.target.checked)}
-                            className="sr-only peer"
-                          />
-                          <div className="w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-amber-500"></div>
-                        </label>
-                      </div>
-                    </>
-                  )}
-                  <div className="flex items-center justify-between">
-                    <div>
-                      <label className="block text-sm text-white">
-                        {t('settings.ambientDryingEnabled')}
-                      </label>
-                      <p className="text-xs text-bambu-gray mt-0.5">
-                        {t('settings.ambientDryingEnabledDescription')}
-                      </p>
-                    </div>
-                    <label className="relative inline-flex items-center cursor-pointer">
-                      <input
-                        type="checkbox"
-                        checked={localSettings.ambient_drying_enabled ?? false}
-                        onChange={(e) => updateSetting('ambient_drying_enabled', e.target.checked)}
-                        className="sr-only peer"
-                      />
-                      <div className="w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green"></div>
-                    </label>
-                  </div>
-                  {/* Drying Presets Table — always visible since manual drying also uses these */}
-                  <div className="space-y-2">
-                    <p className="text-sm text-white font-medium">{t('settings.dryingPresets')}</p>
-                    <p className="text-xs text-bambu-gray">{t('settings.dryingPresetsDescription')}</p>
-                    <div className="overflow-x-auto">
-                      <table className="w-full text-xs">
-                        <thead>
-                          <tr className="text-bambu-gray border-b border-bambu-dark-tertiary">
-                            <th className="text-left py-1.5">{t('settings.dryingFilament')}</th>
-                            <th className="text-center py-1.5" colSpan={2}>AMS 2 Pro</th>
-                            <th className="text-center py-1.5" colSpan={2}>AMS-HT</th>
-                          </tr>
-                        </thead>
-                        <tbody>
-                          {(() => {
-                            const defaults: Record<string, { n3f: number; n3s: number; n3f_hours: number; n3s_hours: number }> = {
-                              PLA: { n3f: 45, n3s: 45, n3f_hours: 12, n3s_hours: 12 },
-                              PETG: { n3f: 65, n3s: 65, n3f_hours: 12, n3s_hours: 12 },
-                              TPU: { n3f: 65, n3s: 75, n3f_hours: 12, n3s_hours: 18 },
-                              ABS: { n3f: 65, n3s: 80, n3f_hours: 12, n3s_hours: 8 },
-                              ASA: { n3f: 65, n3s: 80, n3f_hours: 12, n3s_hours: 8 },
-                              PA: { n3f: 65, n3s: 85, n3f_hours: 12, n3s_hours: 12 },
-                              PC: { n3f: 65, n3s: 80, n3f_hours: 12, n3s_hours: 8 },
-                              PVA: { n3f: 65, n3s: 85, n3f_hours: 12, n3s_hours: 18 },
-                            };
-                            let presets = { ...defaults };
-                            try {
-                              if (localSettings.drying_presets) {
-                                const parsed = JSON.parse(localSettings.drying_presets);
-                                if (typeof parsed === 'object' && parsed !== null) {
-                                  presets = { ...defaults, ...parsed };
-                                }
-                              }
-                            } catch { /* use defaults */ }
-
-                            const updatePreset = (fil: string, key: string, value: number) => {
-                              const updated = { ...presets, [fil]: { ...presets[fil], [key]: value } };
-                              updateSetting('drying_presets', JSON.stringify(updated));
-                            };
-
-                            return Object.entries(presets).map(([fil, preset]) => (
-                              <tr key={fil} className="border-b border-bambu-dark-tertiary/50">
-                                <td className="py-1.5 pr-2 text-white font-medium">{fil}</td>
-                                <td className="py-1 px-1">
-                                  <div className="flex items-center justify-end gap-1">
-                                    <input
-                                      type="number"
-                                      min={30}
-                                      max={65}
-                                      value={preset.n3f}
-                                      onChange={e => updatePreset(fil, 'n3f', Math.max(1, parseInt(e.target.value) || 0))}
-                                      className="w-14 px-1.5 py-1 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-center text-xs focus:border-amber-500/50 focus:outline-none [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
-                                    />
-                                    <span className="text-bambu-gray">°C</span>
-                                  </div>
-                                </td>
-                                <td className="py-1 px-1">
-                                  <div className="flex items-center gap-1">
-                                    <input
-                                      type="number"
-                                      min={1}
-                                      max={24}
-                                      value={preset.n3f_hours}
-                                      onChange={e => updatePreset(fil, 'n3f_hours', Math.max(1, parseInt(e.target.value) || 0))}
-                                      className="w-14 px-1.5 py-1 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-center text-xs focus:border-amber-500/50 focus:outline-none [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
-                                    />
-                                    <span className="text-bambu-gray">h</span>
-                                  </div>
-                                </td>
-                                <td className="py-1 px-1">
-                                  <div className="flex items-center justify-end gap-1">
-                                    <input
-                                      type="number"
-                                      min={30}
-                                      max={85}
-                                      value={preset.n3s}
-                                      onChange={e => updatePreset(fil, 'n3s', Math.max(1, parseInt(e.target.value) || 0))}
-                                      className="w-14 px-1.5 py-1 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-center text-xs focus:border-amber-500/50 focus:outline-none [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
-                                    />
-                                    <span className="text-bambu-gray">°C</span>
-                                  </div>
-                                </td>
-                                <td className="py-1 px-1">
-                                  <div className="flex items-center gap-1">
-                                    <input
-                                      type="number"
-                                      min={1}
-                                      max={24}
-                                      value={preset.n3s_hours}
-                                      onChange={e => updatePreset(fil, 'n3s_hours', Math.max(1, parseInt(e.target.value) || 0))}
-                                      className="w-14 px-1.5 py-1 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-center text-xs focus:border-amber-500/50 focus:outline-none [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
-                                    />
-                                    <span className="text-bambu-gray">h</span>
-                                  </div>
-                                </td>
-                              </tr>
-                            ));
-                          })()}
-                        </tbody>
-                      </table>
-                    </div>
-                  </div>
-                </div>
-
                 {/* Per-Printer Mapping Default */}
                 {/* Per-Printer Mapping Default */}
                 <div className="space-y-3 pt-4 border-t border-bambu-dark-tertiary">
                 <div className="space-y-3 pt-4 border-t border-bambu-dark-tertiary">
                   <div className="flex items-center gap-2 text-white">
                   <div className="flex items-center gap-2 text-white">

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
static/assets/index-Dn-c66XQ.js


+ 1 - 1
static/index.html

@@ -23,7 +23,7 @@
 
 
     <!-- Splash screens for iOS -->
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-CdG7loGy.js"></script>
+    <script type="module" crossorigin src="/assets/index-Dn-c66XQ.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-C0h3VoP7.css">
     <link rel="stylesheet" crossorigin href="/assets/index-C0h3VoP7.css">
   </head>
   </head>
   <body>
   <body>

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است