feat(printers): sort by ETA (#1609)

Adds an "ETA" option to the Printers page sort dropdown.
  Sorts the fleet by remaining print time so the printer that's
  finishing next sits at the top — useful for staging the next
  job's filament ahead of time.

  Tier ordering:
  - Tier 0: currently printing with remaining_time > 0
    (sorted ascending by remaining minutes)
  - Tier 1: currently printing without an ETA yet
    (post-start_print window before total time is known)
  - Tier 2: idle / finished
  - Tier 3: offline
  Name tiebreaker within every tier. The asc / desc arrow
  still applies after tiers resolve.

  Data source is the cached remaining_time (minutes) on the
  per-printer status query — the same field the per-card ETA
  label and the fleet "next finish" badge already read from.
  No new backend round-trip; the sort consumes data that's
  already in React Query cache and updated on every WebSocket
  push.

  groupedPrinters returns null for ETA too — every printer's
  ETA is unique so section headers would just produce a header
  per row. Flat list, like the existing name sort.
This commit is contained in:
maziggy
2026-06-21 14:22:20 +02:00
parent 166e9f9ef2
commit c930c0e80d
15 changed files with 51 additions and 15 deletions
+2
View File
File diff suppressed because one or more lines are too long
+1
View File
@@ -182,6 +182,7 @@ export default {
status: 'Status',
model: 'Modell',
location: 'Standort',
eta: 'Restzeit',
ascending: 'Aufsteigend sortieren',
descending: 'Absteigend sortieren',
},
+1
View File
@@ -182,6 +182,7 @@ export default {
status: 'Status',
model: 'Model',
location: 'Location',
eta: 'ETA',
ascending: 'Sort ascending',
descending: 'Sort descending',
},
+1
View File
@@ -182,6 +182,7 @@ export default {
status: 'Estado',
model: 'Modelo',
location: 'Ubicación',
eta: 'Tiempo restante',
ascending: 'Orden ascendente',
descending: 'Orden descendente',
},
+1
View File
@@ -182,6 +182,7 @@ export default {
status: 'Statut',
model: 'Modèle',
location: 'Emplacement',
eta: 'Temps restant',
ascending: 'Tri croissant',
descending: 'Tri décroissant',
},
+1
View File
@@ -182,6 +182,7 @@ export default {
status: 'Stato',
model: 'Modello',
location: 'Posizione',
eta: 'Tempo rimanente',
ascending: 'Ordina crescente',
descending: 'Ordina decrescente',
},
+1
View File
@@ -181,6 +181,7 @@ export default {
status: 'ステータス',
model: 'モデル',
location: 'ロケーション',
eta: '残り時間',
ascending: '昇順で並べ替え',
descending: '降順で並べ替え',
},
+1
View File
@@ -170,6 +170,7 @@ export default {
status: '상태',
model: '모델',
location: '위치',
eta: '남은 시간',
ascending: '오름차순 정렬',
descending: '내림차순 정렬'
},
+1
View File
@@ -182,6 +182,7 @@ export default {
status: 'Status',
model: 'Modelo',
location: 'Localização',
eta: 'Tempo restante',
ascending: 'Ordem crescente',
descending: 'Ordem decrescente',
},
+1
View File
@@ -182,6 +182,7 @@ export default {
status: 'Durum',
model: 'Model',
location: 'Konum',
eta: 'Kalan süre',
ascending: 'Artan sırala',
descending: 'Azalan sırala',
},
+1
View File
@@ -182,6 +182,7 @@ export default {
status: '状态',
model: '型号',
location: '位置',
eta: '剩余时间',
ascending: '升序排列',
descending: '降序排列',
},
+1
View File
@@ -182,6 +182,7 @@ export default {
status: '狀態',
model: '型號',
location: '位置',
eta: '剩餘時間',
ascending: '升序排列',
descending: '降序排列',
},
+25 -2
View File
@@ -1095,7 +1095,7 @@ function StatusSummaryBar({ printers }: { printers: Printer[] | undefined }) {
);
}
type SortOption = 'name' | 'status' | 'model' | 'location';
type SortOption = 'name' | 'status' | 'model' | 'location' | 'eta';
type ViewMode = 'expanded' | 'compact';
type ToolbarDropdownOption<T extends string> = {
@@ -8014,6 +8014,28 @@ export function PrintersPage() {
return getPriority(statusA) - getPriority(statusB);
});
break;
case 'eta':
sorted.sort((a, b) => {
const statusA = queryClient.getQueryData<{ connected: boolean; state: string | null; remaining_time: number | null }>(['printerStatus', a.id]);
const statusB = queryClient.getQueryData<{ connected: boolean; state: string | null; remaining_time: number | null }>(['printerStatus', b.id]);
const tier = (s: typeof statusA) => {
if (!s?.connected) return 3; // offline last
if (s.state === 'RUNNING' && s.remaining_time != null && s.remaining_time > 0) return 0; // printing with ETA
if (s.state === 'RUNNING') return 1; // printing without ETA
return 2; // idle
};
const ta = tier(statusA);
const tb = tier(statusB);
if (ta !== tb) return ta - tb;
if (ta === 0) {
const diff = (statusA!.remaining_time ?? 0) - (statusB!.remaining_time ?? 0);
if (diff !== 0) return diff;
}
return a.name.localeCompare(b.name);
});
break;
}
// Apply ascending/descending
@@ -8069,7 +8091,7 @@ export function PrintersPage() {
// Group printers when sorted by location, status, or model
const groupedPrinters = useMemo(() => {
if (sortBy === 'name') return null;
if (sortBy === 'name' || sortBy === 'eta') return null;
const groups: Record<string, typeof sortedPrinters> = {};
@@ -8207,6 +8229,7 @@ export function PrintersPage() {
{ value: 'status', label: t('printers.sort.status') },
{ value: 'model', label: t('printers.sort.model') },
{ value: 'location', label: t('printers.sort.location') },
{ value: 'eta', label: t('printers.sort.eta') },
]}
/>
<button
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -26,7 +26,7 @@
<!-- Splash screens for iOS -->
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
<script type="module" crossorigin src="/assets/index-BPZeidkD.js"></script>
<script type="module" crossorigin src="/assets/index-DSkiM9pi.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D9kvaB_m.css">
</head>
<body>