feat(statistics): persist ranges and align history columns

Persist per-day download volume, outcomes, active transfer time, and provider results so today, seven-day, and 30-day views use real partial-window data. Preserve existing all-time counters while extending totals with newly recorded result metrics. Rebuild the history table around one resizable persisted grid shared by headers and rows, with synchronized overflow and consistent alignment across window sizes.
This commit is contained in:
Sucukdeluxe
2026-08-15 04:23:14 +02:00
parent 7c4f166500
commit 83c9afca90
17 changed files with 1157 additions and 217 deletions
+122 -18
View File
@@ -1,4 +1,13 @@
import { useEffect, useState, type ChangeEvent, type MouseEvent, type ReactElement } from "react";
import {
useEffect,
useState,
type ChangeEvent,
type KeyboardEvent,
type MouseEvent,
type PointerEvent,
type ReactElement,
type UIEvent
} from "react";
import {
DataTable,
DataTableBody,
@@ -7,7 +16,20 @@ import {
} from "../../ui/DataTable";
import { Toolbar, ToolbarGroup, ToolbarSearch } from "../../ui/Toolbar";
import { SlidingSelection } from "../../ui/SlidingSelection";
import { paginateHistoryRows, type HistoryFilter, type HistoryPage, type HistoryRow, type HistoryViewModel } from "./history-model";
import {
createHistoryTableColumnWidths,
getHistoryTableGridTemplate,
getHistoryTableMinWidth,
HISTORY_TABLE_COLUMN_IDS,
paginateHistoryRows,
resizeHistoryTableColumn,
type HistoryFilter,
type HistoryPage,
type HistoryRow,
type HistoryTableColumnId,
type HistoryTableColumnWidths,
type HistoryViewModel
} from "./history-model";
import "./history.css";
export interface HistoryViewActions {
@@ -29,7 +51,7 @@ export interface HistoryViewProps {
actions: HistoryViewActions;
}
const filterItems: Array<{ id: HistoryFilter; label: string }> = [
const filterItems: Array<{ id: HistoryFilter; label: string }> = [
{ id: "all", label: "Alle Einträge" },
{ id: "today", label: "Heute" },
{ id: "week", label: "Letzte 7 Tage" },
@@ -37,11 +59,51 @@ const filterItems: Array<{ id: HistoryFilter; label: string }> = [
{ id: "completed", label: "Fertig" },
{ id: "deleted", label: "Gelöscht" },
{ id: "failed", label: "Fehlgeschlagen" }
];
];
const HISTORY_TABLE_COLUMNS = ["Paket / Datei", "Status", "Größe", "Hoster", "Gestartet", "Beendet"] as const;
const HISTORY_TABLE_COLUMN_STORAGE_KEY = "mdd.history-table-columns.v1";
let historyTableResizeSession: { column: HistoryTableColumnId; startX: number; initial: HistoryTableColumnWidths } | null = null;
function loadHistoryTableColumnWidths(): HistoryTableColumnWidths {
try {
const stored = typeof window === "undefined" ? null : window.localStorage.getItem(HISTORY_TABLE_COLUMN_STORAGE_KEY);
return createHistoryTableColumnWidths(stored ? JSON.parse(stored) : undefined);
} catch {
return createHistoryTableColumnWidths();
}
}
function applyHistoryTableColumnWidths(source: HTMLElement, widths: HistoryTableColumnWidths): void {
const table = source.closest(".history-table");
if (!table) return;
const template = getHistoryTableGridTemplate(widths);
const minWidth = `${getHistoryTableMinWidth(widths)}px`;
table.querySelectorAll<HTMLElement>(".history-table-header-row, .history-row, .history-detail-row").forEach((row) => {
if (!row.classList.contains("history-detail-row")) {
row.style.gridTemplateColumns = template;
}
row.style.minWidth = minWidth;
});
}
function persistHistoryTableColumnWidths(widths: HistoryTableColumnWidths): void {
try {
window.localStorage.setItem(HISTORY_TABLE_COLUMN_STORAGE_KEY, JSON.stringify(widths));
} catch {
}
}
function syncHistoryTableScroll(event: UIEvent<HTMLDivElement>): void {
const header = event.currentTarget.parentElement?.querySelector<HTMLElement>(".history-table-header");
if (header) {
header.scrollLeft = event.currentTarget.scrollLeft;
}
}
function HistoryRowDetails({ row }: { row: HistoryRow }): ReactElement {
function HistoryRowDetails({ row, minWidth }: { row: HistoryRow; minWidth: number }): ReactElement {
return (
<div className="history-detail-row" role="row">
<div className="history-detail-row" role="row" style={{ minWidth }}>
<div className="history-detail-cell" role="cell">
<dl className="history-details-grid">
<div><dt>Provider</dt><dd>{row.providerLabel}</dd></div>
@@ -159,18 +221,47 @@ export function HistoryContentPage({ model, actions, page, onPageChange }: Histo
const emptyTitle = model.totalCount === 0 && !model.query && model.filter === "all"
? "Noch kein Verlauf"
: "Keine passenden Einträge";
const announcement = model.loading
const announcement = model.loading
? { role: "status" as const, live: "polite" as const, message: "Verlauf wird geladen. Die gespeicherten Einträge werden geladen." }
: model.error
? { role: "alert" as const, live: "assertive" as const, message: `${model.error}. Öffne die Ansicht erneut, um es noch einmal zu versuchen.` }
: null;
: null;
const columnWidths = loadHistoryTableColumnWidths();
const gridTemplateColumns = getHistoryTableGridTemplate(columnWidths);
const minWidth = getHistoryTableMinWidth(columnWidths);
const beginResize = (event: PointerEvent<HTMLButtonElement>, column: HistoryTableColumnId): void => {
event.preventDefault();
event.stopPropagation();
event.currentTarget.setPointerCapture?.(event.pointerId);
historyTableResizeSession = { column, startX: event.clientX, initial: loadHistoryTableColumnWidths() };
};
const continueResize = (event: PointerEvent<HTMLButtonElement>): void => {
const active = historyTableResizeSession;
if (!active) return;
const next = resizeHistoryTableColumn(active.initial, active.column, event.clientX - active.startX);
applyHistoryTableColumnWidths(event.currentTarget, next);
persistHistoryTableColumnWidths(next);
};
const finishResize = (event: PointerEvent<HTMLButtonElement>): void => {
if (!historyTableResizeSession) return;
event.currentTarget.releasePointerCapture?.(event.pointerId);
historyTableResizeSession = null;
};
const resizeWithKeyboard = (event: KeyboardEvent<HTMLButtonElement>, column: HistoryTableColumnId): void => {
if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return;
event.preventDefault();
event.stopPropagation();
const next = resizeHistoryTableColumn(loadHistoryTableColumnWidths(), column, event.key === "ArrowRight" ? 16 : -16);
applyHistoryTableColumnWidths(event.currentTarget, next);
persistHistoryTableColumnWidths(next);
};
return (
<section aria-label="Verlaufstabelle" className="history-content">
<h1 className="history-main-title">Verlauf</h1>
<DataTable className="history-table" label="Verlauf">
<DataTableHeader className="history-table-header">
<div className="history-table-header-row" role="row">
<div className="history-table-header-row" role="row" style={{ gridTemplateColumns, minWidth }}>
<span className="history-column-select" role="columnheader">
<input
aria-label="Alle sichtbaren Einträge auswählen"
@@ -180,16 +271,28 @@ export function HistoryContentPage({ model, actions, page, onPageChange }: Histo
type="checkbox"
/>
</span>
<span role="columnheader">Paket / Datei</span>
<span role="columnheader">Status</span>
<span role="columnheader">Größe</span>
<span role="columnheader">Hoster</span>
<span role="columnheader">Gestartet</span>
<span role="columnheader">Beendet</span>
{HISTORY_TABLE_COLUMNS.map((column, index) => (
<span className="history-resizable-header" key={column} role="columnheader">
{column}
<button
aria-label={`${column} Spaltenbreite ändern`}
aria-orientation="vertical"
className="history-column-resizer"
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => resizeWithKeyboard(event, HISTORY_TABLE_COLUMN_IDS[index])}
onPointerCancel={finishResize}
onPointerDown={(event) => beginResize(event, HISTORY_TABLE_COLUMN_IDS[index])}
onPointerMove={continueResize}
onPointerUp={finishResize}
role="separator"
type="button"
/>
</span>
))}
<span role="columnheader">Aktion</span>
</div>
</DataTableHeader>
<DataTableBody className="history-table-body" data-visual-region="history-table-body">
<DataTableBody className="history-table-body" data-visual-region="history-table-body" onScroll={syncHistoryTableScroll}>
{model.loading ? (
<DataTableEmpty description="Die gespeicherten Einträge werden geladen." title="Verlauf wird geladen" />
) : model.error ? (
@@ -212,7 +315,8 @@ export function HistoryContentPage({ model, actions, page, onPageChange }: Histo
className={`history-row${isSelected ? " is-selected" : ""}`}
data-history-row-id={row.id}
onContextMenu={onContextMenu}
role="row"
role="row"
style={{ gridTemplateColumns, minWidth }}
>
<span className="history-column-select" role="cell">
<input
@@ -252,7 +356,7 @@ export function HistoryContentPage({ model, actions, page, onPageChange }: Histo
></button>
</span>
</div>
{isExpanded ? <HistoryRowDetails row={row} /> : null}
{isExpanded ? <HistoryRowDetails minWidth={minWidth} row={row} /> : null}
</div>
);
})