Add recurring daily schedule controls

Replace the one-time renderer schedule form with recurring local time and start-day settings while preserving legacy schedule visibility.

Keep the toolbar slot mounted and inaccessible while closed, animate width, opacity, and horizontal position through the global motion preference, and add localized labels.

Surface activation and cancellation failures, reconcile the authoritative snapshot after failed writes, and cover the behavior with focused red-green tests.
This commit is contained in:
Sucukdeluxe
2026-08-22 10:02:29 +02:00
parent 0ca00149eb
commit 30bedd17dd
6 changed files with 336 additions and 41 deletions
+121 -16
View File
@@ -111,6 +111,7 @@ import {
DownloadsSidebar,
DownloadsSidebarStatus,
DownloadsToolbar,
type DailyScheduleStartDay,
type DownloadsViewActions,
type DownloadsViewModel
} from "./views/downloads/DownloadsView";
@@ -1493,6 +1494,67 @@ export async function runLatestUpdateCheck(
await apply(result, generation);
}
interface DailySchedulePersistenceDependencies {
updateSettings: (update: RendererSettingsUpdate) => Promise<RendererSettings>;
getSnapshot: () => Promise<UiSnapshot>;
applySettings: (settings: RendererSettings) => void;
applySnapshot: (snapshot: UiSnapshot) => void;
showError: (message: string) => void;
}
function formatDailyScheduleLocalDate(date: Date): string {
const year = String(date.getFullYear()).padStart(4, "0");
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}
function formatDailyScheduleTime(minuteOfDay: number): string {
const minute = Math.max(0, Math.min(1_439, Math.floor(minuteOfDay)));
return `${String(Math.floor(minute / 60)).padStart(2, "0")}:${String(minute % 60).padStart(2, "0")}`;
}
export function buildDailyScheduleSettingsUpdate(
time: string,
startDay: DailyScheduleStartDay,
now = new Date()
): RendererSettingsUpdate | null {
const match = /^([01]\d|2[0-3]):([0-5]\d)$/.exec(time);
if (!match) {
return null;
}
const firstDate = new Date(now);
if (startDay === "tomorrow") {
firstDate.setDate(firstDate.getDate() + 1);
}
return {
dailyStartEnabled: true,
dailyStartMinuteOfDay: Number(match[1]) * 60 + Number(match[2]),
dailyStartFirstLocalDate: formatDailyScheduleLocalDate(firstDate)
};
}
export async function persistDailyScheduleSettingsUpdate(
update: RendererSettingsUpdate,
operation: "activate" | "cancel",
dependencies: DailySchedulePersistenceDependencies
): Promise<boolean> {
try {
const settings = await dependencies.updateSettings(update);
dependencies.applySettings(settings);
return true;
} catch (error) {
const action = operation === "activate" ? "aktiviert" : "abgebrochen";
dependencies.showError(`Zeitplan konnte nicht ${action} werden: ${String(error)}`);
try {
dependencies.applySnapshot(await dependencies.getSnapshot());
} catch (snapshotError) {
dependencies.showError(`Zeitplan konnte nicht abgeglichen werden: ${String(snapshotError)}`);
}
return false;
}
}
export function App(): ReactElement {
const [snapshot, setSnapshot] = useState<UiSnapshot>(emptySnapshot);
const [appVersion, setAppVersion] = useState("");
@@ -1509,6 +1571,7 @@ export function App(): ReactElement {
const [settingsSaveState, setSettingsSaveState] = useState<SettingsSaveState>("clean");
const [schedulePickerOpen, setSchedulePickerOpen] = useState(false);
const [scheduleTimeInput, setScheduleTimeInput] = useState("");
const [scheduleStartDay, setScheduleStartDay] = useState<DailyScheduleStartDay>("today");
const [scheduleCountdown, setScheduleCountdown] = useState("");
const [runtimeNow, setRuntimeNow] = useState(() => Date.now());
const updateCheckGenerationRef = useRef(0);
@@ -1730,7 +1793,9 @@ export function App(): ReactElement {
}, [settingsDraft.speedLimitKbps]);
useEffect(() => {
const schedMs = snapshot.settings.scheduledStartEpochMs || 0;
const schedMs = snapshot.settings.dailyStartEnabled
? snapshot.settings.nextDailyStartEpochMs
: snapshot.settings.scheduledStartEpochMs || 0;
if (schedMs <= 0) { setScheduleCountdown(""); return; }
const update = (): void => {
const remaining = schedMs - Date.now();
@@ -1744,7 +1809,7 @@ export function App(): ReactElement {
update();
const timer = setInterval(update, 1000);
return () => clearInterval(timer);
}, [snapshot.settings.scheduledStartEpochMs]);
}, [snapshot.settings.dailyStartEnabled, snapshot.settings.nextDailyStartEpochMs, snapshot.settings.scheduledStartEpochMs]);
useEffect(() => {
const timer = setInterval(() => setRuntimeNow(Date.now()), 1000);
@@ -4677,16 +4742,43 @@ export function App(): ReactElement {
});
}, [setClipboardWatcherActive, showToast]);
const applyDailyScheduleSettings = useCallback((settings: RendererSettings): void => {
const apply = (current: UiSnapshot): UiSnapshot => ({ ...current, settings });
const next = apply(snapshotRef.current);
snapshotRef.current = next;
masterSnapshotRef.current = masterSnapshotRef.current ? apply(masterSnapshotRef.current) : next;
if (latestStateRef.current) {
latestStateRef.current = apply(latestStateRef.current);
}
setSnapshot(next);
}, []);
const applyAuthoritativeDailyScheduleSnapshot = useCallback((state: UiSnapshot): void => {
masterSnapshotRef.current = state;
latestStateRef.current = null;
snapshotRef.current = state;
setSnapshot(state);
}, []);
const persistDownloadSchedule = useCallback((update: RendererSettingsUpdate, operation: "activate" | "cancel"): Promise<boolean> => (
persistDailyScheduleSettingsUpdate(update, operation, {
updateSettings: (value) => window.rd.updateSettings(value),
getSnapshot: () => window.rd.getSnapshot(),
applySettings: applyDailyScheduleSettings,
applySnapshot: applyAuthoritativeDailyScheduleSnapshot,
showError: (message) => showToast(message, 3200)
})
), [applyAuthoritativeDailyScheduleSnapshot, applyDailyScheduleSettings, showToast]);
const activateDownloadSchedule = useCallback((): void => {
if (!scheduleTimeInput) return;
const [hours, minutes] = scheduleTimeInput.split(":").map(Number);
const now = new Date();
const target = new Date(now);
target.setHours(hours, minutes, 0, 0);
if (target.getTime() <= now.getTime()) target.setDate(target.getDate() + 1);
void window.rd.updateSettings({ scheduledStartEpochMs: target.getTime() }).catch(() => {});
const update = buildDailyScheduleSettingsUpdate(scheduleTimeInput, scheduleStartDay);
if (!update) return;
void persistDownloadSchedule(update, "activate").then((persisted) => {
if (persisted) {
setSchedulePickerOpen(false);
}, [scheduleTimeInput]);
}
});
}, [persistDownloadSchedule, scheduleStartDay, scheduleTimeInput]);
const removeActionableDownloads = useCallback((): void => {
const ids = new Set(downloadsViewCore.actionableSelectedIds);
@@ -4717,10 +4809,15 @@ export function App(): ReactElement {
reconnectSeconds: snapshot.reconnectSeconds,
reconnectReason: snapshot.session.reconnectReason,
clipboardWatcher: snapshot.clipboardActive,
scheduleActive: snapshot.settings.scheduledStartEpochMs > 0,
scheduleActive: snapshot.settings.dailyStartEnabled || snapshot.settings.scheduledStartEpochMs > 0,
scheduleOpen: schedulePickerOpen,
scheduleTime: scheduleTimeInput,
scheduleLabel: scheduleCountdown || (snapshot.settings.scheduledStartEpochMs > 0 ? new Date(snapshot.settings.scheduledStartEpochMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) : ""),
scheduleStartDay,
scheduleLabel: scheduleCountdown || (snapshot.settings.dailyStartEnabled
? formatDailyScheduleTime(snapshot.settings.dailyStartMinuteOfDay)
: snapshot.settings.scheduledStartEpochMs > 0
? new Date(snapshot.settings.scheduledStartEpochMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
: ""),
packageSpeedBps: downloadPackageSpeeds,
editingPackageId,
editingName,
@@ -4744,7 +4841,7 @@ export function App(): ReactElement {
speed: liveDownloadSpeedBps > 0 ? formatSpeedMbps(liveDownloadSpeedBps) : "0 B/s",
eta: snapshot.etaText
}
}), [actionBusy, columnOrder, downloadDisclosureRevision, downloadPackageSpeeds, downloadQueueTotalBytes, downloadRemaining, downloadsSortColumn, downloadsSortDescending, downloadsViewCore, editingName, editingPackageId, gridTemplate, liveDownloadSpeedBps, providerStats.length, scheduleCountdown, schedulePickerOpen, scheduleTimeInput, snapshot.canPause, snapshot.canStart, snapshot.canStop, snapshot.clipboardActive, snapshot.etaText, snapshot.reconnectSeconds, snapshot.session.items, snapshot.session.paused, snapshot.session.reconnectReason, snapshot.session.running, snapshot.settings.animatePackageDisclosure, snapshot.settings.scheduledStartEpochMs, snapshot.stats.totalDownloaded, snapshot.stats.totalPackages]);
}), [actionBusy, columnOrder, downloadDisclosureRevision, downloadPackageSpeeds, downloadQueueTotalBytes, downloadRemaining, downloadsSortColumn, downloadsSortDescending, downloadsViewCore, editingName, editingPackageId, gridTemplate, liveDownloadSpeedBps, providerStats.length, scheduleCountdown, schedulePickerOpen, scheduleStartDay, scheduleTimeInput, snapshot.canPause, snapshot.canStart, snapshot.canStop, snapshot.clipboardActive, snapshot.etaText, snapshot.reconnectSeconds, snapshot.session.items, snapshot.session.paused, snapshot.session.reconnectReason, snapshot.session.running, snapshot.settings.animatePackageDisclosure, snapshot.settings.dailyStartEnabled, snapshot.settings.dailyStartMinuteOfDay, snapshot.settings.scheduledStartEpochMs, snapshot.stats.totalDownloaded, snapshot.stats.totalPackages]);
const resetColumnLayout = useCallback((): void => {
if (columnDragSettleTimerRef.current !== null) {
@@ -4785,12 +4882,20 @@ export function App(): ReactElement {
},
onStopDownloads: () => { void performQuickAction(() => window.rd.stop()); },
onToggleSchedule: () => {
setSchedulePickerOpen((current) => !current);
setScheduleTimeInput("");
if (!schedulePickerOpen) {
const now = new Date();
const minuteOfDay = snapshot.settings.dailyStartEnabled
? snapshot.settings.dailyStartMinuteOfDay
: now.getHours() * 60 + now.getMinutes();
setScheduleTimeInput(formatDailyScheduleTime(minuteOfDay));
setScheduleStartDay("today");
}
setSchedulePickerOpen(!schedulePickerOpen);
},
onScheduleTimeChange: setScheduleTimeInput,
onScheduleStartDayChange: setScheduleStartDay,
onActivateSchedule: activateDownloadSchedule,
onCancelSchedule: () => { void window.rd.updateSettings({ scheduledStartEpochMs: 0 }).catch(() => {}); },
onCancelSchedule: () => { void persistDownloadSchedule({ dailyStartEnabled: false }, "cancel"); },
onMoveSelectionUp: () => moveSelectedPackages("up", downloadsViewCore.actionableSelectedIds),
onMoveSelectionDown: () => moveSelectedPackages("down", downloadsViewCore.actionableSelectedIds),
onRenameSelection: () => {
+2 -2
View File
@@ -63,7 +63,7 @@ const pairs = [
["Abgeschlossene und gelöschte Pakete erscheinen hier.", "Completed and deleted packages appear here."], ["Passe Filter oder Suche an.", "Adjust the filter or search."], ["Öffne die Ansicht erneut, um es noch einmal zu versuchen.", "Open the view again to retry."],
["Alle sichtbaren Einträge auswählen", "Select all visible entries"], ["Details anzeigen", "Show details"], ["Details ausblenden", "Hide details"],
["Sichtbar:", "Visible:"], ["pro Seite", "per page"],
["Verfügbarkeit", "Availability"], ["Hinzugefügt am", "Added on"], ["Ungeprüft", "Unchecked"], ["Paket gestoppt", "Package stopped"], ["Alle anzeigen", "Show all"], ["Planen", "Schedule"], ["Startzeit", "Start time"],
["Verfügbarkeit", "Availability"], ["Hinzugefügt am", "Added on"], ["Ungeprüft", "Unchecked"], ["Paket gestoppt", "Package stopped"], ["Alle anzeigen", "Show all"], ["Planen", "Schedule"], ["Startzeit", "Start time"], ["Starttag", "Start day"], ["Ab heute", "Starting today"], ["Ab morgen", "Starting tomorrow"],
["Keine Downloads", "No downloads"], ["Keine passenden Downloads", "No matching downloads"], ["Füge Links hinzu, um Downloads vorzubereiten.", "Add links to prepare downloads."], ["Passe Filter oder Suche an.", "Adjust the filter or search."],
["Keine Links gesammelt", "No links collected"], ["Keine passenden Links", "No matching links"], ["Füge Links oder Text ein, um sie zu sammeln.", "Paste links or text to collect them."], ["Links durchsuchen", "Search links"],
["Datenmenge", "Data volume"], ["Sitzungszähler", "Session counter"], ["Sieben Tage", "Seven days"], ["30 Tage", "30 days"], ["Zeitraum", "Period"], ["Erfolgreich", "Successful"],
@@ -215,7 +215,7 @@ const prefixedPairs = [
["Sicherung laden fehlgeschlagen: ", "Loading backup failed: "], ["Support-Bundle fehlgeschlagen: ", "Support bundle failed: "], ["Support-Trace fehlgeschlagen: ", "Support trace failed: "],
["Debug-Setup-Check fehlgeschlagen: ", "Debug setup check failed: "], ["Fehler-Ansicht fehlgeschlagen: ", "Error view failed: "], ["Token-Rotation fehlgeschlagen: ", "Token rotation failed: "],
["Ferndiagnose-Status fehlgeschlagen: ", "Remote diagnostics status failed: "], ["Aktivieren fehlgeschlagen: ", "Enabling failed: "], ["Deaktivieren fehlgeschlagen: ", "Disabling failed: "],
["Session-Reset fehlgeschlagen: ", "Session reset failed: "], ["Download-Reset fehlgeschlagen: ", "Download reset failed: "]
["Session-Reset fehlgeschlagen: ", "Session reset failed: "], ["Download-Reset fehlgeschlagen: ", "Download reset failed: "], ["Zeitplan konnte nicht aktiviert werden: ", "Schedule could not be activated: "], ["Zeitplan konnte nicht abgebrochen werden: ", "Schedule could not be cancelled: "], ["Zeitplan konnte nicht abgeglichen werden: ", "Schedule could not be reconciled: "]
] as const;
export function normalizeLanguage(value: unknown): AppLanguage {
+13 -2
View File
@@ -12,6 +12,8 @@ import "./downloads.css";
const integerFormatter = new Intl.NumberFormat("de-DE", { maximumFractionDigits: 0 });
export type DailyScheduleStartDay = "today" | "tomorrow";
export interface DownloadsStatusModel {
packages: number;
links: number;
@@ -40,6 +42,7 @@ export interface DownloadsViewModel extends DownloadsViewModelCore {
scheduleActive: boolean;
scheduleOpen: boolean;
scheduleTime: string;
scheduleStartDay: DailyScheduleStartDay;
scheduleLabel: string;
packageSpeedBps: Record<string, number>;
editingPackageId: string | null;
@@ -65,6 +68,7 @@ export interface DownloadsViewActions extends DownloadsTableActions {
onStopDownloads: () => void;
onToggleSchedule: () => void;
onScheduleTimeChange: (value: string) => void;
onScheduleStartDayChange: (value: DailyScheduleStartDay) => void;
onActivateSchedule: () => void;
onCancelSchedule: () => void;
onMoveSelectionUp: () => void;
@@ -125,14 +129,21 @@ export function DownloadsToolbar({ actions, model }: { actions: DownloadsViewAct
const hasSelection = model.actionableSelectedIds.length > 0;
const hasSelectedPackage = model.actionableSelectedPackageIds.length > 0;
const onePackage = model.actionableSelectedPackageIds.length === 1 && model.actionableSelectedIds.length === 1;
const scheduleSlotOpen = model.scheduleActive || model.scheduleOpen;
const scheduleSlotClass = `downloads-schedule-slot ${scheduleSlotOpen ? "is-open" : "is-closed"}${model.animationsEnabled ? "" : " is-motion-disabled"}`;
return (
<div className="downloads-toolbar" data-visual-region="downloads-toolbar">
<button disabled={model.actionBusy || !model.canStart} onClick={actions.onStartDownloads} type="button">Start</button>
<button disabled={!model.canPause || model.paused} onClick={actions.onPauseDownloads} type="button">Pause</button>
<button disabled={!model.canStop || model.actionBusy} onClick={actions.onStopDownloads} type="button">Stop</button>
{!model.scheduleActive ? <button aria-expanded={model.scheduleOpen} onClick={actions.onToggleSchedule} type="button">Zeitplan</button> : null}
<span className={scheduleSlotClass}>
<span {...(!scheduleSlotOpen ? { inert: "true" } : {})} aria-hidden={!scheduleSlotOpen} className="downloads-schedule-controls">
{model.scheduleActive
? <span className="downloads-schedule-controls"><strong>Geplant: {model.scheduleLabel}</strong><button disabled={false} onClick={actions.onCancelSchedule} type="button">Abbrechen</button></span>
: <><button aria-expanded={model.scheduleOpen} onClick={actions.onToggleSchedule} type="button">Zeitplan</button>{model.scheduleOpen ? <span className="downloads-schedule-controls"><input aria-label="Startzeit" onChange={(event) => actions.onScheduleTimeChange(event.target.value)} type="time" value={model.scheduleTime} /><button onClick={actions.onActivateSchedule} type="button">Planen</button></span> : null}</>}
? <><strong>Geplant: {model.scheduleLabel}</strong><button disabled={false} onClick={actions.onCancelSchedule} type="button">Abbrechen</button></>
: <><input aria-label="Startzeit" disabled={!scheduleSlotOpen} onChange={(event) => actions.onScheduleTimeChange(event.target.value)} type="time" value={model.scheduleTime} /><select aria-label="Starttag" disabled={!scheduleSlotOpen} onChange={(event) => actions.onScheduleStartDayChange(event.target.value as DailyScheduleStartDay)} value={model.scheduleStartDay}><option value="today">Ab heute</option><option value="tomorrow">Ab morgen</option></select><button disabled={!scheduleSlotOpen} onClick={actions.onActivateSchedule} type="button">Planen</button></>}
</span>
</span>
<span className="downloads-toolbar-divider" />
<button disabled={!hasSelectedPackage} onClick={actions.onMoveSelectionUp} type="button">Nach oben</button>
<button disabled={!hasSelectedPackage} onClick={actions.onMoveSelectionDown} type="button">Nach unten</button>
+33 -1
View File
@@ -237,13 +237,45 @@
background: var(--ui-border);
}
.downloads-schedule-slot {
display: grid;
grid-template-columns: 0fr;
min-width: 0;
overflow: hidden;
opacity: 0;
pointer-events: none;
transition: grid-template-columns 180ms ease, opacity 140ms ease;
}
.downloads-schedule-slot.is-open {
grid-template-columns: 1fr;
opacity: 1;
pointer-events: auto;
}
.downloads-schedule-controls {
display: flex;
align-items: center;
gap: 5px;
min-width: 0;
width: max-content;
overflow: hidden;
white-space: nowrap;
transform: translateX(-10px);
transition: transform 180ms ease;
}
.downloads-schedule-controls input {
.downloads-schedule-slot.is-open .downloads-schedule-controls {
transform: translateX(0);
}
.downloads-schedule-slot.is-motion-disabled,
.downloads-schedule-slot.is-motion-disabled .downloads-schedule-controls {
transition: none !important;
}
.downloads-schedule-controls input,
.downloads-schedule-controls select {
height: 36px;
}
+67 -2
View File
@@ -42,7 +42,8 @@ import {
DownloadsSidebarStatus,
DownloadsToolbar,
DownloadsView,
type DownloadsViewActions
type DownloadsViewActions,
type DownloadsViewModel
} from "../src/renderer/views/downloads/DownloadsView";
import {
DownloadsTableHeader,
@@ -711,6 +712,7 @@ function createActions(overrides: Partial<DownloadsViewActions> = {}): Downloads
onStopDownloads: () => {},
onToggleSchedule: () => {},
onScheduleTimeChange: () => {},
onScheduleStartDayChange: () => {},
onActivateSchedule: () => {},
onCancelSchedule: () => {},
onMoveSelectionUp: () => {},
@@ -774,7 +776,7 @@ function findButton(node: ReactNode, label: string): ReactElement {
return findElement(node, (element) => element.type === "button" && element.props.children === label);
}
function withRuntime(input: DownloadsModelInput, overrides: Record<string, unknown> = {}) {
function withRuntime(input: DownloadsModelInput, overrides: Partial<DownloadsViewModel> = {}): DownloadsViewModel {
return {
...buildDownloadsViewModel(input),
running: true,
@@ -789,6 +791,7 @@ function withRuntime(input: DownloadsModelInput, overrides: Record<string, unkno
scheduleActive: false,
scheduleOpen: false,
scheduleTime: "23:30",
scheduleStartDay: "today",
scheduleLabel: "",
packageSpeedBps: { "package-a": 12_000_000 },
disclosureRevision: 0,
@@ -1186,6 +1189,68 @@ describe("downloads view", () => {
expect(findButton(toolbar, "Abbrechen").props.disabled).toBe(false);
});
it("keeps closed schedule controls mounted, hidden and unreachable", () => {
const toolbar = DownloadsToolbar({
actions: createActions(),
model: withRuntime(createInput(), { scheduleActive: false, scheduleOpen: false })
});
const slot = findElement(toolbar, (element) => String(element.props.className || "").includes("downloads-schedule-slot"));
const controls = findElement(toolbar, (element) => String(element.props.className || "").includes("downloads-schedule-controls"));
const timeInput = findElement(toolbar, (element) => element.type === "input" && element.props["aria-label"] === "Startzeit");
const daySelect = findElement(toolbar, (element) => element.type === "select" && element.props["aria-label"] === "Starttag");
expect(findButton(toolbar, "Zeitplan").props["aria-expanded"]).toBe(false);
expect(slot.props.className).toContain("is-closed");
expect(controls.props["aria-hidden"]).toBe(true);
expect(controls.props.inert).toBe("true");
expect(renderToStaticMarkup(toolbar)).toContain('inert="true"');
expect(timeInput.props.disabled).toBe(true);
expect(daySelect.props.disabled).toBe(true);
expect(findButton(toolbar, "Planen").props.disabled).toBe(true);
});
it("collects local time and the selected first start day from the open schedule slot", () => {
const calls: string[] = [];
const toolbar = DownloadsToolbar({
actions: createActions({
onScheduleTimeChange: (value) => calls.push(`time:${value}`),
onScheduleStartDayChange: (value) => calls.push(`day:${value}`)
}),
model: withRuntime(createInput(), { scheduleOpen: true, scheduleStartDay: "tomorrow", scheduleTime: "08:15" })
});
const controls = findElement(toolbar, (element) => String(element.props.className || "").includes("downloads-schedule-controls"));
const timeInput = findElement(toolbar, (element) => element.type === "input" && element.props["aria-label"] === "Startzeit");
const daySelect = findElement(toolbar, (element) => element.type === "select" && element.props["aria-label"] === "Starttag");
timeInput.props.onChange({ target: { value: "09:45" } });
daySelect.props.onChange({ target: { value: "today" } });
expect(findButton(toolbar, "Zeitplan").props["aria-expanded"]).toBe(true);
expect(controls.props["aria-hidden"]).toBe(false);
expect(controls.props.inert).toBeUndefined();
expect(timeInput.props.value).toBe("08:15");
expect(daySelect.props.value).toBe("tomorrow");
expect(renderToStaticMarkup(toolbar)).toContain("Ab heute");
expect(renderToStaticMarkup(toolbar)).toContain("Ab morgen");
expect(calls).toEqual(["time:09:45", "day:today"]);
});
it("animates the persistent schedule slot horizontally and disables it through the global motion setting", () => {
const closed = renderToStaticMarkup(<DownloadsToolbar actions={createActions()} model={withRuntime(createInput())} />);
const open = renderToStaticMarkup(<DownloadsToolbar actions={createActions()} model={withRuntime(createInput(), { scheduleOpen: true })} />);
const motionDisabled = renderToStaticMarkup(<DownloadsToolbar actions={createActions()} model={withRuntime(createInput(), { animationsEnabled: false, scheduleOpen: true })} />);
const css = readFileSync(new URL("../src/renderer/views/downloads/downloads.css", import.meta.url), "utf8");
expect(closed).toContain("downloads-schedule-slot is-closed");
expect(open).toContain("downloads-schedule-slot is-open");
expect(motionDisabled).toContain("downloads-schedule-slot is-open is-motion-disabled");
expect(css).toMatch(/\.downloads-schedule-slot\s*\{[^}]*grid-template-columns:\s*0fr;[^}]*opacity:\s*0;[^}]*transition:/s);
expect(css).toMatch(/\.downloads-schedule-slot\.is-open\s*\{[^}]*grid-template-columns:\s*1fr;[^}]*opacity:\s*1;/s);
expect(css).toMatch(/\.downloads-schedule-controls\s*\{[^}]*transform:\s*translateX\(-\d+px\);[^}]*transition:\s*transform/s);
expect(css).toMatch(/\.downloads-schedule-slot\.is-open \.downloads-schedule-controls\s*\{[^}]*transform:\s*translateX\(0\);/s);
expect(css).toMatch(/\.downloads-schedule-slot\.is-motion-disabled[^\{]*\{[^}]*transition:\s*none !important;/s);
});
it("keeps the table header and all rows in one horizontal scroll context with exact dense geometry", () => {
const html = renderToStaticMarkup(<DownloadsView actions={createActions()} model={withRuntime(createInput())} />);
const css = readFileSync(new URL("../src/renderer/views/downloads/downloads.css", import.meta.url), "utf8");
+82
View File
@@ -0,0 +1,82 @@
import { describe, expect, it, vi } from "vitest";
import type { RendererSettings, UiSnapshot } from "../src/shared/types";
import {
buildDailyScheduleSettingsUpdate,
persistDailyScheduleSettingsUpdate
} from "../src/renderer/App";
describe("daily schedule settings form", () => {
it("maps local time and the chosen start day to the recurring schedule settings", () => {
const now = new Date(2026, 7, 22, 18, 30, 0, 0);
expect(buildDailyScheduleSettingsUpdate("08:15", "today", now)).toEqual({
dailyStartEnabled: true,
dailyStartMinuteOfDay: 8 * 60 + 15,
dailyStartFirstLocalDate: "2026-08-22"
});
expect(buildDailyScheduleSettingsUpdate("23:45", "tomorrow", now)).toEqual({
dailyStartEnabled: true,
dailyStartMinuteOfDay: 23 * 60 + 45,
dailyStartFirstLocalDate: "2026-08-23"
});
});
it("applies persisted settings after a successful activation", async () => {
const persisted = { dailyStartEnabled: true } as RendererSettings;
const updateSettings = vi.fn().mockResolvedValue(persisted);
const applySettings = vi.fn();
const getSnapshot = vi.fn();
const applySnapshot = vi.fn();
const showError = vi.fn();
const update = {
dailyStartEnabled: true,
dailyStartMinuteOfDay: 495,
dailyStartFirstLocalDate: "2026-08-23"
};
await expect(persistDailyScheduleSettingsUpdate(update, "activate", {
updateSettings,
getSnapshot,
applySettings,
applySnapshot,
showError
})).resolves.toBe(true);
expect(updateSettings).toHaveBeenCalledWith(update);
expect(applySettings).toHaveBeenCalledWith(persisted);
expect(getSnapshot).not.toHaveBeenCalled();
expect(showError).not.toHaveBeenCalled();
});
it.each([
["activate" as const, "Zeitplan konnte nicht aktiviert werden: Error: Speichern fehlgeschlagen"],
["cancel" as const, "Zeitplan konnte nicht abgebrochen werden: Error: Speichern fehlgeschlagen"]
])("shows %s failures before reconciling the authoritative snapshot", async (operation, expectedMessage) => {
const authoritative = { settings: { dailyStartEnabled: false } } as UiSnapshot;
const sequence: string[] = [];
const updateSettings = vi.fn().mockRejectedValue(new Error("Speichern fehlgeschlagen"));
const getSnapshot = vi.fn(async () => {
sequence.push("snapshot");
return authoritative;
});
const applySnapshot = vi.fn((snapshot: UiSnapshot) => {
sequence.push(`apply:${String(snapshot.settings.dailyStartEnabled)}`);
});
const showError = vi.fn((message: string) => {
sequence.push(`error:${message}`);
});
await expect(persistDailyScheduleSettingsUpdate({ dailyStartEnabled: false }, operation, {
updateSettings,
getSnapshot,
applySettings: vi.fn(),
applySnapshot,
showError
})).resolves.toBe(false);
expect(showError).toHaveBeenCalledWith(expectedMessage);
expect(getSnapshot).toHaveBeenCalledTimes(1);
expect(applySnapshot).toHaveBeenCalledWith(authoritative);
expect(sequence).toEqual([`error:${expectedMessage}`, "snapshot", "apply:false"]);
});
});