Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
248 changes: 204 additions & 44 deletions apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,14 @@ import {
squashAtomCommandFailure,
type AtomCommandResult,
} from "@t3tools/client-runtime/state/runtime";
import {
MONTHS,
MONTH_DAYS,
MONTH_DAYS_UNSUPPORTED,
MONTH_NAMES,
describeFixedTimeSchedule,
describeMonthEndFallback,
} from "@t3tools/client-runtime/scheduled-task-schedule";
import {
DEFAULT_WEBHOOK_PROMPT,
emptyWebhookFilterDraft,
Expand All @@ -39,6 +47,7 @@ import { AppState, Platform, Pressable, TextInput as RNTextInput, View } from "r
import { useSafeAreaInsets } from "react-native-safe-area-context";

import { Button } from "../../components/ui/Button";
import { cn } from "../../lib/cn";
import { AppText as Text } from "../../components/ui/AppText";
import { SymbolView } from "../../components/ui/AppSymbol";
import {
Expand Down Expand Up @@ -109,8 +118,7 @@ const DAYS = [
function describeSchedule(task: ScheduledTask): string {
if (task.schedule.type === "webhook") return "On webhook";
if (task.schedule.type === "interval") return formatScheduledTaskInterval(task.schedule.everyMs);
const days = task.schedule.weekdays?.length ? repeatLabel(task.schedule.weekdays) : "Every day";
return `${days} at ${formatTime(task.schedule.timeOfDay)}`;
return describeFixedTimeSchedule(task.schedule, formatTime(task.schedule.timeOfDay));
}

function formatTime(value: string): string {
Expand Down Expand Up @@ -144,6 +152,61 @@ function repeatLabel(weekdays: ReadonlyArray<number>): string {
);
}

/** Many small choices at once, such as the days of a month; a check marks each chosen one. */
function ChoiceGrid(props: {
readonly columns: number;
readonly options: ReadonlyArray<{ readonly value: number; readonly label: string }>;
readonly selected: ReadonlyArray<number>;
readonly onToggle: (value: number) => void;
readonly disabled?: boolean;
}) {
return (
<View className="flex-row flex-wrap border-t border-border-subtle px-3 py-2">
{props.options.map((option) => {
const selected = props.selected.includes(option.value);
return (
<View key={option.value} className="p-1" style={{ width: `${100 / props.columns}%` }}>
<Pressable
accessibilityRole="checkbox"
accessibilityLabel={option.label}
accessibilityState={{ checked: selected, disabled: Boolean(props.disabled) }}
disabled={props.disabled}
onPress={() => props.onToggle(option.value)}
className={cn(
"h-9 flex-row items-center justify-center gap-0.5 rounded-xl active:opacity-70",
selected ? "bg-primary" : "bg-subtle",
)}
>
{selected ? (
<SymbolView
name="checkmark"
size={10}
tintColorClassName="accent-primary-foreground"
type="monochrome"
/>
) : null}
<Text
className={cn(
"text-sm font-t3-medium",
selected ? "text-primary-foreground" : "text-foreground",
)}
>
{option.label}
</Text>
</Pressable>
</View>
);
})}
</View>
);
}

function toggleChoice(values: ReadonlyArray<number>, value: number): ReadonlyArray<number> {
if (!values.includes(value)) return [...values, value];
// One choice always stays on; clearing the last would leave nothing to run on.
return values.length > 1 ? values.filter((item) => item !== value) : values;
}

function FormField(props: {
readonly label: string;
readonly value: string;
Expand Down Expand Up @@ -677,6 +740,14 @@ function TaskForm({
});
const submissionPending = useRef(false);
const [timePickerOpen, setTimePickerOpen] = useState(false);
const calendarSupported = config?.environment.capabilities.scheduledTaskMonthDays === true;
// A draft already using them keeps the controls, so switching to an older server can be undone.
const showCalendar =
calendarSupported || draft.schedule.dayMode === "month" || draft.schedule.limitMonths;
const draftSchedule =
draft.schedule.mode === "fixed_time" ? scheduleFromDraft(draft.schedule) : null;
const fixedSchedule = draftSchedule?.type === "fixed_time" ? draftSchedule : null;
const fixedScheduleFallback = fixedSchedule ? describeMonthEndFallback(fixedSchedule) : null;
const taskMissing =
draft.task !== null &&
tasks.data !== null &&
Expand All @@ -698,7 +769,8 @@ function TaskForm({
saving ||
dictationPending ||
taskMissing ||
environmentUnavailable
environmentUnavailable ||
config === null
)
return;
// Preserve signatures edited on other clients by sending the task's current value.
Expand Down Expand Up @@ -748,6 +820,14 @@ function TaskForm({
Dialog.alert("Project unavailable", "Choose a project in this environment.");
return;
}
if (
!calendarSupported &&
schedule.type === "fixed_time" &&
(schedule.monthDays !== undefined || schedule.months !== undefined)
) {
Dialog.alert("Update this environment", MONTH_DAYS_UNSUPPORTED);
return;
}
const input: ScheduledTaskUpsertInput = {
...(draft.task ? { id: draft.task.id, requireExisting: true } : {}),
title: draft.title.trim(),
Expand Down Expand Up @@ -1007,46 +1087,119 @@ function TaskForm({
}}
/>
) : null}
<SelectRow
label="Repeat"
value={repeatLabel(draft.schedule.weekdays)}
borderTop
actions={[
{
id: "every_day",
title: "Every day",
state: draft.schedule.weekdays.length === 7 ? "on" : undefined,
},
{
id: "weekdays",
title: "Weekdays",
state: repeatLabel(draft.schedule.weekdays) === "Weekdays" ? "on" : undefined,
},
...DAYS.map((day) => ({
id: String(day.index),
title: day.label,
attributes: { keepsMenuPresented: true },
state: draft.schedule.weekdays.includes(day.index)
? ("on" as const)
: undefined,
})),
]}
onSelect={(id) => {
const weekdays =
id === "every_day"
? DAYS.map((day) => day.index)
: id === "weekdays"
? [1, 2, 3, 4, 5]
: (() => {
const day = DAYS.find((item) => String(item.index) === id);
if (!day) return draft.schedule.weekdays;
return draft.schedule.weekdays.includes(day.index)
? draft.schedule.weekdays.filter((index) => index !== day.index)
: [...draft.schedule.weekdays, day.index];
})();
setDraft({ ...draft, schedule: { ...draft.schedule, weekdays } });
}}
/>
{showCalendar ? (
<View className="border-t border-border-subtle px-4 py-3">
<SegmentedControl
size="compact"
options={[
{ value: "week", label: "Days of the week" },
{ value: "month", label: "Days of the month" },
]}
selected={draft.schedule.dayMode}
onSelect={(dayMode) =>
setDraft({ ...draft, schedule: { ...draft.schedule, dayMode } })
}
/>
</View>
) : null}
{draft.schedule.dayMode === "week" ? (
<SelectRow
label="Repeat"
value={repeatLabel(draft.schedule.weekdays)}
borderTop
actions={[
{
id: "every_day",
title: "Every day",
state: draft.schedule.weekdays.length === 7 ? "on" : undefined,
},
{
id: "weekdays",
title: "Weekdays",
state: repeatLabel(draft.schedule.weekdays) === "Weekdays" ? "on" : undefined,
},
...DAYS.map((day) => ({
id: String(day.index),
title: day.label,
attributes: { keepsMenuPresented: true },
state: draft.schedule.weekdays.includes(day.index)
? ("on" as const)
: undefined,
})),
]}
onSelect={(id) => {
const weekdays =
id === "every_day"
? DAYS.map((day) => day.index)
: id === "weekdays"
? [1, 2, 3, 4, 5]
: (() => {
const day = DAYS.find((item) => String(item.index) === id);
if (!day) return draft.schedule.weekdays;
return draft.schedule.weekdays.includes(day.index)
? draft.schedule.weekdays.filter((index) => index !== day.index)
: [...draft.schedule.weekdays, day.index];
})();
setDraft({ ...draft, schedule: { ...draft.schedule, weekdays } });
}}
/>
) : (
<ChoiceGrid
columns={7}
options={MONTH_DAYS.map((day) => ({ value: day, label: String(day) }))}
selected={draft.schedule.monthDays}
disabled={saving}
onToggle={(day) =>
setDraft({
...draft,
schedule: {
...draft.schedule,
monthDays: toggleChoice(draft.schedule.monthDays, day),
},
})
}
/>
)}
{showCalendar ? (
<View className="min-h-14 flex-row items-center gap-3 border-t border-border-subtle px-4 py-3">
<Text className="min-w-0 flex-1 text-lg text-foreground">
Only in some months
</Text>
<ThemedSwitch
accessibilityLabel="Only in some months"
value={draft.schedule.limitMonths}
onValueChange={(limitMonths) =>
setDraft({ ...draft, schedule: { ...draft.schedule, limitMonths } })
}
/>
</View>
) : null}
{draft.schedule.limitMonths ? (
<ChoiceGrid
columns={6}
options={MONTHS.map((month) => ({
value: month,
label: MONTH_NAMES[month - 1] ?? String(month),
}))}
selected={draft.schedule.months}
disabled={saving}
onToggle={(month) =>
setDraft({
...draft,
schedule: {
...draft.schedule,
months: toggleChoice(draft.schedule.months, month),
},
})
}
/>
) : null}
{fixedSchedule ? (
<Text className="border-t border-border-subtle px-4 py-3 text-sm text-foreground-muted">
{describeFixedTimeSchedule(fixedSchedule, formatTime(fixedSchedule.timeOfDay))}.
{fixedScheduleFallback ? ` ${fixedScheduleFallback}` : ""}
</Text>
) : null}
</>
) : draft.schedule.mode === "webhook" ? (
<>
Expand Down Expand Up @@ -1161,7 +1314,14 @@ function TaskForm({
tone="primary"
fullWidth
loading={saving}
disabled={!canOperate || dictationPending || taskMissing || environmentUnavailable}
// Wait for the server config: it says whether days of the month can be saved.
disabled={
config === null ||
!canOperate ||
dictationPending ||
taskMissing ||
environmentUnavailable
}
onPress={() => void save()}
label={saving ? "Saving…" : draft.task ? "Save changes" : "Create task"}
/>
Expand Down
48 changes: 48 additions & 0 deletions apps/mobile/src/features/settings/scheduledTaskDraft.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@ import {
ProjectId,
} from "@t3tools/contracts";
import {
createDraft,
scheduledTaskDefaultModel,
DEFAULT_SCHEDULE,
hasScheduledTaskDraftChanges,
scheduleDraftForTask,
scheduleFromDraft,
} from "./scheduledTaskDraft";
Expand Down Expand Up @@ -196,3 +198,49 @@ it("rejects dedupe keys without valid request placeholders", () => {
scheduleFromDraft({ ...DEFAULT_SCHEDULE, mode: "webhook", coalesceKey: "{{body.bad" }),
).toBeNull();
});

describe("day-of-month schedules", () => {
it("round-trips days of the month limited to some months", () => {
const schedule = {
type: "fixed_time" as const,
timeOfDay: "09:00",
monthDays: [5],
months: [1, 4, 7, 10],
};
const draft = scheduleDraftForTask({ schedule });
expect(draft.dayMode).toBe("month");
expect(draft.limitMonths).toBe(true);
expect(scheduleFromDraft(draft)).toEqual(schedule);
// Switching the limit off runs every month but keeps the choice for switching back.
expect(scheduleFromDraft({ ...draft, limitMonths: false })).toEqual({
type: "fixed_time",
timeOfDay: "09:00",
monthDays: [5],
});
});

it("sends only the active day selection and leaves out every month", () => {
expect(
scheduleFromDraft({ ...DEFAULT_SCHEDULE, dayMode: "month", monthDays: [31, 4, 10] }),
).toEqual({ type: "fixed_time", timeOfDay: "09:00", monthDays: [4, 10, 31] });
});

it.each([
{ dayMode: "month" as const, monthDays: [] },
{ dayMode: "month" as const, monthDays: [32] },
{ limitMonths: true, months: [] },
{ limitMonths: true, months: [13] },
])("rejects an empty or invalid calendar selection %o", (fields) => {
expect(scheduleFromDraft({ ...DEFAULT_SCHEDULE, ...fields })).toBeNull();
});

it("marks a switch between weekdays and days of the month as a change", () => {
const draft = createDraft(null, null);
expect(
hasScheduledTaskDraftChanges(draft, {
...draft,
schedule: { ...draft.schedule, dayMode: "month" },
}),
).toBe(true);
});
});
Loading
Loading