diff --git a/server/routers/schedules.py b/server/routers/schedules.py index ea9c144..6138824 100644 --- a/server/routers/schedules.py +++ b/server/routers/schedules.py @@ -306,19 +306,11 @@ async def update_schedule( was_enabled = schedule.enabled - # Update fields - if data.start_time is not None: - schedule.start_time = data.start_time - if data.duration_minutes is not None: - schedule.duration_minutes = data.duration_minutes - if data.days_of_week is not None: - schedule.days_of_week = data.days_of_week - if data.enabled is not None: - schedule.enabled = data.enabled - if data.yolo_mode is not None: - schedule.yolo_mode = data.yolo_mode - if data.model is not None: - schedule.model = data.model + # Update only fields that were explicitly provided + # This allows sending {"model": null} to clear it vs omitting the field entirely + update_data = data.model_dump(exclude_unset=True) + for field, value in update_data.items(): + setattr(schedule, field, value) db.commit() db.refresh(schedule) diff --git a/server/services/scheduler_service.py b/server/services/scheduler_service.py index e20400b..7495165 100644 --- a/server/services/scheduler_service.py +++ b/server/services/scheduler_service.py @@ -131,6 +131,9 @@ class SchedulerService: start_dt = datetime.strptime(schedule.start_time, "%H:%M") end_dt = start_dt + timedelta(minutes=schedule.duration_minutes) + # Detect midnight crossing + crosses_midnight = end_dt.date() != start_dt.date() + # Handle midnight wraparound for end time end_hour = end_dt.hour end_minute = end_dt.minute @@ -148,8 +151,15 @@ class SchedulerService: ) # Stop job - CRITICAL: timezone=timezone.utc is required for correct UTC scheduling + # If schedule crosses midnight, shift days forward so stop occurs on next day stop_job_id = f"schedule_{schedule.id}_stop" - stop_trigger = CronTrigger(hour=end_hour, minute=end_minute, day_of_week=days, timezone=timezone.utc) + if crosses_midnight: + shifted_bitfield = self._shift_days_forward(schedule.days_of_week) + stop_days = self._bitfield_to_cron_days(shifted_bitfield) + else: + stop_days = days + + stop_trigger = CronTrigger(hour=end_hour, minute=end_minute, day_of_week=stop_days, timezone=timezone.utc) self.scheduler.add_job( self._handle_scheduled_stop, stop_trigger, @@ -304,27 +314,34 @@ class SchedulerService: def _is_within_window(self, schedule, now: datetime) -> bool: """Check if current time is within schedule window.""" - # Check if active on this day - if not schedule.is_active_on_day(now.weekday()): - return False - - # Parse schedule times + # Parse schedule times (keep timezone awareness from now) start_hour, start_minute = map(int, schedule.start_time.split(":")) start_time = now.replace(hour=start_hour, minute=start_minute, second=0, microsecond=0) # Calculate end time end_time = start_time + timedelta(minutes=schedule.duration_minutes) - current_time = now.replace(tzinfo=None) if now.tzinfo else now - start_time = start_time.replace(tzinfo=None) - end_time = end_time.replace(tzinfo=None) + # Detect midnight crossing + crosses_midnight = end_time < start_time or end_time.date() != start_time.date() - # Handle midnight wraparound - if end_time.day > start_time.day: - # Schedule crosses midnight - return current_time >= start_time or current_time < end_time.replace(day=start_time.day) + if crosses_midnight: + # Check today's window (start_time to midnight) OR yesterday's window (midnight to end_time) + # Today: if we're after start_time on the current day + if schedule.is_active_on_day(now.weekday()) and now >= start_time: + return True + + # Yesterday: check if we're before end_time and yesterday was active + yesterday = (now.weekday() - 1) % 7 + if schedule.is_active_on_day(yesterday): + yesterday_start = start_time - timedelta(days=1) + yesterday_end = end_time - timedelta(days=1) + if yesterday_start <= now < yesterday_end: + return True + + return False else: - return start_time <= current_time < end_time + # Normal case: doesn't cross midnight + return schedule.is_active_on_day(now.weekday()) and start_time <= now < end_time async def _start_agent(self, project_name: str, project_dir: Path, schedule): """Start the agent for a project.""" @@ -563,6 +580,27 @@ class SchedulerService: except Exception as e: logger.error(f"Error checking startup for {project_name}: {e}") + @staticmethod + def _shift_days_forward(bitfield: int) -> int: + """ + Shift the 7-bit day mask forward by one day for midnight-crossing schedules. + + Examples: + Monday (1) -> Tuesday (2) + Sunday (64) -> Monday (1) + Mon+Tue (3) -> Tue+Wed (6) + """ + shifted = 0 + # Shift each day forward, wrapping Sunday to Monday + if bitfield & 1: shifted |= 2 # Mon -> Tue + if bitfield & 2: shifted |= 4 # Tue -> Wed + if bitfield & 4: shifted |= 8 # Wed -> Thu + if bitfield & 8: shifted |= 16 # Thu -> Fri + if bitfield & 16: shifted |= 32 # Fri -> Sat + if bitfield & 32: shifted |= 64 # Sat -> Sun + if bitfield & 64: shifted |= 1 # Sun -> Mon + return shifted + @staticmethod def _bitfield_to_cron_days(bitfield: int) -> str: """Convert days bitfield to APScheduler cron format.""" diff --git a/ui/src/components/ScheduleModal.tsx b/ui/src/components/ScheduleModal.tsx index 562bf9d..1f454c8 100644 --- a/ui/src/components/ScheduleModal.tsx +++ b/ui/src/components/ScheduleModal.tsx @@ -102,6 +102,12 @@ export function ScheduleModal({ projectName, isOpen, onClose }: ScheduleModalPro return } + // Validate duration + if (newSchedule.duration_minutes < 1 || newSchedule.duration_minutes > 1440) { + setError('Duration must be between 1 and 1440 minutes') + return + } + // Convert local time to UTC const scheduleToCreate = { ...newSchedule, @@ -309,12 +315,14 @@ export function ScheduleModal({ projectName, isOpen, onClose }: ScheduleModalPro min="1" max="1440" value={newSchedule.duration_minutes} - onChange={(e) => + onChange={(e) => { + const parsed = parseInt(e.target.value, 10) + const value = isNaN(parsed) ? 1 : Math.max(1, Math.min(1440, parsed)) setNewSchedule((prev) => ({ ...prev, - duration_minutes: parseInt(e.target.value) || 0, + duration_minutes: value, })) - } + }} className="neo-input w-full" />
diff --git a/ui/src/lib/timeUtils.ts b/ui/src/lib/timeUtils.ts index 9eac6a3..036c171 100644 --- a/ui/src/lib/timeUtils.ts +++ b/ui/src/lib/timeUtils.ts @@ -73,7 +73,7 @@ export function formatNextRun(isoString: string): string { } // Further out - show day and time - return date.toLocaleDateString([], { + return date.toLocaleString([], { weekday: 'short', hour: 'numeric', minute: '2-digit'