feat(uploads): Fortschritt in MB, Tempo und Restzeit; Abbruchkreuz rot

Das Kreuz faerbt sich beim Darueberfahren rot -- aber nur, solange der
Upload laeuft. Dasselbe Zeichen tut zwei Dinge: eine Meldung wegraeumen
ist folgenlos, einen laufenden Vorgang fallen zu lassen nicht.

Unter dem Balken steht jetzt "4.2 MB / 18.0 MB · 1.8 MB/s · 8 s left".
Die Zahlen kamen schon immer vom XMLHttpRequest, sie wurden nur
weggeworfen. Neu gerechnet wird nur das Tempo -- der Browser meldet
Bytes, nicht deren Ableitung.

Zwei Daempfungen, aus zwei Gruenden:

* Das Tempo aus einem einzelnen Abstand springt zwischen dem Doppelten
  und der Haelfte. Gleitender Mittelwert (0,7/0,3) ueber Messpunkte von
  mindestens 0,2 s.
* Die Anzeige wird hoechstens alle 150 ms nachgezogen. Der Browser meldet
  im 50-ms-Takt; bei drei Dateien waeren das sechzig Neuaufbauten der
  Liste je Sekunde fuer eine Zahl, die niemand so schnell liest.

Jeder Teil erscheint erst, wenn er stimmt: Groesse sofort, Tempo nach dem
zweiten Messpunkt, Restzeit erst mit beidem. Eine Schaetzung aus dem
Nichts waere schlimmer als keine -- man richtet sich danach.

formatDuration in src/format.ts, 5 Tests (37 gesamt).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
marcandClaude Opus 5 committed 2026-09-09 08:46:28 +02:00
1 parent 511213b40d
commit e9f69f7d7f
4 files changed
+175 -14

No files matched your search

+53 -3
View File
@@ -65,6 +65,17 @@ export function useUploads({ folderId, onFinished }: Options) {
*/
const canceled = useRef(new Set<string>())
/**
* 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<string, {
lastAt: number; lastLoaded: number; speed: number; lastPatch: number
}>())
/**
* 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[]) => {