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:
1 parent
511213b40d
commit
e9f69f7d7f
4 files changed
+175
-14
No files matched your search
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useRef } from 'react'
|
import { useEffect, useRef } from 'react'
|
||||||
|
import { formatBytes, formatDuration } from '../format'
|
||||||
|
|
||||||
interface UploadItem {
|
interface UploadItem {
|
||||||
id: string
|
id: string
|
||||||
@@ -6,6 +7,12 @@ interface UploadItem {
|
|||||||
progress: number // 0-100
|
progress: number // 0-100
|
||||||
status: 'uploading' | 'done' | 'error' | 'canceled'
|
status: 'uploading' | 'done' | 'error' | 'canceled'
|
||||||
error?: string
|
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 {
|
interface Props {
|
||||||
@@ -98,21 +105,36 @@ export default function UploadQueue({ items, onDismiss, onCancel }: Props) {
|
|||||||
title={item.status === 'uploading' ? 'Cancel upload' : 'Dismiss'}
|
title={item.status === 'uploading' ? 'Cancel upload' : 'Dismiss'}
|
||||||
aria-label={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"
|
className="ml-2 opacity-50 hover:opacity-100 text-xs shrink-0"
|
||||||
style={{ color: 'var(--color-text-muted)' }}>✕</button>
|
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)' }}
|
||||||
|
>✕</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{item.status === 'uploading' && (
|
{item.status === 'uploading' && (
|
||||||
<div className="h-1.5 rounded-full overflow-hidden" style={{ background: 'var(--color-surface-2)' }}>
|
<>
|
||||||
<div
|
<div className="h-1.5 rounded-full overflow-hidden" style={{ background: 'var(--color-surface-2)' }}>
|
||||||
className="h-full rounded-full transition-all"
|
<div
|
||||||
style={{
|
className="h-full rounded-full transition-all"
|
||||||
width: `${item.progress}%`,
|
style={{
|
||||||
background: 'linear-gradient(90deg, var(--color-primary), var(--color-accent))',
|
width: `${item.progress}%`,
|
||||||
}} />
|
background: 'linear-gradient(90deg, var(--color-primary), var(--color-accent))',
|
||||||
</div>
|
}} />
|
||||||
|
</div>
|
||||||
|
<Details item={item} />
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
{item.status === 'done' && (
|
{item.status === 'done' && (
|
||||||
<p style={{ color: 'var(--color-success)' }} className="text-xs">✓ Uploaded</p>
|
<p style={{ color: 'var(--color-success)' }} className="text-xs">
|
||||||
|
✓ Uploaded{item.total ? ` · ${formatBytes(item.total)}` : ''}
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
{item.status === 'error' && (
|
{item.status === 'error' && (
|
||||||
<p style={{ color: 'var(--color-danger)' }} className="text-xs truncate">✕ {item.error}</p>
|
<p style={{ color: 'var(--color-danger)' }} className="text-xs truncate">✕ {item.error}</p>
|
||||||
@@ -136,3 +158,40 @@ export default function UploadQueue({ items, onDismiss, onCancel }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 (
|
||||||
|
<p
|
||||||
|
className="text-xs mt-1 truncate"
|
||||||
|
style={{ color: 'var(--color-text-muted)', fontVariantNumeric: 'tabular-nums' }}
|
||||||
|
>
|
||||||
|
{teile.join(' · ')}
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import { formatBytes } from './format'
|
import { formatBytes, formatDuration } from './format'
|
||||||
|
|
||||||
describe('formatBytes', () => {
|
describe('formatBytes', () => {
|
||||||
it('lässt kleine Zahlen als Bytes stehen', () => {
|
it('lässt kleine Zahlen als Bytes stehen', () => {
|
||||||
@@ -23,3 +23,33 @@ describe('formatBytes', () => {
|
|||||||
expect(formatBytes(2048 * 1024 ** 3)).toBe('2048.0 GB')
|
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('')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -21,3 +21,25 @@ export function formatBytes(n: number): string {
|
|||||||
if (n < 1024 ** 3) return `${(n / 1024 ** 2).toFixed(1)} MB`
|
if (n < 1024 ** 3) return `${(n / 1024 ** 2).toFixed(1)} MB`
|
||||||
return `${(n / 1024 ** 3).toFixed(1)} GB`
|
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`
|
||||||
|
}
|
||||||
@@ -65,6 +65,17 @@ export function useUploads({ folderId, onFinished }: Options) {
|
|||||||
*/
|
*/
|
||||||
const canceled = useRef(new Set<string>())
|
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.
|
* Der Zielordner in einem Ref – **nicht** in der Abhängigkeitsliste.
|
||||||
*
|
*
|
||||||
@@ -87,6 +98,42 @@ export function useUploads({ folderId, onFinished }: Options) {
|
|||||||
const patchRef = useRef(patch)
|
const patchRef = useRef(patch)
|
||||||
patchRef.current = 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.
|
* Den Vorgang einer Zeile abbrechen.
|
||||||
*
|
*
|
||||||
@@ -107,10 +154,12 @@ export function useUploads({ folderId, onFinished }: Options) {
|
|||||||
if (canceled.current.has(id)) return
|
if (canceled.current.has(id)) return
|
||||||
const controller = new AbortController()
|
const controller = new AbortController()
|
||||||
running.current.set(id, controller)
|
running.current.set(id, controller)
|
||||||
|
rates.current.set(id, { lastAt: 0, lastLoaded: 0, speed: 0, lastPatch: 0 })
|
||||||
try {
|
try {
|
||||||
await uploadWithProgress(file, target,
|
await uploadWithProgress(file, target,
|
||||||
({ percent }) => patch(id, { progress: percent }), controller.signal)
|
({ loaded, total, percent }) => onProgress(id, loaded, total, percent),
|
||||||
patch(id, { progress: 100, status: 'done' })
|
controller.signal)
|
||||||
|
patch(id, { progress: 100, loaded: file.size, total: file.size, speed: 0, status: 'done' })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Ein Abbruch ist kein Fehler: Die Zeile steht schon auf „abgebrochen",
|
// 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,
|
// 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' })
|
patch(id, { status: 'error', error: err instanceof APIError ? err.message : 'Upload failed' })
|
||||||
} finally {
|
} finally {
|
||||||
running.current.delete(id)
|
running.current.delete(id)
|
||||||
|
rates.current.delete(id)
|
||||||
}
|
}
|
||||||
}, [patch])
|
}, [patch, onProgress])
|
||||||
|
|
||||||
/** Einzelne Dateien – aus dem Auswahlfenster oder per Ablage. */
|
/** Einzelne Dateien – aus dem Auswahlfenster oder per Ablage. */
|
||||||
const uploadFiles = useCallback(async (fileList: File[]) => {
|
const uploadFiles = useCallback(async (fileList: File[]) => {
|
||||||
|
|||||||
Reference in new issue
Block a user