diff --git a/frontend/src/components/UploadQueue.tsx b/frontend/src/components/UploadQueue.tsx index 71751f3..49d167c 100644 --- a/frontend/src/components/UploadQueue.tsx +++ b/frontend/src/components/UploadQueue.tsx @@ -1,4 +1,5 @@ import { useEffect, useRef } from 'react' +import { formatBytes, formatDuration } from '../format' interface UploadItem { id: string @@ -6,6 +7,12 @@ interface UploadItem { progress: number // 0-100 status: 'uploading' | 'done' | 'error' | 'canceled' error?: string + /** Schon draußen, in Bytes. */ + loaded?: number + /** Größe der Datei, in Bytes. */ + total?: number + /** Bytes je Sekunde, geglättet. 0 heißt „noch nicht messbar". */ + speed?: number } interface Props { @@ -98,21 +105,36 @@ export default function UploadQueue({ items, onDismiss, onCancel }: Props) { title={item.status === 'uploading' ? 'Cancel upload' : 'Dismiss'} aria-label={item.status === 'uploading' ? 'Cancel upload' : 'Dismiss'} className="ml-2 opacity-50 hover:opacity-100 text-xs shrink-0" - style={{ color: 'var(--color-text-muted)' }}>✕ + style={{ color: 'var(--color-text-muted)' }} + // Beim Darüberfahren rot – **nur solange der Upload läuft**. + // Dasselbe Zeichen tut hier zwei verschiedene Dinge: eine + // Meldung wegräumen ist folgenlos, einen laufenden Vorgang + // fallen zu lassen nicht. Die Farbe ist der einzige Hinweis + // darauf, bevor man klickt. + onMouseEnter={e => { + if (item.status === 'uploading') e.currentTarget.style.color = 'var(--color-danger)' + }} + onMouseLeave={e => { e.currentTarget.style.color = 'var(--color-text-muted)' }} + >✕ {item.status === 'uploading' && ( -
-
-
+ <> +
+
+
+
+ )} {item.status === 'done' && ( -

✓ Uploaded

+

+ ✓ Uploaded{item.total ? ` · ${formatBytes(item.total)}` : ''} +

)} {item.status === 'error' && (

✕ {item.error}

@@ -136,3 +158,40 @@ export default function UploadQueue({ items, onDismiss, onCancel }: Props) {
) } + +/** + * Die Zeile unter dem Balken: wie viel, wie schnell, wie lange noch. + * + * Ein Prozentwert allein sagt nichts über das Warten. Bei 40 % weiß man nicht, + * ob noch zehn Sekunden oder zehn Minuten kommen – und genau das ist die + * Frage, wegen der man auf den Balken sieht. + * + * Jeder Teil erscheint erst, wenn er stimmt: Die Größe kennt der Browser + * sofort, das Tempo erst nach dem zweiten Messpunkt, die Restzeit erst mit + * beidem. Eine Schätzung aus dem Nichts wäre schlimmer als keine – man richtet + * sich danach. + */ +function Details({ item }: { item: UploadItem }) { + const teile: string[] = [] + + if (item.total) { + teile.push(`${formatBytes(item.loaded ?? 0)} / ${formatBytes(item.total)}`) + } + if (item.speed) { + teile.push(`${formatBytes(Math.round(item.speed))}/s`) + if (item.total) { + const rest = formatDuration((item.total - (item.loaded ?? 0)) / item.speed) + if (rest) teile.push(`${rest} left`) + } + } + if (teile.length === 0) return null + + return ( +

+ {teile.join(' · ')} +

+ ) +} diff --git a/frontend/src/format.test.ts b/frontend/src/format.test.ts index 8aaaade..4848374 100644 --- a/frontend/src/format.test.ts +++ b/frontend/src/format.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { formatBytes } from './format' +import { formatBytes, formatDuration } from './format' describe('formatBytes', () => { it('lässt kleine Zahlen als Bytes stehen', () => { @@ -23,3 +23,33 @@ describe('formatBytes', () => { expect(formatBytes(2048 * 1024 ** 3)).toBe('2048.0 GB') }) }) + +describe('formatDuration', () => { + it('zeigt unter einer Minute Sekunden', () => { + expect(formatDuration(1)).toBe('1 s') + expect(formatDuration(45.4)).toBe('45 s') + expect(formatDuration(59)).toBe('59 s') + }) + + it('sagt nie „0 s", solange noch etwas läuft', () => { + expect(formatDuration(0)).toBe('1 s') + expect(formatDuration(0.2)).toBe('1 s') + }) + + it('zeigt darüber Minuten mit zweistelligen Sekunden', () => { + expect(formatDuration(60)).toBe('1:00 min') + expect(formatDuration(187)).toBe('3:07 min') + expect(formatDuration(3599)).toBe('59:59 min') + }) + + it('zeigt ab einer Stunde Stunden', () => { + expect(formatDuration(3600)).toBe('1:00 h') + expect(formatDuration(3600 * 2 + 60 * 5)).toBe('2:05 h') + }) + + it('schweigt bei allem, was keine Dauer ist', () => { + expect(formatDuration(Infinity)).toBe('') + expect(formatDuration(NaN)).toBe('') + expect(formatDuration(-1)).toBe('') + }) +}) diff --git a/frontend/src/format.ts b/frontend/src/format.ts index 2d82923..10cd931 100644 --- a/frontend/src/format.ts +++ b/frontend/src/format.ts @@ -21,3 +21,25 @@ export function formatBytes(n: number): string { if (n < 1024 ** 3) return `${(n / 1024 ** 2).toFixed(1)} MB` return `${(n / 1024 ** 3).toFixed(1)} GB` } + +/** + * Eine Dauer, wie man sie beim Warten liest. + * + * Grob genug, dass die Zahl nicht zappelt, genau genug, dass sie etwas sagt: + * unter einer Minute in Sekunden, darüber als `3:07 min`. Sekunden bis in die + * zehnte Minute auszuschreiben hieße eine Zahl, die sich jede Sekunde ändert + * und trotzdem niemandem hilft. + * + * Leer bei allem, was keine Dauer ist – unendlich, negativ, keine Zahl. Der + * Aufrufer zeigt dann gar nichts an, statt „NaN s" zu schreiben. + */ +export function formatDuration(seconds: number): string { + if (!Number.isFinite(seconds) || seconds < 0) return '' + // Unter einer Sekunde bleibt "1 s" stehen: "0 s" sähe aus wie fertig, + // während der Balken noch läuft. + if (seconds < 60) return `${Math.max(1, Math.round(seconds))} s` + const total = Math.round(seconds) + const m = Math.floor(total / 60) + if (m < 60) return `${m}:${String(total % 60).padStart(2, '0')} min` + return `${Math.floor(m / 60)}:${String(m % 60).padStart(2, '0')} h` +} diff --git a/frontend/src/hooks/useUploads.ts b/frontend/src/hooks/useUploads.ts index 07519db..c7505d8 100644 --- a/frontend/src/hooks/useUploads.ts +++ b/frontend/src/hooks/useUploads.ts @@ -65,6 +65,17 @@ export function useUploads({ folderId, onFinished }: Options) { */ const canceled = useRef(new Set()) + /** + * Was für Tempo und Restzeit gemerkt werden muss. + * + * Der Browser meldet nur „so viele Bytes sind draußen". Wie schnell das + * geht, steht nirgends – das ist die Ableitung, und dafür braucht es den + * vorigen Messpunkt. + */ + const rates = useRef(new Map()) + /** * Der Zielordner in einem Ref – **nicht** in der Abhängigkeitsliste. * @@ -87,6 +98,42 @@ export function useUploads({ folderId, onFinished }: Options) { const patchRef = useRef(patch) patchRef.current = patch + /** + * Ein Fortschrittsbericht: Tempo mitrechnen und die Zeile nachführen. + * + * Zwei Dinge werden hier gedämpft, aus zwei verschiedenen Gründen: + * + * * **Das Tempo** aus einem einzelnen Abstand gerechnet springt zwischen + * dem Doppelten und der Hälfte – die Pakete kommen nun einmal nicht + * gleichmäßig heraus. Ein gleitender Mittelwert macht daraus eine Zahl, + * die man ablesen kann, ohne dass sie träge wird. + * * **Die Anzeige.** Der Browser meldet Fortschritt im 50-Millisekunden- + * Takt; bei drei Dateien gleichzeitig wären das sechzig Neuaufbauten der + * Liste je Sekunde für eine Zahl, die sich niemand so schnell ansieht. + */ + const onProgress = useCallback((id: string, loaded: number, total: number, percent: number) => { + const r = rates.current.get(id) + if (!r) return + const now = performance.now() + + // Erst ab einem Fünftel Sekunde messen: Darunter ist der Abstand so klein, + // dass die Rechnung mehr Zufall als Tempo enthält. + const dt = (now - r.lastAt) / 1000 + if (r.lastAt === 0) { + r.lastAt = now + r.lastLoaded = loaded + } else if (dt >= 0.2) { + const jetzt = (loaded - r.lastLoaded) / dt + r.speed = r.speed === 0 ? jetzt : r.speed * 0.7 + jetzt * 0.3 + r.lastAt = now + r.lastLoaded = loaded + } + + if (now - r.lastPatch < 150 && percent < 100) return + r.lastPatch = now + patch(id, { progress: percent, loaded, total, speed: r.speed }) + }, [patch]) + /** * Den Vorgang einer Zeile abbrechen. * @@ -107,10 +154,12 @@ export function useUploads({ folderId, onFinished }: Options) { if (canceled.current.has(id)) return const controller = new AbortController() running.current.set(id, controller) + rates.current.set(id, { lastAt: 0, lastLoaded: 0, speed: 0, lastPatch: 0 }) try { await uploadWithProgress(file, target, - ({ percent }) => patch(id, { progress: percent }), controller.signal) - patch(id, { progress: 100, status: 'done' }) + ({ loaded, total, percent }) => onProgress(id, loaded, total, percent), + controller.signal) + patch(id, { progress: 100, loaded: file.size, total: file.size, speed: 0, status: 'done' }) } catch (err) { // Ein Abbruch ist kein Fehler: Die Zeile steht schon auf „abgebrochen", // und eine rote Meldung darüber wäre ein Vorwurf für eine Entscheidung, @@ -119,8 +168,9 @@ export function useUploads({ folderId, onFinished }: Options) { patch(id, { status: 'error', error: err instanceof APIError ? err.message : 'Upload failed' }) } finally { running.current.delete(id) + rates.current.delete(id) } - }, [patch]) + }, [patch, onProgress]) /** Einzelne Dateien – aus dem Auswahlfenster oder per Ablage. */ const uploadFiles = useCallback(async (fileList: File[]) => {