@djangocfg/ui-tools 2.1.130 → 2.1.132

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/README.md +57 -1
  2. package/dist/CronScheduler.client-REXEMXZN.mjs +67 -0
  3. package/dist/CronScheduler.client-REXEMXZN.mjs.map +1 -0
  4. package/dist/CronScheduler.client-YJ2SHYNH.cjs +72 -0
  5. package/dist/CronScheduler.client-YJ2SHYNH.cjs.map +1 -0
  6. package/dist/chunk-6G72N466.mjs +995 -0
  7. package/dist/chunk-6G72N466.mjs.map +1 -0
  8. package/dist/chunk-74JT4UIM.cjs +1015 -0
  9. package/dist/chunk-74JT4UIM.cjs.map +1 -0
  10. package/dist/index.cjs +109 -0
  11. package/dist/index.cjs.map +1 -1
  12. package/dist/index.d.cts +280 -1
  13. package/dist/index.d.ts +280 -1
  14. package/dist/index.mjs +32 -1
  15. package/dist/index.mjs.map +1 -1
  16. package/package.json +6 -6
  17. package/src/index.ts +5 -0
  18. package/src/tools/CronScheduler/CronScheduler.client.tsx +140 -0
  19. package/src/tools/CronScheduler/CronScheduler.story.tsx +220 -0
  20. package/src/tools/CronScheduler/components/CronCheatsheet.tsx +101 -0
  21. package/src/tools/CronScheduler/components/CustomInput.tsx +67 -0
  22. package/src/tools/CronScheduler/components/DayChips.tsx +130 -0
  23. package/src/tools/CronScheduler/components/MonthDayGrid.tsx +143 -0
  24. package/src/tools/CronScheduler/components/SchedulePreview.tsx +103 -0
  25. package/src/tools/CronScheduler/components/ScheduleTypeSelector.tsx +57 -0
  26. package/src/tools/CronScheduler/components/TimeSelector.tsx +132 -0
  27. package/src/tools/CronScheduler/components/index.ts +24 -0
  28. package/src/tools/CronScheduler/context/CronSchedulerContext.tsx +242 -0
  29. package/src/tools/CronScheduler/context/hooks.ts +86 -0
  30. package/src/tools/CronScheduler/context/index.ts +18 -0
  31. package/src/tools/CronScheduler/index.tsx +91 -0
  32. package/src/tools/CronScheduler/lazy.tsx +67 -0
  33. package/src/tools/CronScheduler/types/index.ts +112 -0
  34. package/src/tools/CronScheduler/utils/cron-builder.ts +100 -0
  35. package/src/tools/CronScheduler/utils/cron-humanize.ts +218 -0
  36. package/src/tools/CronScheduler/utils/cron-parser.ts +188 -0
  37. package/src/tools/CronScheduler/utils/index.ts +12 -0
  38. package/src/tools/index.ts +36 -0
