package components import ( "context" "fmt" "github.com/starfleetcptn/gomft/internal/db" "time" "encoding/json" "strings" "strconv" "sort" ) // JobCalendarData contains the data for the calendar view type JobCalendarData struct { Jobs []db.Job } // generateCalendarEvents converts jobs to calendar events in JSON format func generateCalendarEvents(jobs []db.Job) string { type CalendarEvent struct { ID string `json:"id"` Title string `json:"title"` Start string `json:"start"` End string `json:"end,omitempty"` AllDay bool `json:"allDay,omitempty"` URL string `json:"url,omitempty"` ClassName string `json:"className,omitempty"` Description string `json:"description,omitempty"` Enabled bool `json:"enabled"` JobID uint `json:"jobId"` JobName string `json:"jobName"` RunTimes []string `json:"runTimes,omitempty"` // Store additional run times for this day RunCount int `json:"runCount,omitempty"` // Count of runs on this day Schedule string `json:"schedule,omitempty"` // Store the schedule for tooltip } var events []CalendarEvent // Set the range for future occurrences - 2 months seems to be a good balance now := time.Now() twoMonthsLater := now.AddDate(0, 2, 0) // Map to track events by job ID and date to consolidate multiple occurrences eventsByJobAndDay := make(map[string][]time.Time) // Store job information for easy access jobInfo := make(map[uint]struct { Name string Enabled bool Schedule string }) // First, gather all runs and group them by job ID and day for _, job := range jobs { // Skip jobs with no next run time if job.NextRun == nil { continue } // Store job information jobName := job.Name if jobName == "" { jobName = job.Config.Name } jobInfo[job.ID] = struct { Name string Enabled bool Schedule string }{ Name: jobName, Enabled: job.GetEnabled(), Schedule: job.Schedule, } // Create the first occurrence based on NextRun nextRun := *job.NextRun // Skip past events that are more than a day old oneDayAgo := now.AddDate(0, 0, -1) if nextRun.Before(oneDayAgo) { // For past events, if we have LastRun, use that instead if job.LastRun != nil { nextRun = *job.LastRun // Still skip if it's too old if nextRun.Before(oneDayAgo) { continue } } else { continue } } // Add initial run to the map dateKey := fmt.Sprintf("%d-%s", job.ID, nextRun.Format("2006-01-02")) eventsByJobAndDay[dateKey] = append(eventsByJobAndDay[dateKey], nextRun) // Try to determine future occurrences based on the cron schedule var interval time.Duration schedule := strings.ToLower(job.Schedule) // Determine interval based on schedule switch { case strings.Contains(schedule, "every minute") || strings.Contains(schedule, "* * * * *"): interval = 1 * time.Minute case strings.Contains(schedule, "every 5 minutes") || strings.Contains(schedule, "*/5 * * * *"): interval = 5 * time.Minute case strings.Contains(schedule, "every 10 minutes") || strings.Contains(schedule, "*/10 * * * *"): interval = 10 * time.Minute case strings.Contains(schedule, "every 15 minutes") || strings.Contains(schedule, "*/15 * * * *"): interval = 15 * time.Minute case strings.Contains(schedule, "every 30 minutes") || strings.Contains(schedule, "*/30 * * * *"): interval = 30 * time.Minute case strings.Contains(schedule, "hourly") || strings.Contains(schedule, "0 * * * *"): interval = 1 * time.Hour case strings.Contains(schedule, "every 2 hours") || strings.Contains(schedule, "0 */2 * * *"): interval = 2 * time.Hour case strings.Contains(schedule, "every 3 hours") || strings.Contains(schedule, "0 */3 * * *"): interval = 3 * time.Hour case strings.Contains(schedule, "every 4 hours") || strings.Contains(schedule, "0 */4 * * *"): interval = 4 * time.Hour case strings.Contains(schedule, "every 6 hours") || strings.Contains(schedule, "0 */6 * * *"): interval = 6 * time.Hour case strings.Contains(schedule, "every 12 hours") || strings.Contains(schedule, "0 */12 * * *"): interval = 12 * time.Hour case strings.Contains(schedule, "daily") || strings.Contains(schedule, "0 0 * * *"): interval = 24 * time.Hour case strings.Contains(schedule, "weekly") || strings.Contains(schedule, "0 0 * * 0"): interval = 7 * 24 * time.Hour case strings.Contains(schedule, "monthly") || strings.Contains(schedule, "0 0 1 * *"): // Approximate as 30 days interval = 30 * 24 * time.Hour default: // For other schedules, try a simple cron expression check if strings.Contains(schedule, "* * * * *") { // Every minute interval = 1 * time.Minute } else if strings.Contains(schedule, "*/") { // Likely a recurring job with specific interval interval = 1 * time.Hour // Default to hourly as a safe guess } else { continue } } // Generate future occurrences // Limit the number of runs we'll capture per day maxRunsPerDay := 20 // Generate future occurrences up to our limits currentTime := nextRun.Add(interval) for currentTime.Before(twoMonthsLater) { dateKey := fmt.Sprintf("%d-%s", job.ID, currentTime.Format("2006-01-02")) // Check if we already have too many runs for this day if len(eventsByJobAndDay[dateKey]) < maxRunsPerDay { eventsByJobAndDay[dateKey] = append(eventsByJobAndDay[dateKey], currentTime) } currentTime = currentTime.Add(interval) } } // Now convert the map to calendar events, consolidating runs on the same day for dateKey, runTimes := range eventsByJobAndDay { // Parse job ID from date key parts := strings.Split(dateKey, "-") jobIDStr := parts[0] jobID, _ := strconv.ParseUint(jobIDStr, 10, 32) // Get the job info job, exists := jobInfo[uint(jobID)] if !exists { continue } // Sort run times chronologically sort.Slice(runTimes, func(i, j int) bool { return runTimes[i].Before(runTimes[j]) }) // Use the first run time as the event time firstRunTime := runTimes[0] // Format run times for display in tooltip formattedTimes := make([]string, 0, len(runTimes)) for i, rt := range runTimes { // Limit to showing max 10 times in tooltip if i >= 10 { formattedTimes = append(formattedTimes, fmt.Sprintf("... and %d more", len(runTimes)-10)) break } formattedTimes = append(formattedTimes, rt.Format("15:04:05")) } // Set class based on job enabled status className := "" if job.Enabled { className = "bg-blue-200 border-blue-600 text-blue-800 dark:bg-blue-800 dark:border-blue-500 dark:text-blue-100" } else { className = "bg-gray-200 border-gray-400 text-gray-700 dark:bg-gray-700 dark:border-gray-500 dark:text-gray-300" } // Create a single event for this job on this day title := job.Name if len(runTimes) > 1 { title = fmt.Sprintf("%s (%d runs)", job.Name, len(runTimes)) } // Create event events = append(events, CalendarEvent{ ID: fmt.Sprintf("job-%d-%s", jobID, firstRunTime.Format("20060102")), Title: title, Start: firstRunTime.Format(time.RFC3339), AllDay: false, URL: fmt.Sprintf("/jobs/%d", jobID), ClassName: className, Description: fmt.Sprintf("Schedule: %s", job.Schedule), Enabled: job.Enabled, JobID: uint(jobID), JobName: job.Name, RunTimes: formattedTimes, RunCount: len(runTimes), Schedule: job.Schedule, }) } // Debug info fmt.Printf("Generated %d consolidated calendar events\n", len(events)) eventsJSON, err := json.Marshal(events) if err != nil { return "[]" // Return empty array if marshaling fails } return string(eventsJSON) } // JobCalendar displays scheduled jobs in a calendar view templ JobCalendar(ctx context.Context, data JobCalendarData) { @LayoutWithContext("Transfer Calendar", ctx) {

Transfer Calendar

New Job
Loading calendar...

About the Calendar View

This calendar displays your scheduled transfer jobs for the next 2 months. Click on any event to view or edit the job details.

Jobs with multiple runs on the same day are consolidated into a single event. Hover over any event to see all scheduled run times for that day.

Filter Options

All Jobs

Shows all scheduled occurrences in the selected timeframe.

Active Only

Shows only enabled jobs that will actually run.

Inactive Only

Shows disabled jobs that won't run unless re-enabled.

Next Occurrences Only

Shows only the next upcoming occurrence of each job.

Legend

Active Jobs
Inactive Jobs
} }