diff --git a/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx index fcb66ae1e6262..f575a50eb7fdd 100644 --- a/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx @@ -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, @@ -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 { @@ -110,8 +119,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 { @@ -145,6 +153,61 @@ function repeatLabel(weekdays: ReadonlyArray): 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; + readonly onToggle: (value: number) => void; + readonly disabled?: boolean; +}) { + return ( + + {props.options.map((option) => { + const selected = props.selected.includes(option.value); + return ( + + 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 ? ( + + ) : null} + + {option.label} + + + + ); + })} + + ); +} + +function toggleChoice(values: ReadonlyArray, value: number): ReadonlyArray { + 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; @@ -687,6 +750,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 && @@ -708,7 +779,8 @@ function TaskForm({ saving || dictationPending || taskMissing || - environmentUnavailable + environmentUnavailable || + config === null ) return; // Preserve signatures edited on other clients by sending the task's current value. @@ -758,6 +830,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(), @@ -1017,46 +1097,119 @@ function TaskForm({ }} /> ) : null} - ({ - 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 ? ( + + + setDraft({ ...draft, schedule: { ...draft.schedule, dayMode } }) + } + /> + + ) : null} + {draft.schedule.dayMode === "week" ? ( + ({ + 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 } }); + }} + /> + ) : ( + ({ 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 ? ( + + + Only in some months + + + setDraft({ ...draft, schedule: { ...draft.schedule, limitMonths } }) + } + /> + + ) : null} + {draft.schedule.limitMonths ? ( + ({ + 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 ? ( + + {describeFixedTimeSchedule(fixedSchedule, formatTime(fixedSchedule.timeOfDay))}. + {fixedScheduleFallback ? ` ${fixedScheduleFallback}` : ""} + + ) : null} ) : draft.schedule.mode === "webhook" ? ( <> @@ -1171,7 +1324,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"} /> diff --git a/apps/mobile/src/features/settings/scheduledTaskDraft.test.ts b/apps/mobile/src/features/settings/scheduledTaskDraft.test.ts index 7359403a67dce..8776e4bd748a1 100644 --- a/apps/mobile/src/features/settings/scheduledTaskDraft.test.ts +++ b/apps/mobile/src/features/settings/scheduledTaskDraft.test.ts @@ -7,8 +7,10 @@ import { ProjectId, } from "@t3tools/contracts"; import { + createDraft, scheduledTaskDefaultModel, DEFAULT_SCHEDULE, + hasScheduledTaskDraftChanges, scheduleDraftForTask, scheduleFromDraft, } from "./scheduledTaskDraft"; @@ -207,3 +209,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); + }); +}); diff --git a/apps/mobile/src/features/settings/scheduledTaskDraft.ts b/apps/mobile/src/features/settings/scheduledTaskDraft.ts index 4ff15c4d9e45b..e5e3015778314 100644 --- a/apps/mobile/src/features/settings/scheduledTaskDraft.ts +++ b/apps/mobile/src/features/settings/scheduledTaskDraft.ts @@ -10,6 +10,7 @@ import type { } from "@t3tools/contracts"; import { DEFAULT_SERVER_SETTINGS } from "@t3tools/contracts"; +import { MONTHS } from "@t3tools/client-runtime/scheduled-task-schedule"; import { parseMaxDeliveryAge, parseWebhookFilterDrafts, @@ -53,7 +54,12 @@ export function scheduledTaskDefaultModel( export type ScheduleDraft = { readonly mode: "fixed_time" | "interval" | "webhook"; readonly timeOfDay: string; + readonly dayMode: "week" | "month"; readonly weekdays: ReadonlyArray; + readonly monthDays: ReadonlyArray; + /** Off runs in every month; the month choices are kept so switching back restores them. */ + readonly limitMonths: boolean; + readonly months: ReadonlyArray; readonly intervalMinutes: string; readonly signature: ScheduledTaskWebhookSignature | null; readonly maxDeliveryAgeMinutes: string; @@ -68,7 +74,11 @@ export type ScheduleDraft = { export const DEFAULT_SCHEDULE: ScheduleDraft = { mode: "fixed_time", timeOfDay: "09:00", + dayMode: "week", weekdays: [1, 2, 3, 4, 5], + monthDays: [1], + limitMonths: false, + months: MONTHS, intervalMinutes: "15", signature: null, maxDeliveryAgeMinutes: "", @@ -81,14 +91,20 @@ export const DEFAULT_SCHEDULE: ScheduleDraft = { export function scheduleDraftForTask(task: Pick): ScheduleDraft { switch (task.schedule.type) { - case "fixed_time": + case "fixed_time": { + const months = task.schedule.months?.length ? uniqueSorted(task.schedule.months) : MONTHS; return { ...DEFAULT_SCHEDULE, timeOfDay: task.schedule.timeOfDay, + dayMode: task.schedule.monthDays?.length ? "month" : "week", weekdays: task.schedule.weekdays?.length - ? [...new Set(task.schedule.weekdays)].sort((a, b) => a - b) + ? uniqueSorted(task.schedule.weekdays) : [0, 1, 2, 3, 4, 5, 6], + monthDays: task.schedule.monthDays?.length ? uniqueSorted(task.schedule.monthDays) : [1], + limitMonths: months.length < 12, + months, }; + } case "interval": return { ...DEFAULT_SCHEDULE, @@ -171,21 +187,38 @@ export function scheduleFromDraft(draft: ScheduleDraft): ScheduledTaskUpsertSche const everyMs = Math.round(minutes * 60_000); return minutes >= 1 && Number.isSafeInteger(everyMs) ? { type: "interval", everyMs } : null; } - const weekdays = [...new Set(draft.weekdays)].sort((a, b) => a - b); + const weekdays = uniqueSorted(draft.weekdays); + const monthDays = uniqueSorted(draft.monthDays); + const months = draft.limitMonths ? uniqueSorted(draft.months) : MONTHS; + const days = draft.dayMode === "month" ? monthDays : weekdays; + const [minimum, maximum] = draft.dayMode === "month" ? [1, 31] : [0, 6]; if ( !/^([01]?\d|2[0-3]):[0-5]\d$/.test(draft.timeOfDay) || - weekdays.length === 0 || - weekdays.some((day) => !Number.isInteger(day) || day < 0 || day > 6) + days.length === 0 || + days.some((day) => !Number.isInteger(day) || day < minimum || day > maximum) || + months.length === 0 || + months.some((month) => !Number.isInteger(month) || month < 1 || month > 12) ) { return null; } return { type: "fixed_time", timeOfDay: draft.timeOfDay, - ...(weekdays.length === 7 ? {} : { weekdays }), + ...(draft.dayMode === "month" + ? monthDays.length === 31 + ? {} + : { monthDays } + : weekdays.length === 7 + ? {} + : { weekdays }), + ...(months.length === 12 ? {} : { months }), }; } +function uniqueSorted(values: ReadonlyArray): number[] { + return [...new Set(values)].sort((a, b) => a - b); +} + type Workspace = "worktree" | "root" | "existing_worktree"; export type ScheduledTaskDraft = { readonly task: ScheduledTask | null; @@ -215,7 +248,11 @@ function draftSignature(draft: ScheduledTaskDraft): string { .map((option) => [option.id, option.value]), draft.schedule.mode, draft.schedule.timeOfDay, - [...draft.schedule.weekdays].sort((a, b) => a - b), + draft.schedule.dayMode, + uniqueSorted(draft.schedule.weekdays), + uniqueSorted(draft.schedule.monthDays), + draft.schedule.limitMonths, + uniqueSorted(draft.schedule.months), draft.schedule.intervalMinutes, draft.schedule.maxDeliveryAgeMinutes, draft.schedule.replayKey, diff --git a/apps/server/src/environment/ServerEnvironment.test.ts b/apps/server/src/environment/ServerEnvironment.test.ts index 7cebccb2a1f7c..26d0afcf9b0ce 100644 --- a/apps/server/src/environment/ServerEnvironment.test.ts +++ b/apps/server/src/environment/ServerEnvironment.test.ts @@ -113,6 +113,7 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { mode === "web" ? undefined : "desktop-managed", ); expect(descriptor.capabilities).toMatchObject({ + scheduledTaskMonthDays: true, settledWorktreeCleanup: true, storageCleanupRun: true, projectWorktreeCleanup: true, diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index 72d7f0c2de7ef..afcbce61e5556 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -278,6 +278,7 @@ export const make = Effect.gen(function* () { peerLinks: true, mcpModeLimitHeader: true, linkFence: true, + scheduledTaskMonthDays: true, }, }; diff --git a/apps/server/src/provider/agentContextBudget.json b/apps/server/src/provider/agentContextBudget.json index 07830f90d0567..e8027648830a6 100644 --- a/apps/server/src/provider/agentContextBudget.json +++ b/apps/server/src/provider/agentContextBudget.json @@ -1,5 +1,5 @@ { "claudeSystemAppendBytes": 5129, "codexAdditionalContextBytes": 6859, - "mcpToolListJsonBytes": 46109 + "mcpToolListJsonBytes": 46329 } diff --git a/apps/server/src/scheduledTasks/Schedule.test.ts b/apps/server/src/scheduledTasks/Schedule.test.ts index f5973969747f7..1c8e06010a373 100644 --- a/apps/server/src/scheduledTasks/Schedule.test.ts +++ b/apps/server/src/scheduledTasks/Schedule.test.ts @@ -1,8 +1,40 @@ import { describe, expect, it } from "@effect/vitest"; +import type { ScheduledTaskSchedule } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; import { isSameSchedule, nextScheduledRunAt } from "./Schedule.ts"; +const SOFIA = "Europe/Sofia"; + +function sofia(year: number, month: number, day: number, hour = 9, minute = 0) { + return DateTime.makeZonedUnsafe( + { year, month, day, hour, minute, second: 0, millisecond: 0 }, + { timeZone: SOFIA, adjustForTimeZone: true }, + ); +} + +function localLabel(value: DateTime.DateTime): string { + const parts = DateTime.toParts(value); + const pad = (part: number) => String(part).padStart(2, "0"); + return `${parts.year}-${pad(parts.month)}-${pad(parts.day)} ${pad(parts.hour)}:${pad(parts.minute)}`; +} + +function upcomingRuns( + schedule: ScheduledTaskSchedule, + from: DateTime.DateTime, + count: number, +): ReadonlyArray { + const runs: string[] = []; + let cursor = from; + while (runs.length < count) { + const next = nextScheduledRunAt(schedule, cursor); + if (next === null) break; + runs.push(localLabel(next)); + cursor = next; + } + return runs; +} + describe("scheduled task schedule calculation", () => { it("skips to the next matching fixed-time weekday", () => { const next = nextScheduledRunAt( @@ -26,6 +58,80 @@ describe("scheduled task schedule calculation", () => { expect(parts?.minute).toBe(0); }); + it("runs on the chosen days of every month", () => { + expect( + upcomingRuns( + { type: "fixed_time", timeOfDay: "09:00", monthDays: [6, 4] }, + sofia(2026, 10, 19, 10), + 4, + ), + ).toEqual(["2026-11-04 09:00", "2026-11-06 09:00", "2026-12-04 09:00", "2026-12-06 09:00"]); + }); + + it("runs a day a month doesn't have on that month's last day", () => { + expect( + upcomingRuns( + { type: "fixed_time", timeOfDay: "09:00", monthDays: [31] }, + sofia(2026, 1, 31, 10), + 5, + ), + ).toEqual([ + "2026-02-28 09:00", + "2026-03-31 09:00", + "2026-04-30 09:00", + "2026-05-31 09:00", + "2026-06-30 09:00", + ]); + // The 30th and the 31st share April's last day, so April runs once. + expect( + upcomingRuns( + { type: "fixed_time", timeOfDay: "09:00", monthDays: [30, 31] }, + sofia(2026, 4, 1), + 3, + ), + ).toEqual(["2026-04-30 09:00", "2026-05-30 09:00", "2026-05-31 09:00"]); + }); + + it("runs on 29 February in leap years and on the 28th otherwise", () => { + expect( + upcomingRuns( + { type: "fixed_time", timeOfDay: "09:00", monthDays: [29], months: [2] }, + sofia(2026, 1, 1), + 3, + ), + ).toEqual(["2026-02-28 09:00", "2027-02-28 09:00", "2028-02-29 09:00"]); + }); + + it("limits runs to the chosen months across a year boundary", () => { + expect( + upcomingRuns( + { type: "fixed_time", timeOfDay: "09:00", monthDays: [5], months: [1, 4, 7, 10] }, + sofia(2026, 10, 19, 10), + 4, + ), + ).toEqual(["2027-01-05 09:00", "2027-04-05 09:00", "2027-07-05 09:00", "2027-10-05 09:00"]); + expect( + upcomingRuns( + { type: "fixed_time", timeOfDay: "09:00", weekdays: [1], months: [1] }, + sofia(2026, 2, 1), + 2, + ), + ).toEqual(["2027-01-04 09:00", "2027-01-11 09:00"]); + }); + + it("keeps a monthly run on local wall-clock time across a daylight-saving change", () => { + const schedule = { type: "fixed_time", timeOfDay: "09:00", monthDays: [4] } as const; + const october = nextScheduledRunAt(schedule, sofia(2026, 10, 1)); + const november = october === null ? null : nextScheduledRunAt(schedule, october); + // Sofia leaves summer time (UTC+3) for UTC+2 on 25 October. + expect(october ? DateTime.formatIso(DateTime.toUtc(october)) : null).toBe( + "2026-10-04T06:00:00.000Z", + ); + expect(november ? DateTime.formatIso(DateTime.toUtc(november)) : null).toBe( + "2026-11-04T07:00:00.000Z", + ); + }); + it("compares schedules structurally", () => { expect( isSameSchedule({ type: "interval", everyMs: 60_000 }, { type: "interval", everyMs: 60_000 }), @@ -76,4 +182,36 @@ describe("scheduled task schedule calculation", () => { ), ).toBe(false); }); + + it("compares month-day and month selections structurally", () => { + expect( + isSameSchedule( + { type: "fixed_time", timeOfDay: "09:00", monthDays: [6, 4, 4], months: [10, 1] }, + { type: "fixed_time", timeOfDay: "09:00", monthDays: [4, 6], months: [1, 10] }, + ), + ).toBe(true); + expect( + isSameSchedule( + { + type: "fixed_time", + timeOfDay: "09:00", + monthDays: Array.from({ length: 31 }, (_, index) => index + 1), + months: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12], + }, + { type: "fixed_time", timeOfDay: "09:00" }, + ), + ).toBe(true); + expect( + isSameSchedule( + { type: "fixed_time", timeOfDay: "09:00", monthDays: [4] }, + { type: "fixed_time", timeOfDay: "09:00", monthDays: [5] }, + ), + ).toBe(false); + expect( + isSameSchedule( + { type: "fixed_time", timeOfDay: "09:00", monthDays: [5] }, + { type: "fixed_time", timeOfDay: "09:00", monthDays: [5], months: [1, 4, 7, 10] }, + ), + ).toBe(false); + }); }); diff --git a/apps/server/src/scheduledTasks/Schedule.ts b/apps/server/src/scheduledTasks/Schedule.ts index bac32bb913146..8187eeab5015f 100644 --- a/apps/server/src/scheduledTasks/Schedule.ts +++ b/apps/server/src/scheduledTasks/Schedule.ts @@ -21,9 +21,10 @@ export function nextScheduledRunAt( const time = parseTimeOfDay(schedule.timeOfDay); if (time === null) return null; - const weekdays = - schedule.weekdays && schedule.weekdays.length > 0 ? new Set(schedule.weekdays) : null; - for (let offset = 0; offset <= 7; offset += 1) { + const weekdays = selection(schedule.weekdays); + const monthDays = selection(schedule.monthDays); + const months = selection(schedule.months); + for (let offset = 0; offset <= MAX_LOOKAHEAD_DAYS; offset += 1) { const candidate = DateTime.setParts(DateTime.add(from, { days: offset }), { hour: time.hour, minute: time.minute, @@ -31,15 +32,35 @@ export function nextScheduledRunAt( millisecond: 0, }); if (DateTime.toEpochMillis(candidate) <= DateTime.toEpochMillis(from)) continue; - if (weekdays !== null && !weekdays.has(DateTime.toParts(candidate).weekDay)) continue; + const parts = DateTime.toParts(candidate); + if (months !== null && !months.has(parts.month)) continue; + if (weekdays !== null && !weekdays.has(parts.weekDay)) continue; + if (monthDays !== null && !matchesMonthDay(monthDays, candidate)) continue; return candidate; } return null; } -function weekdayKey(weekdays: ReadonlyArray | undefined): string { - const unique = [...new Set(weekdays ?? [])].toSorted((x, y) => x - y); - if (unique.length === 0 || unique.length === 7) return "daily"; +// One month filter can put the next run a year away, and a weekday filter can +// push it up to six days further. +const MAX_LOOKAHEAD_DAYS = 366 + 6; + +function selection(values: ReadonlyArray | undefined): ReadonlySet | null { + return values && values.length > 0 ? new Set(values) : null; +} + +function matchesMonthDay(monthDays: ReadonlySet, candidate: DateTime.DateTime): boolean { + const { day } = DateTime.toParts(candidate); + if (monthDays.has(day)) return true; + // A day the month doesn't have runs on its last day, so 31 means "month end". + const lastDay = DateTime.toParts(DateTime.endOf(candidate, "month")).day; + return day === lastDay && [...monthDays].some((monthDay) => monthDay > lastDay); +} + +/** Normalizes a selection where none and all mean the same thing: no filter. */ +function selectionKey(values: ReadonlyArray | undefined, all: number): string { + const unique = [...new Set(values ?? [])].toSorted((x, y) => x - y); + if (unique.length === 0 || unique.length === all) return "all"; return unique.join(","); } @@ -57,7 +78,9 @@ export function isSameSchedule(a: ScheduledTaskSchedule, b: ScheduledTaskSchedul bTime !== null && aTime.hour === bTime.hour && aTime.minute === bTime.minute && - weekdayKey(a.weekdays) === weekdayKey(b.weekdays) + selectionKey(a.weekdays, 7) === selectionKey(b.weekdays, 7) && + selectionKey(a.monthDays, 31) === selectionKey(b.monthDays, 31) && + selectionKey(a.months, 12) === selectionKey(b.months, 12) ); } diff --git a/apps/server/src/scheduledTasks/ScheduledTaskService.schedule.test.ts b/apps/server/src/scheduledTasks/ScheduledTaskService.schedule.test.ts index 1070b49a0dcf2..10004902b6dc3 100644 --- a/apps/server/src/scheduledTasks/ScheduledTaskService.schedule.test.ts +++ b/apps/server/src/scheduledTasks/ScheduledTaskService.schedule.test.ts @@ -229,3 +229,52 @@ it.effect( }).pipe(Effect.provide(ScheduledTaskService.layer.pipe(Layer.provide(layerDependencies)))); }).pipe(Effect.provide(SqlitePersistence.layerMemory)), ); + +it.effect("stores a day-of-month schedule and schedules its next chosen day", () => + Effect.gen(function* () { + const local = (month: number, day: number, year = 2026) => + DateTime.makeZonedUnsafe( + { year, month, day, hour: 9, minute: 0, second: 0, millisecond: 0 }, + { timeZone: DateTime.zoneMakeLocal(), adjustForTimeZone: true }, + ); + yield* TestClock.setTime(DateTime.toEpochMillis(local(10, 19))); + + const layerDependencies = Layer.mergeAll( + NodeCrypto.layer, + Scheduler.layer, + Layer.mock(ThreadLaunchService.ThreadLaunchService)({}), + Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ + namedProjectsRoot: "/projects", + isScratchProject: () => Effect.succeed(false), + }), + Layer.mock(ThreadManagementService.ThreadManagementService)({}), + Layer.mock(SecretRequests.SecretRequests)({}), + ); + yield* Effect.gen(function* () { + const service = yield* ScheduledTaskService.ScheduledTaskService; + const input = yield* decodeUpsertInput({ + title: "Quarterly report", + prompt: "Prepare the quarterly report.", + enabled: true, + schedule: { type: "fixed_time", timeOfDay: "09:00", monthDays: [5], months: [1, 4, 7, 10] }, + projectId: "project-month-days", + workspaceStrategy: { type: "root" }, + modelSelection: { instanceId: "codex", model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + }); + const created = yield* service.upsert(input); + expect(created.task.nextRunAt).toBe(DateTime.formatIso(DateTime.toUtc(local(1, 5, 2027)))); + + const [listed] = (yield* service.list()).tasks; + expect(listed?.schedule).toEqual(input.schedule); + + const monthly = yield* service.upsert({ + ...input, + id: created.task.id, + schedule: { type: "fixed_time", timeOfDay: "09:00", monthDays: [4, 6] }, + }); + expect(monthly.task.nextRunAt).toBe(DateTime.formatIso(DateTime.toUtc(local(11, 4)))); + }).pipe(Effect.provide(ScheduledTaskService.layer.pipe(Layer.provide(layerDependencies)))); + }).pipe(Effect.provide(SqlitePersistence.layerMemory)), +); diff --git a/apps/web/src/components/settings/ScheduledTasksSettings.browser.test.tsx b/apps/web/src/components/settings/ScheduledTasksSettings.browser.test.tsx index 7e42bba4bc6c8..05ca91059a327 100644 --- a/apps/web/src/components/settings/ScheduledTasksSettings.browser.test.tsx +++ b/apps/web/src/components/settings/ScheduledTasksSettings.browser.test.tsx @@ -47,7 +47,12 @@ const threadFamilyRead = { threads: [{ id: task.threadId, title: "Fix login bug" }], }; -const state = vi.hoisted(() => ({ connected: true, tasksLoaded: true, secondary: false })); +const state = vi.hoisted(() => ({ + connected: true, + tasksLoaded: true, + secondary: false, + monthDays: true, +})); const secondaryId = EnvironmentId.make("secondary"); const permissions = new Map>(); @@ -55,6 +60,7 @@ function setup() { state.connected = true; state.tasksLoaded = true; state.secondary = false; + state.monthDays = true; permissions.clear(); permissions.set(environmentId, Atom.make(true)); permissions.set(secondaryId, Atom.make(true)); @@ -70,7 +76,14 @@ function environment() { environmentId, label: "Laptop", connection: { phase: state.connected ? "connected" : "disconnected" }, - serverConfig: state.connected ? { environment: { platform: "linux" } } : null, + serverConfig: state.connected + ? { + environment: { + platform: "linux", + capabilities: state.monthDays ? { scheduledTaskMonthDays: true } : {}, + }, + } + : null, }; } @@ -213,4 +226,18 @@ describe("ScheduledTasksSettings", () => { await expect.element(dialog.getByRole("heading", { name: "New task" })).toBeInTheDocument(); await expect.element(name).toHaveValue("My draft"); }); + + it("offers days of the month only when the server can store them", async () => { + setup(); + const monthDaysToggle = async (monthDays: boolean) => { + state.monthDays = monthDays; + await using _screen = await render(); + await userEvent.click(page.getByRole("button", { name: "New task" })); + const dialog = page.getByRole("dialog"); + await expect.element(dialog.getByRole("button", { name: "Mon" })).toBeInTheDocument(); + return dialog.getByRole("button", { name: "Days of the month" }).query() !== null; + }; + expect(await monthDaysToggle(false)).toBe(false); + expect(await monthDaysToggle(true)).toBe(true); + }); }); diff --git a/apps/web/src/components/settings/ScheduledTasksSettings.tsx b/apps/web/src/components/settings/ScheduledTasksSettings.tsx index 934436d8dce4e..8d1d89d0904d5 100644 --- a/apps/web/src/components/settings/ScheduledTasksSettings.tsx +++ b/apps/web/src/components/settings/ScheduledTasksSettings.tsx @@ -11,7 +11,7 @@ import { PlusIcon, Trash2Icon, } from "lucide-react"; -import { type ReactNode, useRef, useState } from "react"; +import { type Dispatch, type ReactNode, type SetStateAction, useRef, useState } from "react"; import type { EnvironmentId, ModelSelection, @@ -24,6 +24,14 @@ import type { ScheduledTaskWebhookDeliverySummary, ThreadId, } from "@t3tools/contracts"; +import { + MONTHS, + MONTH_DAYS, + MONTH_DAYS_UNSUPPORTED, + MONTH_NAMES, + describeFixedTimeSchedule, + describeMonthEndFallback, +} from "@t3tools/client-runtime/scheduled-task-schedule"; import { DEFAULT_WEBHOOK_PROMPT, emptyWebhookFilterDraft, @@ -85,6 +93,7 @@ import { scheduledTaskDefaultModel, taskToDraft, type DraftState, + type DayMode, type ScheduleMode, type WorkspaceMode, } from "./scheduledTasksSettings.logic"; @@ -136,7 +145,11 @@ const EMPTY_DRAFT: DraftState = { scheduleMode: "fixed", intervalMinutes: "15", timeOfDay: "09:00", + dayMode: "week", weekdays: new Set([1, 2, 3, 4, 5]), + monthDays: new Set([1]), + limitMonths: false, + months: new Set(MONTHS), projectId: "", threadId: "", workspaceMode: "worktree", @@ -292,14 +305,7 @@ export function scheduleLabel(schedule: ScheduledTaskSchedule): string { ? `Every ${minutes} min` : `Every ${Math.round(schedule.everyMs / 1000)} sec`; } - const weekdays = schedule.weekdays ?? []; - const days = - weekdays.length === 0 - ? "Daily" - : weekdays.length === 5 && weekdays.every((day) => day >= 1 && day <= 5) - ? "Weekdays" - : weekdays.map((day) => WEEKDAY_LABELS[day]).join(", "); - return `${days} at ${schedule.timeOfDay}`; + return describeFixedTimeSchedule(schedule); } /** formatRelativeTime handles only the past; upcoming runs need future labels. */ @@ -1155,6 +1161,135 @@ function ScheduledTaskDestination({ ); } +/** Toggle values are strings; an empty choice is ignored so one option always stays on. */ +function numberChoices(values: readonly string[]): ReadonlySet | null { + return values.length === 0 ? null : new Set(values.map(Number)); +} + +function FixedTimeScheduleFields({ + draft, + setDraft, + calendarSupported, +}: { + readonly draft: DraftState; + readonly setDraft: Dispatch>; + readonly calendarSupported: boolean; +}) { + const schedule = scheduleFromDraft(draft); + const fixed = schedule?.type === "fixed_time" ? schedule : null; + const fallback = fixed ? describeMonthEndFallback(fixed) : null; + // A draft already using them keeps the controls, so switching to an older server can be undone. + const showCalendar = calendarSupported || draft.dayMode === "month" || draft.limitMonths; + return ( +
+
+
+ + + setDraft((current) => ({ ...current, timeOfDay: event.target.value })) + } + /> +
+ {showCalendar ? ( + { + const dayMode = values[0] as DayMode | undefined; + if (dayMode === "week" || dayMode === "month") + setDraft((current) => ({ ...current, dayMode })); + }} + > + Days of the week + Days of the month + + ) : null} +
+ {draft.dayMode === "week" ? ( + { + const weekdays = numberChoices(values); + if (weekdays) setDraft((current) => ({ ...current, weekdays })); + }} + > + {WEEKDAY_ORDER.map((day) => ( + + {WEEKDAY_SHORT[day]} + + ))} + + ) : ( + { + const monthDays = numberChoices(values); + if (monthDays) setDraft((current) => ({ ...current, monthDays })); + }} + > + {MONTH_DAYS.map((day) => ( + + {day} + + ))} + + )} + {showCalendar ? ( +
+ + setDraft((current) => ({ ...current, limitMonths }))} + /> +
+ ) : null} + {draft.limitMonths ? ( + { + const months = numberChoices(values); + if (months) setDraft((current) => ({ ...current, months })); + }} + > + {MONTHS.map((month) => ( + + {MONTH_NAMES[month - 1]} + + ))} + + ) : null} + {fixed ? ( +

+ {describeFixedTimeSchedule(fixed)}. + {fallback ? {fallback} : null} +

+ ) : null} +
+ ); +} + function ScheduledTaskEditorForm({ initialEnvironmentId, task, @@ -1173,6 +1308,8 @@ function ScheduledTaskEditorForm({ const environment = useEnvironment(environmentId); const connected = environment?.connection.phase === "connected" && environment.serverConfig !== null; + const calendarSupported = + environment?.serverConfig?.environment.capabilities.scheduledTaskMonthDays === true; const tasksQuery = useEnvironmentQuery( connected ? serverEnvironment.scheduledTasksLive({ environmentId, input: {} }) : null, ); @@ -1281,6 +1418,14 @@ function ScheduledTaskEditorForm({ for (const error of webhookDraftErrors(draft)) reportFailure(error.title, error.description); return; } + if ( + !calendarSupported && + schedule.type === "fixed_time" && + (schedule.monthDays !== undefined || schedule.months !== undefined) + ) { + reportFailure("Update this environment", MONTH_DAYS_UNSUPPORTED); + return; + } if ( schedule.type === "webhook" && schedule.signature && @@ -1768,42 +1913,11 @@ function ScheduledTaskEditorForm({ ) : null} ) : draft.scheduleMode === "fixed" ? ( -
-
- - - setDraft((current) => ({ ...current, timeOfDay: event.target.value })) - } - /> - on -
- { - if (values.length === 0) return; - setDraft((current) => ({ - ...current, - weekdays: new Set(values.map(Number)), - })); - }} - > - {WEEKDAY_ORDER.map((day) => ( - - {WEEKDAY_SHORT[day]} - - ))} - -
+ ) : (
diff --git a/apps/web/src/components/settings/scheduledTasksSettings.logic.test.ts b/apps/web/src/components/settings/scheduledTasksSettings.logic.test.ts index 7d13e1edc4e36..83a3574bf997f 100644 --- a/apps/web/src/components/settings/scheduledTasksSettings.logic.test.ts +++ b/apps/web/src/components/settings/scheduledTasksSettings.logic.test.ts @@ -344,3 +344,52 @@ it("rejects dedupe templates without a request placeholder", () => { expect(scheduleFromDraft({ ...draft, replayKey: "literal" })).toBeNull(); expect(scheduleFromDraft({ ...draft, coalesceKey: "{{body.bad" })).toBeNull(); }); + +describe("fixed-time scheduled tasks", () => { + const fixedTask = (schedule: ScheduledTask["schedule"]): ScheduledTask => ({ + ...legacyTask, + schedule, + }); + + it("round-trips days of the month limited to some months", () => { + const schedule = { + type: "fixed_time", + timeOfDay: "09:00", + monthDays: [5], + months: [1, 4, 7, 10], + } as const; + const draft = taskToDraft(fixedTask(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("sorts days numerically and sends only the active day selection", () => { + const draft = taskToDraft(fixedTask({ type: "fixed_time", timeOfDay: "09:00" })); + expect( + scheduleFromDraft({ ...draft, dayMode: "month", monthDays: new Set([31, 4, 10]) }), + ).toEqual({ type: "fixed_time", timeOfDay: "09:00", monthDays: [4, 10, 31] }); + expect(scheduleFromDraft({ ...draft, dayMode: "week", weekdays: new Set([5, 1]) })).toEqual({ + type: "fixed_time", + timeOfDay: "09:00", + weekdays: [1, 5], + }); + }); + + it("leaves out selections that cover every day or month", () => { + const draft = taskToDraft(fixedTask({ type: "fixed_time", timeOfDay: "09:00" })); + expect(draft.dayMode).toBe("week"); + expect(draft.limitMonths).toBe(false); + expect(scheduleFromDraft(draft)).toEqual({ type: "fixed_time", timeOfDay: "09:00" }); + expect(scheduleFromDraft({ ...draft, limitMonths: true })).toEqual({ + type: "fixed_time", + timeOfDay: "09:00", + }); + }); +}); diff --git a/apps/web/src/components/settings/scheduledTasksSettings.logic.ts b/apps/web/src/components/settings/scheduledTasksSettings.logic.ts index 27ddc59f84c7d..072c74a1e2b51 100644 --- a/apps/web/src/components/settings/scheduledTasksSettings.logic.ts +++ b/apps/web/src/components/settings/scheduledTasksSettings.logic.ts @@ -11,6 +11,7 @@ import { type ProviderInteractionMode, type ServerSettings, } from "@t3tools/contracts"; +import { MONTHS } from "@t3tools/client-runtime/scheduled-task-schedule"; import { parseMaxDeliveryAge, parseWebhookFilterDrafts, @@ -53,6 +54,7 @@ export function validateScheduledTasksSearch(raw: Record) { } export type ScheduleMode = "fixed" | "interval" | "webhook"; +export type DayMode = "week" | "month"; export type WorkspaceMode = "root" | "worktree" | "existing_worktree"; export interface DraftState { @@ -63,7 +65,12 @@ export interface DraftState { readonly scheduleMode: ScheduleMode; readonly intervalMinutes: string; readonly timeOfDay: string; + readonly dayMode: DayMode; readonly weekdays: ReadonlySet; + readonly monthDays: ReadonlySet; + /** Off runs in every month; the month choices are kept so switching back restores them. */ + readonly limitMonths: boolean; + readonly months: ReadonlySet; readonly projectId: string; readonly threadId: string; readonly workspaceMode: WorkspaceMode; @@ -186,21 +193,33 @@ export function scheduleFromDraft(draft: DraftState): ScheduledTaskUpsertSchedul const everyMs = Math.round(Number(draft.intervalMinutes) * 60_000); return { type: "interval", everyMs }; } - const selectedEveryDay = draft.weekdays.size === 0 || draft.weekdays.size === 7; + const days = + draft.dayMode === "month" + ? chosen(draft.monthDays, 31, (monthDays) => ({ monthDays })) + : chosen(draft.weekdays, 7, (weekdays) => ({ weekdays })); return { type: "fixed_time", timeOfDay: draft.timeOfDay || "09:00", - ...(selectedEveryDay ? {} : { weekdays: [...draft.weekdays].toSorted() }), + ...days, + ...(draft.limitMonths ? chosen(draft.months, 12, (months) => ({ months })) : {}), }; } +/** None and all both mean "no filter", which the schedule leaves out. */ +function chosen(values: ReadonlySet, all: number, field: (values: number[]) => T) { + return values.size === 0 || values.size >= all + ? {} + : field([...values].toSorted((a, b) => a - b)); +} + export function taskToDraft(task: ScheduledTask): DraftState { const schedule = task.schedule; const filters = webhookFilterDrafts(schedule.type === "webhook" ? schedule.filters : undefined); - const weekdays = - schedule.type === "fixed_time" && schedule.weekdays && schedule.weekdays.length > 0 - ? new Set(schedule.weekdays) - : new Set([0, 1, 2, 3, 4, 5, 6]); + const fixed = schedule.type === "fixed_time" ? schedule : null; + const months = new Set(fixed?.months?.length ? fixed.months : MONTHS); + const weekdays = fixed?.weekdays?.length + ? new Set(fixed.weekdays) + : new Set([0, 1, 2, 3, 4, 5, 6]); return { editingId: task.id, title: task.title, @@ -210,8 +229,12 @@ export function taskToDraft(task: ScheduledTask): DraftState { schedule.type === "interval" ? "interval" : schedule.type === "webhook" ? "webhook" : "fixed", intervalMinutes: schedule.type === "interval" ? String(Math.max(1, schedule.everyMs / 60_000)) : "15", - timeOfDay: schedule.type === "fixed_time" ? schedule.timeOfDay : "09:00", + timeOfDay: fixed?.timeOfDay ?? "09:00", + dayMode: fixed?.monthDays?.length ? "month" : "week", weekdays, + monthDays: new Set(fixed?.monthDays?.length ? fixed.monthDays : [1]), + limitMonths: months.size < 12, + months, projectId: task.projectId, threadId: task.threadId ?? "", workspaceMode: task.workspaceStrategy.type, diff --git a/apps/web/src/components/ui/toggle-group.tsx b/apps/web/src/components/ui/toggle-group.tsx index 5ed6b8cc345bf..11684a6b44ace 100644 --- a/apps/web/src/components/ui/toggle-group.tsx +++ b/apps/web/src/components/ui/toggle-group.tsx @@ -33,9 +33,11 @@ function ToggleGroup({ ? "gap-0.5 rounded-lg bg-input/40 p-0.5" : variant === "default" ? "gap-0.5" - : orientation === "horizontal" - ? "*:not-first:not-data-[slot=separator]:before:-start-[0.5px] *:not-last:not-data-[slot=separator]:before:-end-[0.5px] *:not-first:rounded-s-none *:not-last:rounded-e-none *:not-first:border-s-0 *:not-last:border-e-0 *:not-first:before:rounded-s-none *:not-last:before:rounded-e-none" - : "*:not-first:not-data-[slot=separator]:before:-top-[0.5px] *:not-last:not-data-[slot=separator]:before:-bottom-[0.5px] flex-col *:not-first:rounded-t-none *:not-last:rounded-b-none *:not-first:border-t-0 *:not-last:border-b-0 *:not-first:before:rounded-t-none *:not-last:before:rounded-b-none *:data-[slot=toggle]:not-last:before:hidden dark:*:last:before:hidden dark:*:first:before:block", + : variant === "check" + ? "gap-1" + : orientation === "horizontal" + ? "*:not-first:not-data-[slot=separator]:before:-start-[0.5px] *:not-last:not-data-[slot=separator]:before:-end-[0.5px] *:not-first:rounded-s-none *:not-last:rounded-e-none *:not-first:border-s-0 *:not-last:border-e-0 *:not-first:before:rounded-s-none *:not-last:before:rounded-e-none" + : "*:not-first:not-data-[slot=separator]:before:-top-[0.5px] *:not-last:not-data-[slot=separator]:before:-bottom-[0.5px] flex-col *:not-first:rounded-t-none *:not-last:rounded-b-none *:not-first:border-t-0 *:not-last:border-b-0 *:not-first:before:rounded-t-none *:not-last:before:rounded-b-none *:data-[slot=toggle]:not-last:before:hidden dark:*:last:before:hidden dark:*:first:before:block", className, )} data-size={size} diff --git a/apps/web/src/components/ui/toggle.tsx b/apps/web/src/components/ui/toggle.tsx index afe51f0f69388..4ac83e68584bd 100644 --- a/apps/web/src/components/ui/toggle.tsx +++ b/apps/web/src/components/ui/toggle.tsx @@ -1,8 +1,12 @@ import { Toggle as TogglePrimitive } from "@base-ui/react/toggle"; import { cva, type VariantProps } from "class-variance-authority"; +import { CheckIcon } from "lucide-react"; import { cn } from "~/lib/utils"; +const OUTLINE_CLASSES = + "border-input bg-background not-dark:bg-clip-padding shadow-xs/5 not-disabled:not-active:not-data-pressed:before:shadow-[0_1px_--theme(--color-black/4%)] dark:bg-input/32 dark:data-pressed:bg-input dark:hover:bg-input/64 dark:not-disabled:not-active:not-data-pressed:before:shadow-[0_-1px_--theme(--color-white/6%)] dark:not-disabled:not-data-pressed:before:shadow-[0_-1px_--theme(--color-white/2%)] [:disabled,:active,[data-pressed]]:shadow-none"; + const toggleVariants = cva( "[&_svg]:-mx-0.5 relative inline-flex shrink-0 cursor-pointer select-none items-center justify-center gap-2 whitespace-nowrap rounded-lg border font-medium text-base text-foreground outline-none transition-shadow before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-lg)-1px)] pointer-coarse:after:absolute pointer-coarse:after:size-full pointer-coarse:after:min-h-11 pointer-coarse:after:min-w-11 hover:bg-accent focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-64 aria-disabled:opacity-64 data-pressed:bg-input/64 data-pressed:text-accent-foreground sm:text-sm [&_svg:not([class*='opacity-'])]:opacity-80 [&_svg:not([class*='size-'])]:size-4.5 sm:[&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0", { @@ -26,8 +30,9 @@ const toggleVariants = cva( pill: "border-transparent rounded-full px-3 font-normal text-muted-foreground data-pressed:text-foreground", ghost: "border-transparent text-foreground shadow-none [:disabled,:active,[data-pressed]]:shadow-none before:shadow-none data-pressed:bg-accent data-pressed:text-accent-foreground disabled:opacity-100 disabled:text-muted-foreground disabled:[&_svg]:opacity-100", - outline: - "border-input bg-background not-dark:bg-clip-padding shadow-xs/5 not-disabled:not-active:not-data-pressed:before:shadow-[0_1px_--theme(--color-black/4%)] dark:bg-input/32 dark:data-pressed:bg-input dark:hover:bg-input/64 dark:not-disabled:not-active:not-data-pressed:before:shadow-[0_-1px_--theme(--color-white/6%)] dark:not-disabled:not-data-pressed:before:shadow-[0_-1px_--theme(--color-white/2%)] [:disabled,:active,[data-pressed]]:shadow-none", + outline: OUTLINE_CLASSES, + // Multi-select choices: a check marks each chosen option. + check: `${OUTLINE_CLASSES} gap-1`, segmented: "border-transparent text-muted-foreground shadow-none transition-colors before:shadow-none hover:bg-background/55 hover:text-foreground data-pressed:bg-background data-pressed:text-foreground data-pressed:shadow-xs/10 dark:hover:bg-input/32 dark:data-pressed:bg-input/72", }, @@ -39,6 +44,7 @@ function Toggle({ className, variant, size, + children, ...props }: TogglePrimitive.Props & VariantProps) { return ( @@ -46,7 +52,12 @@ function Toggle({ className={cn(toggleVariants({ className, size, variant }))} data-slot="toggle" {...props} - /> + > + {variant === "check" ? ( +