From 11f2c27f7f3bcce1045ae636bbc02b8d10d54e9b Mon Sep 17 00:00:00 2001 From: Alessandro Zappia Date: Fri, 19 Dec 2025 21:24:31 +0100 Subject: [PATCH 1/4] add monthly backup frequency with multi-day selection --- .../components/create-schedule-form.tsx | 53 +++++++++++++++++-- .../modules/backups/routes/backup-details.tsx | 2 +- .../modules/backups/routes/create-backup.tsx | 2 +- app/utils/utils.ts | 8 ++- 4 files changed, 58 insertions(+), 7 deletions(-) diff --git a/app/client/modules/backups/components/create-schedule-form.tsx b/app/client/modules/backups/components/create-schedule-form.tsx index 56457b93..4ae1faf5 100644 --- a/app/client/modules/backups/components/create-schedule-form.tsx +++ b/app/client/modules/backups/components/create-schedule-form.tsx @@ -1,4 +1,5 @@ import { arktypeResolver } from "@hookform/resolvers/arktype"; + import { useQuery } from "@tanstack/react-query"; import { type } from "arktype"; import { useCallback, useState } from "react"; @@ -18,6 +19,7 @@ import { } from "~/client/components/ui/form"; import { Input } from "~/client/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select"; +import { Button } from "~/client/components/ui/button"; import { Textarea } from "~/client/components/ui/textarea"; import { VolumeFileBrowser } from "~/client/components/volume-file-browser"; import type { BackupSchedule, Volume } from "~/client/lib/types"; @@ -33,6 +35,7 @@ const internalFormSchema = type({ frequency: "string", dailyTime: "string?", weeklyDay: "string?", + monthlyDays: "string[]?", keepLast: "number?", keepHourly: "number?", keepDaily: "number?", @@ -78,15 +81,19 @@ const backupScheduleToFormValues = (schedule?: BackupSchedule): InternalFormValu } const parts = schedule.cronExpression.split(" "); - const [minutePart, hourPart, , , dayOfWeekPart] = parts; + const [minutePart, hourPart, dayOfMonthPart, , dayOfWeekPart] = parts; const isHourly = hourPart === "*"; - const isDaily = !isHourly && dayOfWeekPart === "*"; - const frequency = isHourly ? "hourly" : isDaily ? "daily" : "weekly"; + const isMonthly = !isHourly && dayOfMonthPart !== "*" && dayOfWeekPart === "*"; + const isDaily = !isHourly && dayOfMonthPart === "*" && dayOfWeekPart === "*"; + + const frequency = isHourly ? "hourly" : isMonthly ? "monthly" : isDaily ? "daily" : "weekly"; const dailyTime = isHourly ? undefined : `${hourPart.padStart(2, "0")}:${minutePart.padStart(2, "0")}`; const weeklyDay = frequency === "weekly" ? dayOfWeekPart : undefined; + + const monthlyDays = isMonthly ? dayOfMonthPart.split(",") : undefined; const patterns = schedule.includePatterns || []; const isGlobPattern = (p: string) => /[*?[\]]/.test(p); @@ -96,7 +103,8 @@ const backupScheduleToFormValues = (schedule?: BackupSchedule): InternalFormValu return { name: schedule.name, repositoryId: schedule.repositoryId, - frequency, + frequency, + monthlyDays, dailyTime, weeklyDay, includePatterns: fileBrowserPaths.length > 0 ? fileBrowserPaths : undefined, @@ -249,6 +257,7 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }: Hourly Daily Weekly + Monthly @@ -302,6 +311,42 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }: )} /> )} + {frequency === "monthly" && ( + ( + + Days of the month + +
+ {Array.from({ length: 31 }, (_, i) => { + const day = (i + 1).toString(); + const isSelected = field.value?.includes(day); + return ( + + ); + })} +
+
+ Select one or more days when the backup should run. + +
+ )} + /> + )} diff --git a/app/client/modules/backups/routes/backup-details.tsx b/app/client/modules/backups/routes/backup-details.tsx index 9ddecf1f..dd67883c 100644 --- a/app/client/modules/backups/routes/backup-details.tsx +++ b/app/client/modules/backups/routes/backup-details.tsx @@ -141,7 +141,7 @@ export default function ScheduleDetailsPage({ params, loaderData }: Route.Compon const handleSubmit = (formValues: BackupScheduleFormValues) => { if (!schedule) return; - const cronExpression = getCronExpression(formValues.frequency, formValues.dailyTime, formValues.weeklyDay); + const cronExpression = getCronExpression(formValues.frequency, formValues.dailyTime, formValues.weeklyDay, formValues.monthlyDays); const retentionPolicy: Record = {}; if (formValues.keepLast) retentionPolicy.keepLast = formValues.keepLast; diff --git a/app/client/modules/backups/routes/create-backup.tsx b/app/client/modules/backups/routes/create-backup.tsx index f0592b67..4c51ac34 100644 --- a/app/client/modules/backups/routes/create-backup.tsx +++ b/app/client/modules/backups/routes/create-backup.tsx @@ -71,7 +71,7 @@ export default function CreateBackup({ loaderData }: Route.ComponentProps) { const handleSubmit = (formValues: BackupScheduleFormValues) => { if (!selectedVolumeId) return; - const cronExpression = getCronExpression(formValues.frequency, formValues.dailyTime, formValues.weeklyDay); + const cronExpression = getCronExpression(formValues.frequency, formValues.dailyTime, formValues.weeklyDay, formValues.monthlyDays); const retentionPolicy: Record = {}; if (formValues.keepLast) retentionPolicy.keepLast = formValues.keepLast; diff --git a/app/utils/utils.ts b/app/utils/utils.ts index dc5d3a29..ba382152 100644 --- a/app/utils/utils.ts +++ b/app/utils/utils.ts @@ -1,6 +1,6 @@ import { intervalToDuration } from "date-fns"; -export const getCronExpression = (frequency: string, dailyTime?: string, weeklyDay?: string): string => { +export const getCronExpression = (frequency: string, dailyTime?: string, weeklyDay?: string,monthlyDays?: string[]): string => { if (frequency === "hourly") { return "0 * * * *"; } @@ -14,6 +14,12 @@ export const getCronExpression = (frequency: string, dailyTime?: string, weeklyD if (frequency === "daily") { return `${minutes} ${hours} * * *`; } + + if (frequency === "monthly") { + const sortedDays = (monthlyDays || []).map(Number).sort((a, b) => a - b); + const days = sortedDays.length > 0 ? sortedDays.join(",") : "1"; + return `${minutes} ${hours} ${days} * *`; + } return `${minutes} ${hours} * * ${weeklyDay ?? "0"}`; }; From 47aefa9bcb5db5f930ce5df7027e85e0f4775b0f Mon Sep 17 00:00:00 2001 From: Alessandro Zappia <60665910+alexzapd@users.noreply.github.com> Date: Fri, 19 Dec 2025 22:31:41 +0100 Subject: [PATCH 2/4] Update app/utils/utils.ts Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- app/utils/utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/utils/utils.ts b/app/utils/utils.ts index ba382152..ab015f48 100644 --- a/app/utils/utils.ts +++ b/app/utils/utils.ts @@ -1,6 +1,6 @@ import { intervalToDuration } from "date-fns"; -export const getCronExpression = (frequency: string, dailyTime?: string, weeklyDay?: string,monthlyDays?: string[]): string => { +export const getCronExpression = (frequency: string, dailyTime?: string, weeklyDay?: string, monthlyDays?: string[]): string => { if (frequency === "hourly") { return "0 * * * *"; } From e350843d4ec457cf76b2a984fadc259e79d5465b Mon Sep 17 00:00:00 2001 From: Alessandro Zappia <60665910+alexzapd@users.noreply.github.com> Date: Fri, 19 Dec 2025 22:34:19 +0100 Subject: [PATCH 3/4] Update app/utils/utils.ts Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- app/utils/utils.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/utils/utils.ts b/app/utils/utils.ts index ab015f48..bd8fd5a1 100644 --- a/app/utils/utils.ts +++ b/app/utils/utils.ts @@ -16,7 +16,10 @@ export const getCronExpression = (frequency: string, dailyTime?: string, weeklyD } if (frequency === "monthly") { - const sortedDays = (monthlyDays || []).map(Number).sort((a, b) => a - b); + const sortedDays = (monthlyDays || []) + .map(Number) + .filter((day) => day >= 1 && day <= 31) + .sort((a, b) => a - b); const days = sortedDays.length > 0 ? sortedDays.join(",") : "1"; return `${minutes} ${hours} ${days} * *`; } From a985c95a31256a14b04f312e6a01199441e2d728 Mon Sep 17 00:00:00 2001 From: Nicolas Meienberger Date: Sun, 21 Dec 2025 20:34:33 +0100 Subject: [PATCH 4/4] chore: wording and formatting --- .../components/create-schedule-form.tsx | 81 +++++++++---------- .../modules/backups/routes/backup-details.tsx | 7 +- .../modules/backups/routes/create-backup.tsx | 7 +- app/utils/utils.ts | 23 +++--- 4 files changed, 65 insertions(+), 53 deletions(-) diff --git a/app/client/modules/backups/components/create-schedule-form.tsx b/app/client/modules/backups/components/create-schedule-form.tsx index 4ae1faf5..adfaf820 100644 --- a/app/client/modules/backups/components/create-schedule-form.tsx +++ b/app/client/modules/backups/components/create-schedule-form.tsx @@ -88,12 +88,9 @@ const backupScheduleToFormValues = (schedule?: BackupSchedule): InternalFormValu const isDaily = !isHourly && dayOfMonthPart === "*" && dayOfWeekPart === "*"; const frequency = isHourly ? "hourly" : isMonthly ? "monthly" : isDaily ? "daily" : "weekly"; - const dailyTime = isHourly ? undefined : `${hourPart.padStart(2, "0")}:${minutePart.padStart(2, "0")}`; - const weeklyDay = frequency === "weekly" ? dayOfWeekPart : undefined; - - const monthlyDays = isMonthly ? dayOfMonthPart.split(",") : undefined; + const monthlyDays = isMonthly ? dayOfMonthPart.split(",") : undefined; const patterns = schedule.includePatterns || []; const isGlobPattern = (p: string) => /[*?[\]]/.test(p); @@ -103,8 +100,8 @@ const backupScheduleToFormValues = (schedule?: BackupSchedule): InternalFormValu return { name: schedule.name, repositoryId: schedule.repositoryId, - frequency, - monthlyDays, + frequency, + monthlyDays, dailyTime, weeklyDay, includePatterns: fileBrowserPaths.length > 0 ? fileBrowserPaths : undefined, @@ -257,7 +254,7 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }: Hourly Daily Weekly - Monthly + Specific days @@ -312,41 +309,41 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }: /> )} {frequency === "monthly" && ( - ( - - Days of the month - -
- {Array.from({ length: 31 }, (_, i) => { - const day = (i + 1).toString(); - const isSelected = field.value?.includes(day); - return ( - - ); - })} -
-
- Select one or more days when the backup should run. - -
- )} - /> - )} + ( + + Days of the month + +
+ {Array.from({ length: 31 }, (_, i) => { + const day = (i + 1).toString(); + const isSelected = field.value?.includes(day); + return ( + + ); + })} +
+
+ Select one or more days when the backup should run. + +
+ )} + /> + )} diff --git a/app/client/modules/backups/routes/backup-details.tsx b/app/client/modules/backups/routes/backup-details.tsx index dd67883c..6081a7a6 100644 --- a/app/client/modules/backups/routes/backup-details.tsx +++ b/app/client/modules/backups/routes/backup-details.tsx @@ -141,7 +141,12 @@ export default function ScheduleDetailsPage({ params, loaderData }: Route.Compon const handleSubmit = (formValues: BackupScheduleFormValues) => { if (!schedule) return; - const cronExpression = getCronExpression(formValues.frequency, formValues.dailyTime, formValues.weeklyDay, formValues.monthlyDays); + const cronExpression = getCronExpression( + formValues.frequency, + formValues.dailyTime, + formValues.weeklyDay, + formValues.monthlyDays, + ); const retentionPolicy: Record = {}; if (formValues.keepLast) retentionPolicy.keepLast = formValues.keepLast; diff --git a/app/client/modules/backups/routes/create-backup.tsx b/app/client/modules/backups/routes/create-backup.tsx index 4c51ac34..7b6484f5 100644 --- a/app/client/modules/backups/routes/create-backup.tsx +++ b/app/client/modules/backups/routes/create-backup.tsx @@ -71,7 +71,12 @@ export default function CreateBackup({ loaderData }: Route.ComponentProps) { const handleSubmit = (formValues: BackupScheduleFormValues) => { if (!selectedVolumeId) return; - const cronExpression = getCronExpression(formValues.frequency, formValues.dailyTime, formValues.weeklyDay, formValues.monthlyDays); + const cronExpression = getCronExpression( + formValues.frequency, + formValues.dailyTime, + formValues.weeklyDay, + formValues.monthlyDays, + ); const retentionPolicy: Record = {}; if (formValues.keepLast) retentionPolicy.keepLast = formValues.keepLast; diff --git a/app/utils/utils.ts b/app/utils/utils.ts index bd8fd5a1..9225e300 100644 --- a/app/utils/utils.ts +++ b/app/utils/utils.ts @@ -1,6 +1,11 @@ import { intervalToDuration } from "date-fns"; -export const getCronExpression = (frequency: string, dailyTime?: string, weeklyDay?: string, monthlyDays?: string[]): string => { +export const getCronExpression = ( + frequency: string, + dailyTime?: string, + weeklyDay?: string, + monthlyDays?: string[], +): string => { if (frequency === "hourly") { return "0 * * * *"; } @@ -14,15 +19,15 @@ export const getCronExpression = (frequency: string, dailyTime?: string, weeklyD if (frequency === "daily") { return `${minutes} ${hours} * * *`; } - + if (frequency === "monthly") { - const sortedDays = (monthlyDays || []) - .map(Number) - .filter((day) => day >= 1 && day <= 31) - .sort((a, b) => a - b); - const days = sortedDays.length > 0 ? sortedDays.join(",") : "1"; - return `${minutes} ${hours} ${days} * *`; - } + const sortedDays = (monthlyDays || []) + .map(Number) + .filter((day) => day >= 1 && day <= 31) + .sort((a, b) => a - b); + const days = sortedDays.length > 0 ? sortedDays.join(",") : "1"; + return `${minutes} ${hours} ${days} * *`; + } return `${minutes} ${hours} * * ${weeklyDay ?? "0"}`; };