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
+127 -22
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("");
@@ -1507,9 +1569,10 @@ export function App(): ReactElement {
const [scheduleSpeedInputs, setScheduleSpeedInputs] = useState<Record<string, string>>({});
const [settingsDirty, setSettingsDirty] = useState(false);
const [settingsSaveState, setSettingsSaveState] = useState<SettingsSaveState>("clean");
const [schedulePickerOpen, setSchedulePickerOpen] = useState(false);
const [scheduleTimeInput, setScheduleTimeInput] = useState("");
const [scheduleCountdown, setScheduleCountdown] = useState("");
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);
const dismissedUpdateTagRef = useRef("");
@@ -1729,9 +1792,11 @@ export function App(): ReactElement {
setSpeedLimitInput(formatMbpsInputFromKbps(settingsDraft.speedLimitKbps));
}, [settingsDraft.speedLimitKbps]);
useEffect(() => {
const schedMs = snapshot.settings.scheduledStartEpochMs || 0;
if (schedMs <= 0) { setScheduleCountdown(""); return; }
useEffect(() => {
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();
if (remaining <= 0) { setScheduleCountdown(""); return; }
@@ -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(() => {});
setSchedulePickerOpen(false);
}, [scheduleTimeInput]);
const update = buildDailyScheduleSettingsUpdate(scheduleTimeInput, scheduleStartDay);
if (!update) return;
void persistDownloadSchedule(update, "activate").then((persisted) => {
if (persisted) {
setSchedulePickerOpen(false);
}
});
}, [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 {
+25 -14
View File
@@ -11,6 +11,8 @@ import { VirtualizedDownloadsBody } from "./VirtualizedDownloadsBody";
import "./downloads.css";
const integerFormatter = new Intl.NumberFormat("de-DE", { maximumFractionDigits: 0 });
export type DailyScheduleStartDay = "today" | "tomorrow";
export interface DownloadsStatusModel {
packages: number;
@@ -38,9 +40,10 @@ export interface DownloadsViewModel extends DownloadsViewModelCore {
reconnectReason: string;
clipboardWatcher: boolean;
scheduleActive: boolean;
scheduleOpen: boolean;
scheduleTime: string;
scheduleLabel: string;
scheduleOpen: boolean;
scheduleTime: string;
scheduleStartDay: DailyScheduleStartDay;
scheduleLabel: string;
packageSpeedBps: Record<string, number>;
editingPackageId: string | null;
editingName: string;
@@ -63,9 +66,10 @@ export interface DownloadsViewActions extends DownloadsTableActions {
onStartDownloads: () => void;
onPauseDownloads: () => void;
onStopDownloads: () => void;
onToggleSchedule: () => void;
onScheduleTimeChange: (value: string) => void;
onActivateSchedule: () => void;
onToggleSchedule: () => void;
onScheduleTimeChange: (value: string) => void;
onScheduleStartDayChange: (value: DailyScheduleStartDay) => void;
onActivateSchedule: () => void;
onCancelSchedule: () => void;
onMoveSelectionUp: () => void;
onMoveSelectionDown: () => void;
@@ -121,18 +125,25 @@ export function DownloadsSidebarStatus({ model }: { model: DownloadsViewModel })
})}<div><span>Geschwindigkeit</span><strong data-status-metric="speed">{speed}</strong></div><div><span>ETA</span><strong data-status-metric="eta">{eta}</strong></div></section>;
}
export function DownloadsToolbar({ actions, model }: { actions: DownloadsViewActions; model: DownloadsViewModel }): ReactElement {
const hasSelection = model.actionableSelectedIds.length > 0;
const hasSelectedPackage = model.actionableSelectedPackageIds.length > 0;
const onePackage = model.actionableSelectedPackageIds.length === 1 && model.actionableSelectedIds.length === 1;
return (
export function DownloadsToolbar({ actions, model }: { actions: DownloadsViewActions; model: DownloadsViewModel }): ReactElement {
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
? <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}</>}
{!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
? <><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;
}