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
+9 -6
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { buildBackupPayload, planBackupImport } from "../src/main/backup-payload";
import type { AppSettings, SessionState, HistoryEntry } from "../src/shared/types";
import type { AppSettings, SessionState, HistoryEntry, StatisticsLedger } from "../src/shared/types";
function settings(overrides: Partial<AppSettings> = {}): AppSettings {
return { backupIncludeDownloads: false, token: "secret", outputDir: "C:\\dl" } as unknown as AppSettings;
@@ -11,16 +11,18 @@ const session: SessionState = {
runStartedAt: 0, totalDownloadedBytes: 0, summaryText: "", reconnectUntil: 0,
reconnectReason: "", paused: false, running: true, updatedAt: 0
};
const history: HistoryEntry[] = [{ id: "h1" } as unknown as HistoryEntry];
const baseInput = { appVersion: "1.7.183", exportedAt: "2026-06-07T00:00:00Z", session, history };
const history: HistoryEntry[] = [{ id: "h1" } as unknown as HistoryEntry];
const statistics: StatisticsLedger = { version: 1, startedAt: 1, days: [] };
const baseInput = { appVersion: "1.7.183", exportedAt: "2026-06-07T00:00:00Z", session, history, statistics };
describe("buildBackupPayload — default is settings-only", () => {
it("omits session AND history when backupIncludeDownloads is false (default)", () => {
const p = buildBackupPayload({ ...baseInput, settings: { backupIncludeDownloads: false } as AppSettings });
expect(p.kind).toBe("settings-only");
expect(p.session).toBeUndefined();
expect(p.history).toBeUndefined();
expect(p.history).toBeUndefined();
expect(p.statistics).toBeUndefined();
expect(p.settings).toBeDefined();
});
@@ -28,7 +30,8 @@ describe("buildBackupPayload — default is settings-only", () => {
const p = buildBackupPayload({ ...baseInput, settings: { backupIncludeDownloads: true } as AppSettings });
expect(p.kind).toBe("full");
expect(p.session).toBe(session);
expect(p.history).toBe(history);
expect(p.history).toBe(history);
expect(p.statistics).toBe(statistics);
});
it("treats a missing flag as settings-only (safe default)", () => {
+37 -11
View File
@@ -6,11 +6,15 @@ import type { HistoryEntry } from "../src/shared/types";
import {
buildHistoryViewModel,
deriveHistoryHoster,
deriveHistoryStartAt,
filterHistoryRows,
deriveHistoryStartAt,
filterHistoryRows,
createHistoryTableColumnWidths,
getHistoryTableGridTemplate,
getHistoryTableMinWidth,
HISTORY_PAGE_SIZE,
paginateHistoryRows,
pruneHistoryIds,
pruneHistoryIds,
resizeHistoryTableColumn,
selectVisibleHistoryIds,
type HistoryFilter,
type HistoryViewEntry
@@ -234,6 +238,28 @@ describe("history model", () => {
});
describe("HistoryView", () => {
it("uses bounded persistent widths and one exact grid for history headers and rows", () => {
const defaults = createHistoryTableColumnWidths();
const resized = resizeHistoryTableColumn(defaults, "status", 80);
const clamped = createHistoryTableColumnWidths({ ...defaults, name: -500, completed: 9000 });
expect(resized.status).toBe(defaults.status + 80);
expect(clamped.name).toBeGreaterThan(0);
expect(clamped.completed).toBeLessThan(9000);
expect(getHistoryTableGridTemplate(resized)).toContain(`${resized.status}px`);
expect(getHistoryTableMinWidth(resized)).toBeGreaterThan(getHistoryTableMinWidth(defaults));
const html = renderToStaticMarkup(
<HistoryView
actions={createActions()}
model={buildHistoryViewModel(entries.slice(0, 2), "all", "", [], [], false, "", now)}
/>
);
const template = getHistoryTableGridTemplate(defaults).replaceAll(" ", " ");
expect(html.match(new RegExp(`grid-template-columns:${template.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`, "g"))).toHaveLength(3);
expect(html.match(/Spaltenbreite ändern/g)).toHaveLength(6);
});
it("builds the complete page status as one localizable text value", () => {
expect(historyPageStatusLabel({ page: 2, pageSize: 100, rangeLabel: "101200 von 250", rows: [], totalItems: 250, totalPages: 3 }))
.toBe("Seite 2 von 3");
@@ -320,7 +346,7 @@ describe("HistoryView", () => {
expect(html.match(/data-sliding-selection-active="true"/g)).toHaveLength(1);
});
it("keeps the header and rows inside the same internal horizontal scroll context", () => {
it("keeps one clipped header synchronized with the scrollable history rows", () => {
const html = renderToStaticMarkup(
<HistoryView
actions={createActions()}
@@ -335,10 +361,9 @@ describe("HistoryView", () => {
expect(tableStart).toBeGreaterThan(-1);
expect(headerStart).toBeGreaterThan(tableStart);
expect(bodyStart).toBeGreaterThan(headerStart);
expect(css).toMatch(/\.history-content \.history-table\s*\{[^}]*overflow:\s*auto;/s);
expect(css).toMatch(/\.history-table > \.history-table-header\s*\{[^}]*position:\s*sticky;[^}]*top:\s*0;/s);
expect(css).toMatch(/\.history-table > \.history-table-body\s*\{[^}]*overflow:\s*visible;/s);
expect(css).not.toMatch(/\.history-table > \.history-table-body\s*\{[^}]*overflow:\s*auto;/s);
expect(css).toMatch(/\.history-content \.history-table\s*\{[^}]*overflow:\s*hidden;/s);
expect(css).toMatch(/\.history-table > \.history-table-header\s*\{[^}]*overflow:\s*hidden;/s);
expect(css).toMatch(/\.history-table > \.history-table-body\s*\{[^}]*overflow:\s*auto;/s);
});
it("keeps every real AppShell history surface non-selectable while allowing text selection only for detail values", () => {
@@ -393,7 +418,7 @@ describe("HistoryView", () => {
expect(html).not.toMatch(/>Start<|>Pause<|>Stop<|Priorität/);
});
it("matches the download action control and centers every header except package and file", () => {
it("matches the download action control and aligns every header with its data column", () => {
const model = buildHistoryViewModel(entries.slice(0, 1), "all", "", [], [], false, "", now);
const content = HistoryContentPage({
actions: createActions(),
@@ -406,8 +431,9 @@ describe("HistoryView", () => {
expect(actionCell.props.children.props.children).toBe("⋮");
expect(styles).toMatch(/\.history-row-action button\s*\{[^}]*background:\s*var\(--ui-input\);[^}]*border:\s*1px solid var\(--ui-border\);[^}]*height:\s*30px;[^}]*width:\s*30px;/s);
expect(styles).toMatch(/\.history-table-header-row > span\s*\{[^}]*text-align:\s*center;/s);
expect(styles).toMatch(/\.history-table-header-row > span:nth-child\(2\)\s*\{[^}]*text-align:\s*left;/s);
expect(styles).toMatch(/\.history-table-header-row > span,\s*\.history-row > span\s*\{[^}]*text-align:\s*left;/s);
expect(styles).toMatch(/\.history-table-header-row > span:nth-child\(4\),\s*\.history-row > span:nth-child\(4\)\s*\{[^}]*text-align:\s*right;/s);
expect(styles).toMatch(/\.history-row-action\s*\{[^}]*place-items:\s*center;/s);
});
it("renders each visual marker once, occupied main rows separately from closed detail rows and an honest footer", () => {
+163
View File
@@ -0,0 +1,163 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { defaultSettings } from "../src/main/constants";
import { DownloadManager } from "../src/main/download-manager";
import { createStoragePaths, emptySession } from "../src/main/storage";
import {
aggregateStatisticsRange,
createStatisticsLedger,
loadStatisticsLedger,
recordStatisticsActiveInterval,
recordStatisticsBytes,
recordStatisticsOutcome,
saveStatisticsLedger
} from "../src/main/statistics-ledger";
const roots: string[] = [];
afterEach(() => {
for (const root of roots.splice(0)) {
fs.rmSync(root, { recursive: true, force: true });
}
});
function localTime(day: number, hour = 12): number {
return new Date(2026, 7, day, hour, 0, 0, 0).getTime();
}
describe("statistics ledger", () => {
it("aggregates every available day inside a rolling seven-day window without requiring seven complete days", () => {
let ledger = createStatisticsLedger(localTime(10));
ledger = recordStatisticsBytes(ledger, "realdebrid", 100, localTime(7));
ledger = recordStatisticsOutcome(ledger, "realdebrid", "completed", localTime(7));
ledger = recordStatisticsBytes(ledger, "debridlink", 200, localTime(8));
ledger = recordStatisticsOutcome(ledger, "debridlink", "failed", localTime(8));
ledger = recordStatisticsBytes(ledger, "realdebrid", 300, localTime(10));
ledger = recordStatisticsOutcome(ledger, "realdebrid", "completed", localTime(10));
const aggregate = aggregateStatisticsRange(ledger, 7, localTime(10));
expect(aggregate).toMatchObject({
downloadedBytes: 600,
completedFiles: 2,
failedFiles: 1,
coveredDays: 3
});
expect(aggregate.providers).toEqual({
debridlink: { bytes: 200, completed: 0, failed: 1 },
realdebrid: { bytes: 400, completed: 2, failed: 0 }
});
});
it("excludes data outside the requested calendar window and computes average speed from measured active time", () => {
let ledger = createStatisticsLedger(localTime(10));
ledger = recordStatisticsBytes(ledger, "realdebrid", 8_000, localTime(10));
ledger = recordStatisticsActiveInterval(ledger, localTime(10, 10), localTime(10, 10) + 2_000);
ledger = recordStatisticsBytes(ledger, "debridlink", 99_000, localTime(3));
ledger = recordStatisticsActiveInterval(ledger, localTime(3, 10), localTime(3, 10) + 1_000);
const today = aggregateStatisticsRange(ledger, 1, localTime(10));
const week = aggregateStatisticsRange(ledger, 7, localTime(10));
const month = aggregateStatisticsRange(ledger, 30, localTime(10));
expect(today).toMatchObject({ downloadedBytes: 8_000, activeDownloadMs: 2_000, averageSpeedBps: 4_000 });
expect(week.downloadedBytes).toBe(8_000);
expect(month).toMatchObject({ downloadedBytes: 107_000, activeDownloadMs: 3_000 });
});
it("splits active intervals across local calendar days", () => {
const start = new Date(2026, 7, 9, 23, 59, 59, 500).getTime();
const end = new Date(2026, 7, 10, 0, 0, 0, 500).getTime();
const ledger = recordStatisticsActiveInterval(createStatisticsLedger(start), start, end);
expect(aggregateStatisticsRange(ledger, 1, localTime(10)).activeDownloadMs).toBe(500);
expect(aggregateStatisticsRange(ledger, 1, localTime(9)).activeDownloadMs).toBe(500);
});
it("persists normalized statistics and recovers safely from malformed files", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mdd-statistics-ledger-"));
roots.push(root);
const filePath = path.join(root, "rd_statistics.json");
const ledger = recordStatisticsOutcome(
recordStatisticsBytes(createStatisticsLedger(localTime(10)), "realdebrid", 4_096, localTime(10)),
"realdebrid",
"completed",
localTime(10)
);
saveStatisticsLedger(filePath, ledger);
expect(loadStatisticsLedger(filePath, localTime(10))).toEqual(ledger);
fs.writeFileSync(filePath, "{broken", "utf8");
expect(loadStatisticsLedger(filePath, localTime(11))).toEqual(createStatisticsLedger(localTime(11)));
});
it("retries transient Windows rename failures while preserving the statistics file", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mdd-statistics-rename-"));
roots.push(root);
const filePath = path.join(root, "rd_statistics.json");
const ledger = recordStatisticsBytes(createStatisticsLedger(localTime(10)), "realdebrid", 8_192, localTime(10));
const rename = fs.renameSync.bind(fs);
let attempts = 0;
const spy = vi.spyOn(fs, "renameSync").mockImplementation((source, target) => {
attempts += 1;
if (attempts < 3) {
throw Object.assign(new Error("busy"), { code: "EPERM" });
}
return rename(source, target);
});
saveStatisticsLedger(filePath, ledger);
expect(attempts).toBe(3);
expect(loadStatisticsLedger(filePath, localTime(10))).toEqual(ledger);
spy.mockRestore();
});
it("records provider bytes and terminal outcomes through the download manager and restores them after restart", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mdd-statistics-manager-"));
roots.push(root);
const paths = createStoragePaths(root);
const session = emptySession();
session.items.item = {
id: "item",
packageId: "package",
url: "https://example.test/file",
provider: "realdebrid",
providerLabel: "Real-Debrid",
status: "completed",
retries: 0,
speedBps: 0,
downloadedBytes: 4_096,
totalBytes: 4_096,
progressPercent: 100,
fileName: "file.bin",
targetPath: path.join(root, "file.bin"),
resumable: true,
attempts: 1,
lastError: "",
fullStatus: "Fertig",
createdAt: Date.now(),
updatedAt: Date.now()
};
const manager = new DownloadManager(defaultSettings(), session, paths);
(manager as any).runItemIds.add("item");
(manager as any).recordProviderDownloadedBytes("realdebrid", 4_096);
(manager as any).recordRunOutcome("item", "completed");
manager.persistNowSync();
const current = aggregateStatisticsRange(manager.getStats().statistics, 1);
expect(current).toMatchObject({ downloadedBytes: 4_096, completedFiles: 1, failedFiles: 0 });
expect(current.providers.realdebrid).toEqual({ bytes: 4_096, completed: 1, failed: 0 });
expect(fs.existsSync(paths.statisticsFile)).toBe(true);
const restored = new DownloadManager(defaultSettings(), emptySession(), paths);
expect(aggregateStatisticsRange(restored.getStats().statistics, 1)).toMatchObject({
downloadedBytes: 4_096,
completedFiles: 1
});
});
});
+62 -37
View File
@@ -5,9 +5,8 @@ import { describe, expect, it } from "vitest";
import type { DownloadItem, DownloadStatus, UiSnapshot } from "../src/shared/types";
import { appendBandwidthSample, readBandwidthChartPalette, readDownloadSpeedSparklinePalette } from "../src/renderer/App";
import {
buildStatisticsViewModel,
type StatisticsMetric,
type StatisticsRange
buildStatisticsViewModel,
type StatisticsMetric
} from "../src/renderer/views/statistics/statistics-model";
import {
StatisticsContent,
@@ -15,7 +14,13 @@ import {
StatisticsView,
type StatisticsViewActions
} from "../src/renderer/views/statistics/StatisticsView";
import { createVisualFixture } from "./visual/fixtures";
import { createVisualFixture } from "./visual/fixtures";
import {
createStatisticsLedger,
recordStatisticsActiveInterval,
recordStatisticsBytes,
recordStatisticsOutcome
} from "../src/main/statistics-ledger";
const now = new Date(2026, 7, 10, 12, 0, 0, 0).getTime();
@@ -183,23 +188,28 @@ describe("statistics model", () => {
expect(model.providers.some((row) => row.id.includes("host"))).toBe(false);
});
it("uses daily provider usage only for the matching local day", () => {
const snapshot = createSnapshot();
snapshot.stats.totalDownloaded = 999_999;
snapshot.settings.providerDailyUsageDay = "2026-08-10";
snapshot.settings.providerDailyUsageBytes = { realdebrid: 500, alldebrid: 1_500 };
it("uses persisted daily bytes, results and active time for every statistic shown today", () => {
const snapshot = createSnapshot();
let ledger = createStatisticsLedger(now);
ledger = recordStatisticsBytes(ledger, "realdebrid", 500, now);
ledger = recordStatisticsOutcome(ledger, "realdebrid", "completed", now);
ledger = recordStatisticsBytes(ledger, "alldebrid", 1_500, now);
ledger = recordStatisticsOutcome(ledger, "alldebrid", "failed", now);
ledger = recordStatisticsActiveInterval(ledger, now - 2_000, now);
snapshot.stats.statistics = ledger;
const model = buildStatisticsViewModel(snapshot, "today", now);
expect(model.metrics.downloadedBytes).toMatchObject({ value: 2_000, available: true });
expect(model.metrics.downloadedBytes).toMatchObject({ value: 2_000, available: true });
expect(model.metrics.files).toMatchObject({ value: 1, available: true });
expect(model.metrics.successRate).toMatchObject({ value: 50, available: true });
expect(model.metrics.errors).toMatchObject({ value: 1, available: true });
expect(model.metrics.averageSpeedBps).toMatchObject({ value: 1_000, available: true });
expect(model.providers.map((row) => [row.id, row.bytes])).toEqual([
["alldebrid", 1_500],
["realdebrid", 500]
]);
expectUnavailable(model.metrics.files);
expectUnavailable(model.metrics.successRate);
expectUnavailable(model.metrics.errors);
expectUnavailable(model.metrics.averageSpeedBps);
expect(model.providers.map((row) => [row.completed, row.failed])).toEqual([[0, 1], [1, 0]]);
});
it("treats a stale daily key as a genuine zero today without stale provider rows", () => {
@@ -213,28 +223,42 @@ describe("statistics model", () => {
expect(model.providers).toEqual([]);
});
it.each(["week", "month"] satisfies StatisticsRange[])("keeps %s unavailable without inventing historical buckets", (range) => {
const snapshot = createSnapshot();
snapshot.stats.totalDownloaded = 5_000;
snapshot.stats.totalDownloadedAllTime = 50_000;
snapshot.settings.providerDailyUsageDay = "2026-08-10";
snapshot.settings.providerDailyUsageBytes = { realdebrid: 4_000 };
snapshot.settings.providerTotalUsageBytes = { realdebrid: 40_000 };
const model = buildStatisticsViewModel(snapshot, range, now);
expect(model.coverage).toBe("unavailable");
expect(model.message).toBe("Für diesen Zeitraum werden noch keine historischen Daten gespeichert.");
expect(model.providers).toEqual([]);
expect(model.providerScope).toBeNull();
Object.values(model.metrics).forEach(expectUnavailable);
});
it("uses all-time counters and provider totals without inventing historical outcomes", () => {
it("sums every available day in seven-day and 30-day windows without waiting for a full period", () => {
const snapshot = createSnapshot();
let ledger = createStatisticsLedger(new Date(2026, 7, 3, 12).getTime());
ledger = recordStatisticsBytes(ledger, "realdebrid", 300, new Date(2026, 7, 3, 12).getTime());
ledger = recordStatisticsOutcome(ledger, "realdebrid", "completed", new Date(2026, 7, 3, 12).getTime());
ledger = recordStatisticsBytes(ledger, "debridlink", 700, new Date(2026, 7, 8, 12).getTime());
ledger = recordStatisticsOutcome(ledger, "debridlink", "failed", new Date(2026, 7, 8, 12).getTime());
ledger = recordStatisticsBytes(ledger, "realdebrid", 500, now);
ledger = recordStatisticsOutcome(ledger, "realdebrid", "completed", now);
snapshot.stats.statistics = ledger;
const week = buildStatisticsViewModel(snapshot, "week", now);
const month = buildStatisticsViewModel(snapshot, "month", now);
expect(week.metrics.downloadedBytes.value).toBe(1_200);
expect(week.metrics.files.value).toBe(1);
expect(week.metrics.errors.value).toBe(1);
expect(week.message).toContain("2 erfasste Tage");
expect(month.metrics.downloadedBytes.value).toBe(1_500);
expect(month.metrics.files.value).toBe(2);
expect(month.metrics.errors.value).toBe(1);
expect(month.message).toContain("3 erfasste Tage");
});
it("combines existing all-time counters with persisted outcomes, provider results and measured average speed", () => {
const snapshot = createSnapshot();
snapshot.stats.totalDownloadedAllTime = 25_000;
snapshot.stats.totalFilesAllTime = 42;
snapshot.settings.providerTotalUsageBytes = { realdebrid: 5_000, debridlink: 20_000 };
snapshot.settings.providerTotalUsageBytes = { realdebrid: 5_000, debridlink: 20_000 };
let ledger = createStatisticsLedger(now - 10_000);
ledger = recordStatisticsBytes(ledger, "realdebrid", 2_000, now);
ledger = recordStatisticsActiveInterval(ledger, now - 2_000, now);
ledger = recordStatisticsOutcome(ledger, "realdebrid", "completed", now);
ledger = recordStatisticsOutcome(ledger, "realdebrid", "completed", now);
ledger = recordStatisticsOutcome(ledger, "debridlink", "failed", now);
snapshot.stats.statistics = ledger;
snapshot.summary = {
total: 10,
success: 9,
@@ -253,10 +277,11 @@ describe("statistics model", () => {
["debridlink", 20_000],
["realdebrid", 5_000]
]);
expect(model.providers.every((row) => row.completed === null && row.failed === null)).toBe(true);
expectUnavailable(model.metrics.successRate);
expectUnavailable(model.metrics.errors);
expectUnavailable(model.metrics.averageSpeedBps);
expect(model.providers.map((row) => [row.completed, row.failed])).toEqual([[0, 1], [2, 0]]);
expect(model.metrics.successRate.available).toBe(true);
expect(model.metrics.successRate.value).toBeCloseTo(200 / 3);
expect(model.metrics.errors).toMatchObject({ value: 1, available: true });
expect(model.metrics.averageSpeedBps).toMatchObject({ value: 1_000, available: true });
});
it("prefers live queue outcomes over an old summary and uses the summary only after the run ends", () => {
+32 -2
View File
@@ -458,8 +458,38 @@ function createDenseSnapshot(): UiSnapshot {
sessionStartedAt: 1786309200000,
appSessionStartedAt: 1786309200000,
sessionRuntimeMs: 3600000,
totalRuntimeMs: 172800000,
runtimeMeasuredAt: 1786312800000
totalRuntimeMs: 172800000,
runtimeMeasuredAt: 1786312800000,
statistics: {
version: 1,
startedAt: 1786053600000,
days: [
{
day: "2026-08-08",
downloadedBytes: 182536110080,
measuredBytes: 182536110080,
completedFiles: 124,
failedFiles: 3,
activeDownloadMs: 21600000,
providers: {
realdebrid: { bytes: 123480309760, completed: 86, failed: 1 },
debridlink: { bytes: 59055800320, completed: 38, failed: 2 }
}
},
{
day: "2026-08-10",
downloadedBytes: 541165879488,
measuredBytes: 541165879488,
completedFiles: 310,
failedFiles: 2,
activeDownloadMs: 32400000,
providers: {
realdebrid: { bytes: 328565653504, completed: 192, failed: 1 },
debridlink: { bytes: 212600225984, completed: 118, failed: 1 }
}
}
]
}
};
snapshot.speedText = "12,0 MB/s";
snapshot.etaText = "00:17:24";