@@ -0,0 +1,143 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * MonthDayGrid
5
+ *
6
+ * Full-width calendar-style grid for selecting days of the month.
7
+ * Clean, intuitive design like a mini calendar.
8
+ */
9
+
10
+ import { cn } from '@djangocfg/ui-core/lib';
11
+ import { useCronMonthDays } from '../context/hooks';
12
+ import type { MonthDay } from '../types';
13
+
14
+ // Generate days 1-31
15
+ const DAYS: MonthDay[] = Array.from({ length: 31 }, (_, i) => (i + 1) as MonthDay);
16
+
17
+ // Fill to complete last row (31 days = 4 full rows + 3 days, need 4 more to fill)
18
+ const GRID_SIZE = 35; // 5 rows x 7 columns
19
+
20
+ export interface MonthDayGridProps {
21
+ disabled?: boolean;
22
+ showPresets?: boolean;
23
+ className?: string;
24
+ }
25
+
26
+ export function MonthDayGrid({
27
+ disabled,
28
+ showPresets = true,
29
+ className,
30
+ }: MonthDayGridProps) {
31
+ const { monthDays, toggleMonthDay, setMonthDays } = useCronMonthDays();
32
+
33
+ const is1st = monthDays.length === 1 && monthDays[0] === 1;
34
+ const is15th = monthDays.length === 1 && monthDays[0] === 15;
35
+ const is1stAnd15th = monthDays.length === 2 && monthDays.includes(1) && monthDays.includes(15);
36
+
37
+ return (
38
+ <div className={cn('space-y-3', className)}>
39
+ {/* Quick Presets */}
40
+ {showPresets && (
41
+ <div className="flex gap-2">
42
+ <PresetButton
43
+ label="1st"
44
+ isActive={is1st}
45
+ onClick={() => setMonthDays([1] as MonthDay[])}
46
+ disabled={disabled}
47
+ />
48
+ <PresetButton
49
+ label="15th"
50
+ isActive={is15th}
51
+ onClick={() => setMonthDays([15] as MonthDay[])}
52
+ disabled={disabled}
53
+ />
54
+ <PresetButton
55
+ label="1st & 15th"
56
+ isActive={is1stAnd15th}
57
+ onClick={() => setMonthDays([1, 15] as MonthDay[])}
58
+ disabled={disabled}
59
+ />
60
+ </div>
61
+ )}
62
+
63
+ {/* Calendar Grid - full width */}
64
+ <div className="grid grid-cols-7 gap-1">
65
+ {Array.from({ length: GRID_SIZE }, (_, i) => {
66
+ const day = i + 1;
67
+ const isValidDay = day <= 31;
68
+ const isSelected = isValidDay && monthDays.includes(day as MonthDay);
69
+ // Days that don't exist in some months
70
+ const isPartialMonth = day > 28;
71
+
72
+ if (!isValidDay) {
73
+ return <div key={i} className="aspect-square" />;
74
+ }
75
+
76
+ return (
77
+ <button
78
+ key={day}
79
+ type="button"
80
+ disabled={disabled}
81
+ onClick={() => toggleMonthDay(day as MonthDay)}
82
+ aria-pressed={isSelected}
83
+ aria-label={`Day ${day}`}
84
+ className={cn(
85
+ 'aspect-square flex items-center justify-center',
86
+ 'rounded-lg text-sm font-medium',
87
+ 'transition-all duration-150',
88
+ 'focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/50',
89
+ 'active:scale-[0.95]',
90
+ isSelected
91
+ ? 'bg-primary text-primary-foreground shadow-sm'
92
+ : cn(
93
+ 'bg-muted/30 hover:bg-muted/60',
94
+ isPartialMonth
95
+ ? 'text-muted-foreground/50'
96
+ : 'text-muted-foreground'
97
+ ),
98
+ disabled && 'opacity-50 cursor-not-allowed pointer-events-none'
99
+ )}
100
+ >
101
+ {day}
102
+ </button>
103
+ );
104
+ })}
105
+ </div>
106
+
107
+ {/* Selection hint */}
108
+ {monthDays.length > 1 && (
109
+ <p className="text-xs text-muted-foreground text-center">
110
+ {monthDays.length} days selected
111
+ </p>
112
+ )}
113
+ </div>
114
+ );
115
+ }
116
+
117
+ interface PresetButtonProps {
118
+ label: string;
119
+ isActive: boolean;
120
+ onClick: () => void;
121
+ disabled?: boolean;
122
+ }
123
+
124
+ function PresetButton({ label, isActive, onClick, disabled }: PresetButtonProps) {
125
+ return (
126
+ <button
127
+ type="button"
128
+ disabled={disabled}
129
+ onClick={onClick}
130
+ className={cn(
131
+ 'flex-1 px-3 py-1.5 rounded-md text-xs font-medium',
132
+ 'transition-colors duration-150',
133
+ 'focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/50',
134
+ isActive
135
+ ? 'bg-primary/15 text-primary border border-primary/30'
136
+ : 'bg-muted/30 text-muted-foreground hover:bg-muted/50 border border-transparent',
137
+ disabled && 'opacity-50 cursor-not-allowed'
138
+ )}
139
+ >
140
+ {label}
141
+ </button>
142
+ );
143
+ }
@@ -0,0 +1,103 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * SchedulePreview
5
+ *
6
+ * Human-readable preview with cron expression and cheatsheet.
7
+ */
8
+
9
+ import { useState, useCallback } from 'react';
10
+ import { Calendar, Copy, Check } from 'lucide-react';
11
+ import { cn } from '@djangocfg/ui-core/lib';
12
+ import { useCronPreview } from '../context/hooks';
13
+ import { CronCheatsheet } from './CronCheatsheet';
14
+
15
+ export interface SchedulePreviewProps {
16
+ /** Show raw cron expression (default: true) */
17
+ showCronExpression?: boolean;
18
+ /** Enable copy to clipboard */
19
+ allowCopy?: boolean;
20
+ /** Additional CSS classes */
21
+ className?: string;
22
+ }
23
+
24
+ export function SchedulePreview({
25
+ showCronExpression = true,
26
+ allowCopy = false,
27
+ className,
28
+ }: SchedulePreviewProps) {
29
+ const { cronExpression, humanDescription, isValid } = useCronPreview();
30
+ const [copied, setCopied] = useState(false);
31
+
32
+ const handleCopy = useCallback(async () => {
33
+ try {
34
+ await navigator.clipboard.writeText(cronExpression);
35
+ setCopied(true);
36
+ setTimeout(() => setCopied(false), 2000);
37
+ } catch (err) {
38
+ console.error('Failed to copy:', err);
39
+ }
40
+ }, [cronExpression]);
41
+
42
+ return (
43
+ <div
44
+ className={cn(
45
+ 'px-3 py-2.5 rounded-lg border',
46
+ 'bg-muted/30 border-border/50',
47
+ !isValid && 'border-destructive/30 bg-destructive/5',
48
+ className
49
+ )}
50
+ >
51
+ {/* Human description */}
52
+ <div className="flex items-center justify-between gap-2">
53
+ <div className="flex items-center gap-2 min-w-0">
54
+ <Calendar
55
+ className={cn(
56
+ 'h-4 w-4 shrink-0',
57
+ isValid ? 'text-primary' : 'text-destructive'
58
+ )}
59
+ />
60
+ <span
61
+ className={cn(
62
+ 'text-sm',
63
+ isValid ? 'text-foreground' : 'text-destructive'
64
+ )}
65
+ >
66
+ {humanDescription}
67
+ </span>
68
+ </div>
69
+
70
+ {/* Copy Button */}
71
+ {allowCopy && (
72
+ <button
73
+ type="button"
74
+ onClick={handleCopy}
75
+ disabled={!isValid}
76
+ className={cn(
77
+ 'p-1 rounded transition-colors duration-150',
78
+ 'hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/50',
79
+ 'disabled:opacity-50 disabled:cursor-not-allowed'
80
+ )}
81
+ title={copied ? 'Copied!' : 'Copy cron expression'}
82
+ >
83
+ {copied ? (
84
+ <Check className="h-3.5 w-3.5 text-green-500" />
85
+ ) : (
86
+ <Copy className="h-3.5 w-3.5 text-muted-foreground" />
87
+ )}
88
+ </button>
89
+ )}
90
+ </div>
91
+
92
+ {/* Cron expression with cheatsheet */}
93
+ {showCronExpression && (
94
+ <div className="mt-1.5 pt-1.5 border-t border-border/30 flex items-center justify-between">
95
+ <code className="text-xs font-mono text-muted-foreground">
96
+ {cronExpression}
97
+ </code>
98
+ <CronCheatsheet />
99
+ </div>
100
+ )}
101
+ </div>
102
+ );
103
+ }
@@ -0,0 +1,57 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * ScheduleTypeSelector
5
+ *
6
+ * Segmented control for selecting schedule type.
7
+ * Uses Tabs from ui-core styled as a compact segmented control.
8
+ */
9
+
10
+ import { Tabs, TabsList, TabsTrigger } from '@djangocfg/ui-core/components';
11
+ import { cn } from '@djangocfg/ui-core/lib';
12
+ import { useCronType } from '../context/hooks';
13
+ import type { ScheduleType } from '../types';
14
+
15
+ const SCHEDULE_TYPES: { value: ScheduleType; label: string }[] = [
16
+ { value: 'daily', label: 'Daily' },
17
+ { value: 'weekly', label: 'Weekly' },
18
+ { value: 'monthly', label: 'Monthly' },
19
+ { value: 'custom', label: 'Custom' },
20
+ ];
21
+
22
+ export interface ScheduleTypeSelectorProps {
23
+ disabled?: boolean;
24
+ className?: string;
25
+ }
26
+
27
+ export function ScheduleTypeSelector({
28
+ disabled,
29
+ className,
30
+ }: ScheduleTypeSelectorProps) {
31
+ const { type, setType } = useCronType();
32
+
33
+ return (
34
+ <Tabs
35
+ value={type}
36
+ onValueChange={(v) => setType(v as ScheduleType)}
37
+ className={cn('w-full', className)}
38
+ >
39
+ <TabsList className="grid w-full grid-cols-4 h-9 p-0.5">
40
+ {SCHEDULE_TYPES.map(({ value, label }) => (
41
+ <TabsTrigger
42
+ key={value}
43
+ value={value}
44
+ disabled={disabled}
45
+ className={cn(
46
+ 'text-xs font-medium px-2 py-1.5',
47
+ 'data-[state=active]:shadow-sm',
48
+ 'transition-all duration-150'
49
+ )}
50
+ >
51
+ {label}
52
+ </TabsTrigger>
53
+ ))}
54
+ </TabsList>
55
+ </Tabs>
56
+ );
57
+ }
@@ -0,0 +1,132 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * TimeSelector
5
+ *
6
+ * Clean time picker with hour and minute selects.
7
+ */
8
+
9
+ import {
10
+ Select,
11
+ SelectContent,
12
+ SelectItem,
13
+ SelectTrigger,
14
+ SelectValue,
15
+ } from '@djangocfg/ui-core/components';
16
+ import { cn } from '@djangocfg/ui-core/lib';
17
+ import { Clock } from 'lucide-react';
18
+ import { useCronTime } from '../context/hooks';
19
+
20
+ const HOURS = Array.from({ length: 24 }, (_, i) => i);
21
+ const MINUTES = [0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55];
22
+
23
+ export interface TimeSelectorProps {
24
+ format?: '12h' | '24h';
25
+ disabled?: boolean;
26
+ className?: string;
27
+ }
28
+
29
+ export function TimeSelector({
30
+ format = '24h',
31
+ disabled,
32
+ className,
33
+ }: TimeSelectorProps) {
34
+ const { hour, minute, setTime } = useCronTime();
35
+
36
+ const is24h = format === '24h';
37
+ const displayHour = is24h ? hour : (hour % 12) || 12;
38
+ const isPM = hour >= 12;
39
+
40
+ const handleHourChange = (value: string) => {
41
+ let newHour = parseInt(value, 10);
42
+ if (!is24h) {
43
+ if (isPM && newHour !== 12) newHour += 12;
44
+ else if (!isPM && newHour === 12) newHour = 0;
45
+ }
46
+ setTime(newHour, minute);
47
+ };
48
+
49
+ const handleMinuteChange = (value: string) => {
50
+ setTime(hour, parseInt(value, 10));
51
+ };
52
+
53
+ const handlePeriodChange = (value: string) => {
54
+ const newIsPM = value === 'PM';
55
+ let newHour = hour;
56
+ if (newIsPM && hour < 12) newHour = hour + 12;
57
+ else if (!newIsPM && hour >= 12) newHour = hour - 12;
58
+ setTime(newHour, minute);
59
+ };
60
+
61
+ const hours = is24h ? HOURS : Array.from({ length: 12 }, (_, i) => i + 1);
62
+
63
+ return (
64
+ <div className={cn('flex items-center gap-3', className)}>
65
+ <div className="flex items-center gap-2 text-muted-foreground">
66
+ <Clock className="h-4 w-4" />
67
+ <span className="text-sm">Run at</span>
68
+ </div>
69
+
70
+ <div className="flex items-center gap-1.5 flex-1">
71
+ {/* Hour */}
72
+ <Select
73
+ value={displayHour.toString()}
74
+ onValueChange={handleHourChange}
75
+ disabled={disabled}
76
+ >
77
+ <SelectTrigger className="w-[70px] h-9">
78
+ <SelectValue>
79
+ {displayHour.toString().padStart(2, '0')}
80
+ </SelectValue>
81
+ </SelectTrigger>
82
+ <SelectContent className="max-h-48">
83
+ {hours.map((h) => (
84
+ <SelectItem key={h} value={h.toString()}>
85
+ {h.toString().padStart(2, '0')}
86
+ </SelectItem>
87
+ ))}
88
+ </SelectContent>
89
+ </Select>
90
+
91
+ <span className="text-muted-foreground font-medium">:</span>
92
+
93
+ {/* Minute */}
94
+ <Select
95
+ value={minute.toString()}
96
+ onValueChange={handleMinuteChange}
97
+ disabled={disabled}
98
+ >
99
+ <SelectTrigger className="w-[70px] h-9">
100
+ <SelectValue>
101
+ {minute.toString().padStart(2, '0')}
102
+ </SelectValue>
103
+ </SelectTrigger>
104
+ <SelectContent className="max-h-48">
105
+ {MINUTES.map((m) => (
106
+ <SelectItem key={m} value={m.toString()}>
107
+ {m.toString().padStart(2, '0')}
108
+ </SelectItem>
109
+ ))}
110
+ </SelectContent>
111
+ </Select>
112
+
113
+ {/* AM/PM for 12h */}
114
+ {!is24h && (
115
+ <Select
116
+ value={isPM ? 'PM' : 'AM'}
117
+ onValueChange={handlePeriodChange}
118
+ disabled={disabled}
119
+ >
120
+ <SelectTrigger className="w-[70px] h-9">
121
+ <SelectValue />
122
+ </SelectTrigger>
123
+ <SelectContent>
124
+ <SelectItem value="AM">AM</SelectItem>
125
+ <SelectItem value="PM">PM</SelectItem>
126
+ </SelectContent>
127
+ </Select>
128
+ )}
129
+ </div>
130
+ </div>
131
+ );
132
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * CronScheduler Components
3
+ */
4
+
5
+ export { ScheduleTypeSelector } from './ScheduleTypeSelector';
6
+ export type { ScheduleTypeSelectorProps } from './ScheduleTypeSelector';
7
+
8
+ export { TimeSelector } from './TimeSelector';
9
+ export type { TimeSelectorProps } from './TimeSelector';
10
+
11
+ export { DayChips } from './DayChips';
12
+ export type { DayChipsProps } from './DayChips';
13
+
14
+ export { MonthDayGrid } from './MonthDayGrid';
15
+ export type { MonthDayGridProps } from './MonthDayGrid';
16
+
17
+ export { CustomInput } from './CustomInput';
18
+ export type { CustomInputProps } from './CustomInput';
19
+
20
+ export { SchedulePreview } from './SchedulePreview';
21
+ export type { SchedulePreviewProps } from './SchedulePreview';
22
+
23
+ export { CronCheatsheet } from './CronCheatsheet';
24
+ export type { CronCheatsheetProps } from './CronCheatsheet';
@@ -0,0 +1,242 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * CronScheduler Context
5
+ *
6
+ * Provides centralized state management for the CronScheduler component.
7
+ * All child components subscribe to this context for state and actions.
8
+ */
9
+
10
+ import {
11
+ createContext,
12
+ useContext,
13
+ useMemo,
14
+ useCallback,
15
+ useState,
16
+ useEffect,
17
+ useRef,
18
+ } from 'react';
19
+ import type {
20
+ CronSchedulerContextValue,
21
+ CronSchedulerState,
22
+ CronSchedulerProviderProps,
23
+ ScheduleType,
24
+ WeekDay,
25
+ MonthDay,
26
+ } from '../types';
27
+ import { buildCron } from '../utils/cron-builder';
28
+ import { parseCron } from '../utils/cron-parser';
29
+ import { humanizeCron } from '../utils/cron-humanize';
30
+
31
+ // ============================================================================
32
+ // Context
33
+ // ============================================================================
34
+
35
+ const CronSchedulerContext = createContext<CronSchedulerContextValue | null>(null);
36
+
37
+ // ============================================================================
38
+ // Default State
39
+ // ============================================================================
40
+
41
+ const DEFAULT_STATE: CronSchedulerState = {
42
+ type: 'daily',
43
+ hour: 9,
44
+ minute: 0,
45
+ weekDays: [1, 2, 3, 4, 5] as WeekDay[], // Mon-Fri
46
+ monthDays: [1] as MonthDay[],
47
+ customCron: '* * * * *',
48
+ isValid: true,
49
+ };
50
+
51
+ // ============================================================================
52
+ // Provider
53
+ // ============================================================================
54
+
55
+ export function CronSchedulerProvider({
56
+ children,
57
+ value,
58
+ onChange,
59
+ defaultType = 'daily',
60
+ }: CronSchedulerProviderProps) {
61
+ // Track if this is initial mount to avoid calling onChange on mount
62
+ const isInitialMount = useRef(true);
63
+
64
+ // Store onChange in a ref to avoid triggering effects when it changes
65
+ const onChangeRef = useRef(onChange);
66
+ onChangeRef.current = onChange;
67
+
68
+ // Initialize state from value prop or defaults
69
+ const [state, setState] = useState<CronSchedulerState>(() => {
70
+ if (value) {
71
+ const parsed = parseCron(value);
72
+ if (parsed) return parsed;
73
+ }
74
+ return { ...DEFAULT_STATE, type: defaultType };
75
+ });
76
+
77
+ // Sync with external value (controlled component support)
78
+ useEffect(() => {
79
+ if (value) {
80
+ const parsed = parseCron(value);
81
+ if (parsed) {
82
+ const currentCron = buildCron(state);
83
+ if (value !== currentCron) {
84
+ setState(parsed);
85
+ }
86
+ }
87
+ }
88
+ }, [value]); // eslint-disable-line react-hooks/exhaustive-deps
89
+
90
+ // Computed: cron expression
91
+ const cronExpression = useMemo(() => buildCron(state), [state]);
92
+
93
+ // Computed: human-readable description
94
+ const humanDescription = useMemo(() => humanizeCron(cronExpression), [cronExpression]);
95
+
96
+ // Notify parent on change (skip initial mount)
97
+ // Use ref to avoid re-triggering when onChange identity changes
98
+ useEffect(() => {
99
+ if (isInitialMount.current) {
100
+ isInitialMount.current = false;
101
+ return;
102
+ }
103
+ onChangeRef.current?.(cronExpression);
104
+ }, [cronExpression]);
105
+
106
+ // ============================================================================
107
+ // Actions
108
+ // ============================================================================
109
+
110
+ const setType = useCallback((type: ScheduleType) => {
111
+ setState(s => ({ ...s, type }));
112
+ }, []);
113
+
114
+ const setTime = useCallback((hour: number, minute: number) => {
115
+ setState(s => ({
116
+ ...s,
117
+ hour: Math.max(0, Math.min(23, hour)),
118
+ minute: Math.max(0, Math.min(59, minute)),
119
+ }));
120
+ }, []);
121
+
122
+ const toggleWeekDay = useCallback((day: WeekDay) => {
123
+ setState(s => {
124
+ const hasDay = s.weekDays.includes(day);
125
+ // Prevent deselecting the last day
126
+ if (hasDay && s.weekDays.length === 1) return s;
127
+
128
+ const newDays = hasDay
129
+ ? s.weekDays.filter(d => d !== day)
130
+ : [...s.weekDays, day];
131
+
132
+ return {
133
+ ...s,
134
+ weekDays: newDays.sort((a, b) => a - b) as WeekDay[],
135
+ };
136
+ });
137
+ }, []);
138
+
139
+ const setWeekDays = useCallback((days: WeekDay[]) => {
140
+ setState(s => ({
141
+ ...s,
142
+ weekDays: days.length > 0
143
+ ? [...days].sort((a, b) => a - b) as WeekDay[]
144
+ : [1] as WeekDay[], // Default to Monday if empty
145
+ }));
146
+ }, []);
147
+
148
+ const toggleMonthDay = useCallback((day: MonthDay) => {
149
+ setState(s => {
150
+ const hasDay = s.monthDays.includes(day);
151
+ // Prevent deselecting the last day
152
+ if (hasDay && s.monthDays.length === 1) return s;
153
+
154
+ const newDays = hasDay
155
+ ? s.monthDays.filter(d => d !== day)
156
+ : [...s.monthDays, day];
157
+
158
+ return {
159
+ ...s,
160
+ monthDays: newDays.sort((a, b) => a - b) as MonthDay[],
161
+ };
162
+ });
163
+ }, []);
164
+
165
+ const setMonthDays = useCallback((days: MonthDay[]) => {
166
+ setState(s => ({
167
+ ...s,
168
+ monthDays: days.length > 0
169
+ ? [...days].sort((a, b) => a - b) as MonthDay[]
170
+ : [1] as MonthDay[], // Default to 1st if empty
171
+ }));
172
+ }, []);
173
+
174
+ const setCustomCron = useCallback((customCron: string) => {
175
+ const parsed = parseCron(customCron);
176
+ setState(s => ({
177
+ ...s,
178
+ customCron,
179
+ isValid: parsed !== null,
180
+ }));
181
+ }, []);
182
+
183
+ const reset = useCallback(() => {
184
+ setState({ ...DEFAULT_STATE, type: defaultType });
185
+ }, [defaultType]);
186
+
187
+ // ============================================================================
188
+ // Context Value
189
+ // ============================================================================
190
+
191
+ const contextValue: CronSchedulerContextValue = useMemo(
192
+ () => ({
193
+ // State
194
+ ...state,
195
+ // Computed
196
+ cronExpression,
197
+ humanDescription,
198
+ // Actions
199
+ setType,
200
+ setTime,
201
+ toggleWeekDay,
202
+ setWeekDays,
203
+ toggleMonthDay,
204
+ setMonthDays,
205
+ setCustomCron,
206
+ reset,
207
+ }),
208
+ [
209
+ state,
210
+ cronExpression,
211
+ humanDescription,
212
+ setType,
213
+ setTime,
214
+ toggleWeekDay,
215
+ setWeekDays,
216
+ toggleMonthDay,
217
+ setMonthDays,
218
+ setCustomCron,
219
+ reset,
220
+ ]
221
+ );
222
+
223
+ return (
224
+ <CronSchedulerContext.Provider value={contextValue}>
225
+ {children}
226
+ </CronSchedulerContext.Provider>
227
+ );
228
+ }
229
+
230
+ // ============================================================================
231
+ // Context Hook
232
+ // ============================================================================
233
+
234
+ export function useCronSchedulerContext(): CronSchedulerContextValue {
235
+ const context = useContext(CronSchedulerContext);
236
+ if (!context) {
237
+ throw new Error(
238
+ 'useCronSchedulerContext must be used within CronSchedulerProvider'
239
+ );
240
+ }
241
+ return context;
242
+ }