Sort packages by availability and tidy availability-related displays
- Make the Verfügbarkeit column sortable from fully online through partial and unchecked down to fully offline, reversing on the second click - Show partially available packages in the warning color as soon as one link is online and one is offline - Switch the package order instantly on column sorts instead of sliding rows - Keep offline links out of the package status error count - Show queue volumes of one terabyte or more as grouped gigabytes with one decimal per language Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MxAH7rSv8MgfKEPwMkR8N2
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
65084bd6d2
commit
558d6ca4f4
@@ -39,7 +39,7 @@ import {
|
||||
getProviderDailyUsageBytes,
|
||||
getProviderUsageDayKey
|
||||
} from "../shared/provider-daily-limits";
|
||||
import { preservePackageOrderForDisplay, sortPackageOrderByName } from "./package-order";
|
||||
import { preservePackageOrderForDisplay, sortPackageOrderByAvailability, sortPackageOrderByName } from "./package-order";
|
||||
import { pruneSelection, releaseAccountSelectionFocus, resolveEscapeSelectionScope, resolveSelectAllSelectionScope, shouldClearDownloadSelection } from "./selection";
|
||||
import { buildConfiguredProviderOrder, createAccountToggleQueue, enqueueAccountToggleIntent, filterAccountDialogOptions, formatAccountOperationError, getAccountDialogSelectableOptions, getAvailableAccountOptions, mergeAccountToggleSettings, pruneAccountRowSelections, resolveAccountStatusState, resolveAccountToggleIntentEnabled, resolveAccountUsername, resolveVisibleAccountKind, sortAccountServices, updateAccountRowSelection, type AccountToggleTarget } from "./account-ui";
|
||||
import { buildAccountDeleteCommand, buildAccountReplaceCommand, buildAccountSecretRequest, createAccountEditState, validateAccountEdit } from "./account-edit";
|
||||
@@ -1904,6 +1904,7 @@ export function App(): ReactElement {
|
||||
const [downloadFilter, setDownloadFilter] = useState<DownloadSidebarFilter>("all");
|
||||
const [downloadProviderFilter, setDownloadProviderFilter] = useState("all");
|
||||
const [downloadsSortColumn, setDownloadsSortColumn] = useState<DownloadSortColumn>("name");
|
||||
const [downloadsSortRevision, setDownloadsSortRevision] = useState(0);
|
||||
const [downloadsSortDescending, setDownloadsSortDescending] = useState(false);
|
||||
const [showAllPackages, setShowAllPackages] = useState(false);
|
||||
const [actionBusy, setActionBusy] = useState(false);
|
||||
@@ -5292,6 +5293,7 @@ export function App(): ReactElement {
|
||||
const nextDescending = downloadsSortColumn === column ? !downloadsSortDescending : false;
|
||||
setDownloadsSortColumn(column);
|
||||
setDownloadsSortDescending(nextDescending);
|
||||
setDownloadsSortRevision((revision) => revision + 1);
|
||||
const baseOrder = packageOrderRef.current.length > 0 ? packageOrderRef.current : snapshot.session.packageOrder;
|
||||
const sorted = column === "progress"
|
||||
? sortPackageOrderByProgress(baseOrder, snapshot.session.packages, snapshot.session.items, nextDescending)
|
||||
@@ -5307,7 +5309,9 @@ export function App(): ReactElement {
|
||||
nextDescending,
|
||||
Object.fromEntries(downloadsViewCore.packageRows.map((row) => [row.package.id, row.items]))
|
||||
)
|
||||
: sortPackageOrderByName(baseOrder, snapshot.session.packages, nextDescending);
|
||||
: column === "availability"
|
||||
? sortPackageOrderByAvailability(baseOrder, snapshot.session.packages, snapshot.session.items, nextDescending)
|
||||
: sortPackageOrderByName(baseOrder, snapshot.session.packages, nextDescending);
|
||||
pendingPackageOrderRef.current = [...sorted];
|
||||
pendingPackageOrderAtRef.current = Date.now();
|
||||
packageOrderRef.current = sorted;
|
||||
@@ -5450,6 +5454,7 @@ export function App(): ReactElement {
|
||||
gridTemplate,
|
||||
sortColumn: downloadsSortColumn,
|
||||
sortDirection: downloadsSortDescending ? "desc" : "asc",
|
||||
packageOrderSortRevision: downloadsSortRevision,
|
||||
disclosureRevision: downloadDisclosureRevision,
|
||||
animationsEnabled: snapshot.settings.animatePackageDisclosure,
|
||||
status: {
|
||||
@@ -5457,9 +5462,9 @@ export function App(): ReactElement {
|
||||
links: downloadQueueMetrics.pendingItemCount,
|
||||
session: humanSize(snapshot.stats.totalDownloaded),
|
||||
sessionBytes: snapshot.stats.totalDownloaded,
|
||||
total: formatRemainingDownloadBytes(downloadQueueTotal),
|
||||
total: formatRemainingDownloadBytes(downloadQueueTotal, normalizeLanguage(settingsDraft.language)),
|
||||
totalBytes: downloadQueueTotal.bytes,
|
||||
remaining: formatRemainingDownloadBytes(downloadRemaining),
|
||||
remaining: formatRemainingDownloadBytes(downloadRemaining, normalizeLanguage(settingsDraft.language)),
|
||||
remainingBytes: downloadRemaining.bytes,
|
||||
remainingTooltip: formatRemainingDownloadTooltip(downloadRemaining),
|
||||
hosters: downloadQueueMetrics.hosterCount,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { PackageEntry } from "../shared/types";
|
||||
import type { DownloadItem, PackageEntry } from "../shared/types";
|
||||
import { compareAvailabilitySummaries, getAvailabilitySummary } from "./views/downloads/DownloadsTable";
|
||||
|
||||
export function reorderPackageOrderByDrop(order: string[], draggedPackageId: string, targetPackageId: string): string[] {
|
||||
const fromIndex = order.indexOf(draggedPackageId);
|
||||
@@ -24,6 +25,24 @@ export function sortPackageOrderByName(order: string[], packages: Record<string,
|
||||
return sorted;
|
||||
}
|
||||
|
||||
export function sortPackageOrderByAvailability(
|
||||
order: string[],
|
||||
packages: Record<string, PackageEntry>,
|
||||
items: Record<string, DownloadItem>,
|
||||
descending: boolean
|
||||
): string[] {
|
||||
const summaries = new Map(order.map((packageId) => {
|
||||
const packageItems = (packages[packageId]?.itemIds ?? []).map((id) => items[id]).filter((item): item is DownloadItem => Boolean(item));
|
||||
return [packageId, getAvailabilitySummary(packageItems)] as const;
|
||||
}));
|
||||
const sorted = [...order];
|
||||
sorted.sort((a, b) => {
|
||||
const cmp = compareAvailabilitySummaries(summaries.get(a)!, summaries.get(b)!);
|
||||
return descending ? -cmp : cmp;
|
||||
});
|
||||
return sorted;
|
||||
}
|
||||
|
||||
export function preservePackageOrderForDisplay(packages: PackageEntry[]): PackageEntry[] {
|
||||
return packages;
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
} from "../../download-format";
|
||||
import type { DownloadPackageRow } from "./downloads-model";
|
||||
|
||||
export type DownloadSortColumn = "name" | "size" | "hoster" | "progress" | "service";
|
||||
export type DownloadSortColumn = "name" | "size" | "hoster" | "progress" | "service" | "availability";
|
||||
|
||||
const DOWNLOAD_SELECTION_COLUMN_WIDTH = "36px";
|
||||
const DOWNLOAD_ACTION_COLUMN_WIDTH = "60px";
|
||||
@@ -68,7 +68,7 @@ export const downloadColumnDefinitions: Record<string, { label: string; width: s
|
||||
prio: { label: "Priorität", width: "minmax(var(--downloads-priority-min, 85px), 0.8fr)" },
|
||||
status: { label: "Status", width: "minmax(var(--downloads-status-min, 210px), 1.2fr)" },
|
||||
speed: { label: "Geschwindigkeit", width: "minmax(var(--downloads-speed-min, 120px), 1fr)" },
|
||||
availability: { label: "Verfügbarkeit", width: "minmax(var(--downloads-availability-min, 110px), 1fr)" },
|
||||
availability: { label: "Verfügbarkeit", width: "minmax(var(--downloads-availability-min, 110px), 1fr)", sortable: "availability" },
|
||||
added: { label: "Hinzugefügt am", width: "minmax(var(--downloads-added-min, 135px), 1fr)" }
|
||||
};
|
||||
|
||||
@@ -79,15 +79,31 @@ function effectiveItemOnlineStatus(item: DownloadItem): DownloadItem["onlineStat
|
||||
?? (item.status === "downloading" || item.status === "integrity_check" || item.status === "completed" ? "online" : undefined);
|
||||
}
|
||||
|
||||
export function getAvailabilitySummary(items: DownloadItem[]): { online: number; total: number; state: AvailabilityState } {
|
||||
export interface AvailabilitySummary {
|
||||
online: number;
|
||||
offline: number;
|
||||
total: number;
|
||||
state: AvailabilityState;
|
||||
}
|
||||
|
||||
export function getAvailabilitySummary(items: readonly DownloadItem[]): AvailabilitySummary {
|
||||
const total = items.length;
|
||||
const availability = items.map(effectiveItemOnlineStatus);
|
||||
const online = availability.filter((status) => status === "online").length;
|
||||
const offline = availability.filter((status) => status === "offline").length;
|
||||
if (total > 0 && online === total) return { online, total, state: "online" };
|
||||
if (total > 0 && offline === total) return { online, total, state: "offline" };
|
||||
if (total > 0 && online + offline === total) return { online, total, state: "partial" };
|
||||
return { online, total, state: "checking" };
|
||||
if (total > 0 && online === total) return { online, offline, total, state: "online" };
|
||||
if (total > 0 && offline === total) return { online, offline, total, state: "offline" };
|
||||
if (online > 0 && offline > 0) return { online, offline, total, state: "partial" };
|
||||
return { online, offline, total, state: "checking" };
|
||||
}
|
||||
|
||||
export function compareAvailabilitySummaries(a: AvailabilitySummary, b: AvailabilitySummary): number {
|
||||
const onlineShareA = a.total > 0 ? a.online / a.total : 0;
|
||||
const onlineShareB = b.total > 0 ? b.online / b.total : 0;
|
||||
if (onlineShareA !== onlineShareB) return onlineShareB - onlineShareA;
|
||||
const offlineShareA = a.total > 0 ? a.offline / a.total : 0;
|
||||
const offlineShareB = b.total > 0 ? b.offline / b.total : 0;
|
||||
return offlineShareA - offlineShareB;
|
||||
}
|
||||
|
||||
function Availability({ online, total, state, text }: { online: number; total: number; state: AvailabilityState; text?: string }): ReactElement {
|
||||
@@ -333,9 +349,10 @@ export function areItemRowPropsEqual(previous: ItemRowProps, next: ItemRowProps)
|
||||
|
||||
export const ItemRow = memo(ItemRowContent, areItemRowPropsEqual);
|
||||
|
||||
export function getPackageProgress(row: DownloadPackageRow): { done: number; failed: number; cancelled: number; total: number; value: number } {
|
||||
export function getPackageProgress(row: DownloadPackageRow): { done: number; failed: number; offline: number; cancelled: number; total: number; value: number } {
|
||||
let done = Math.max(0, Number(row.package.cleanedCompletedItemCount || 0));
|
||||
let failed = 0;
|
||||
let offline = 0;
|
||||
let cancelled = 0;
|
||||
let extracted = Math.max(0, Number(row.package.cleanedExtractedItemCount || 0));
|
||||
let extracting = false;
|
||||
@@ -343,8 +360,10 @@ export function getPackageProgress(row: DownloadPackageRow): { done: number; fai
|
||||
let extractingProgress = 0;
|
||||
for (const item of row.allItems) {
|
||||
if (item.status === "completed") done += 1;
|
||||
else if (item.status === "failed") failed += 1;
|
||||
else if (item.status === "cancelled") cancelled += 1;
|
||||
else if (item.status === "failed") {
|
||||
failed += 1;
|
||||
if (item.onlineStatus === "offline") offline += 1;
|
||||
} else if (item.status === "cancelled") cancelled += 1;
|
||||
const fullStatus = item.fullStatus || "";
|
||||
if (fullStatus.startsWith("Entpackt")) {
|
||||
extracted += 1;
|
||||
@@ -368,7 +387,7 @@ export function getPackageProgress(row: DownloadPackageRow): { done: number; fai
|
||||
const downloadProgress = Math.min(useExtractSplit ? 50 : 100, Math.floor(downloadRatio * (useExtractSplit ? 50 : 100)));
|
||||
const extractionProgress = Math.min(50, Math.floor(((extracted + extractingProgress) / total) * 50));
|
||||
const value = Math.min(100, useExtractSplit ? downloadProgress + extractionProgress : downloadProgress);
|
||||
return { done, failed, cancelled, total, value };
|
||||
return { done, failed: failed - offline, offline, cancelled, total, value };
|
||||
}
|
||||
|
||||
export function getPackageSizeProgress(row: DownloadPackageRow): { downloaded: number; total: number; value: number } {
|
||||
@@ -442,7 +461,7 @@ function packageCell(row: DownloadPackageRow, column: string, packageSpeedBps: n
|
||||
const text = availability.state === "checking"
|
||||
? row.allItems.some((item) => item.onlineStatus === "checking") ? "Prüfung" : "Ungeprüft"
|
||||
: undefined;
|
||||
return <Availability {...availability} text={text} />;
|
||||
return <Availability online={availability.online} state={availability.state} text={text} total={availability.total} />;
|
||||
}
|
||||
if (column === "added") return <span className="downloads-cell">{formatDateTime(entry.createdAt)}</span>;
|
||||
return null;
|
||||
|
||||
@@ -52,6 +52,7 @@ export interface DownloadsViewModel extends DownloadsViewModelCore {
|
||||
gridTemplate: string;
|
||||
sortColumn?: DownloadSortColumn;
|
||||
sortDirection?: "asc" | "desc";
|
||||
packageOrderSortRevision?: number;
|
||||
disclosureRevision: number;
|
||||
animationsEnabled: boolean;
|
||||
status: DownloadsStatusModel;
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
animateDownloadOrderRows,
|
||||
captureDownloadOrderRowTops,
|
||||
getDownloadOrderTransitionPinnedIds,
|
||||
shouldAnimateDownloadOrderChange,
|
||||
getDownloadPackageOrder,
|
||||
isDownloadPackageOrderChange
|
||||
} from "./download-order-transition";
|
||||
@@ -137,8 +138,15 @@ export function VirtualizedDownloadsBody({ actions, model, state }: { actions: D
|
||||
const orderRowTopsRef = useRef<Map<string, number>>(new Map());
|
||||
const [, setOrderTransitionRevision] = useState(0);
|
||||
const packageOrderChanged = isDownloadPackageOrderChange(previousPackageOrderRef.current, packageOrder);
|
||||
const packageOrderSortRevision = model.packageOrderSortRevision ?? 0;
|
||||
const appliedSortRevisionRef = useRef(packageOrderSortRevision);
|
||||
const orderAnimationsEnabled = shouldAnimateDownloadOrderChange({
|
||||
animationsEnabled: model.animationsEnabled,
|
||||
sortRevision: packageOrderSortRevision,
|
||||
appliedSortRevision: appliedSortRevisionRef.current
|
||||
});
|
||||
const orderTransitionPinnedIds = getDownloadOrderTransitionPinnedIds({
|
||||
enabled: model.animationsEnabled,
|
||||
enabled: orderAnimationsEnabled,
|
||||
previousOrder: previousPackageOrderRef.current,
|
||||
nextOrder: packageOrder,
|
||||
previousVisibleIds: previousVisibleIdsRef.current,
|
||||
@@ -198,9 +206,10 @@ export function VirtualizedDownloadsBody({ actions, model, state }: { actions: D
|
||||
useRendererLayoutEffect(() => {
|
||||
previousPackageOrderRef.current = packageOrder;
|
||||
previousVisibleIdsRef.current = virtualWindow.rows.map((entry) => entry.id);
|
||||
appliedSortRevisionRef.current = packageOrderSortRevision;
|
||||
const body = bodyRef.current;
|
||||
if (!body) return;
|
||||
if (!model.animationsEnabled) {
|
||||
if (!orderAnimationsEnabled) {
|
||||
for (const animation of orderAnimationsRef.current) animation.cancel();
|
||||
orderAnimationsRef.current = [];
|
||||
orderRowTopsRef.current = captureDownloadOrderRowTops(body);
|
||||
@@ -228,7 +237,7 @@ export function VirtualizedDownloadsBody({ actions, model, state }: { actions: D
|
||||
orderTransitionTimerRef.current = 0;
|
||||
setOrderTransitionRevision((revision) => revision + 1);
|
||||
}, DOWNLOAD_ORDER_TRANSITION_DURATION_MS);
|
||||
}, [model.animationsEnabled, orderTransitionPinnedIds, packageOrder, packageOrderChanged, virtualWindow.rows]);
|
||||
}, [orderAnimationsEnabled, orderTransitionPinnedIds, packageOrder, packageOrderChanged, packageOrderSortRevision, virtualWindow.rows]);
|
||||
|
||||
useEffect(() => () => {
|
||||
if (orderTransitionTimerRef.current) window.clearTimeout(orderTransitionTimerRef.current);
|
||||
|
||||
@@ -20,6 +20,10 @@ export function isDownloadPackageOrderChange(previous: readonly string[], next:
|
||||
return next.some((id, index) => previous[index] !== id);
|
||||
}
|
||||
|
||||
export function shouldAnimateDownloadOrderChange(input: { animationsEnabled: boolean; sortRevision: number; appliedSortRevision: number }): boolean {
|
||||
return input.animationsEnabled && input.sortRevision === input.appliedSortRevision;
|
||||
}
|
||||
|
||||
export function getDownloadOrderTransitionPinnedIds(input: {
|
||||
enabled: boolean;
|
||||
previousOrder: readonly string[];
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { DownloadItem, DownloadStatus, PackageEntry } from "../../../shared/types";
|
||||
import type { AppLanguage, DownloadItem, DownloadStatus, PackageEntry } from "../../../shared/types";
|
||||
import { extractHoster, humanSize } from "../../download-format";
|
||||
import { DOWNLOAD_FILE_ROW_HEIGHT, DOWNLOAD_PACKAGE_ROW_HEIGHT, type DownloadVirtualRowInput } from "./download-virtualizer";
|
||||
|
||||
@@ -147,9 +147,14 @@ export function getDownloadQueueStatusMetrics(items: readonly DownloadItem[]): {
|
||||
};
|
||||
}
|
||||
|
||||
export function formatRemainingDownloadBytes(summary: { bytes: number; unknownItems: number }): string {
|
||||
const largeQueueGigabyteFormatters: Record<AppLanguage, Intl.NumberFormat> = {
|
||||
de: new Intl.NumberFormat("de-DE", { minimumFractionDigits: 1, maximumFractionDigits: 1 }),
|
||||
en: new Intl.NumberFormat("en-US", { minimumFractionDigits: 1, maximumFractionDigits: 1 })
|
||||
};
|
||||
|
||||
export function formatRemainingDownloadBytes(summary: { bytes: number; unknownItems: number }, language: AppLanguage = "de"): string {
|
||||
const value = summary.bytes >= 1024 ** 4
|
||||
? `${(summary.bytes / 1024 ** 4).toFixed(5)} TB`
|
||||
? `${largeQueueGigabyteFormatters[language].format(summary.bytes / 1024 ** 3)} GB`
|
||||
: humanSize(summary.bytes);
|
||||
if (summary.unknownItems <= 0) return value;
|
||||
return summary.bytes > 0 ? `≥ ${value}` : "Unbekannt";
|
||||
|
||||
Reference in New Issue
Block a user