diff --git a/components/calendar.templ b/components/calendar.templ
new file mode 100644
index 0000000..408384d
--- /dev/null
+++ b/components/calendar.templ
@@ -0,0 +1,640 @@
+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) {
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{ generateCalendarEvents(data.Jobs) }
+
+
+
+
+
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
+
+
+
+
+
+
+
+
+ }
+}
\ No newline at end of file
diff --git a/components/layout.templ b/components/layout.templ
index ed9f2bf..5f86cb5 100644
--- a/components/layout.templ
+++ b/components/layout.templ
@@ -169,6 +169,10 @@ templ LayoutWithContext(title string, ctx context.Context) {
Scheduled Jobs
+
+
+ Transfer Calendar
+
Transfer History
@@ -273,6 +277,10 @@ templ LayoutWithContext(title string, ctx context.Context) {
Scheduled Jobs
+
+
+ Transfer Calendar
+
Transfer History
diff --git a/internal/web/handlers/job_handlers.go b/internal/web/handlers/job_handlers.go
index 718d306..2c0dca4 100644
--- a/internal/web/handlers/job_handlers.go
+++ b/internal/web/handlers/job_handlers.go
@@ -42,6 +42,23 @@ func (h *Handlers) HandleJobs(c *gin.Context) {
components.Jobs(c, data).Render(c, c.Writer)
}
+// HandleCalendarView handles the GET /calendar route
+func (h *Handlers) HandleCalendarView(c *gin.Context) {
+ userID := c.GetUint("userID")
+
+ // Get all jobs with Next Run time for this user
+ var jobs []db.Job
+ h.DB.Where("created_by = ?", userID).Preload("Config").Find(&jobs)
+
+ // Prepare data for the calendar view
+ data := components.JobCalendarData{
+ Jobs: jobs,
+ }
+
+ // Render the calendar view
+ components.JobCalendar(c, data).Render(c, c.Writer)
+}
+
// HandleJobRunDetails handles the GET /job/:id route
func (h *Handlers) HandleJobRunDetails(c *gin.Context) {
userID := c.GetUint("userID")
diff --git a/internal/web/handlers/routes.go b/internal/web/handlers/routes.go
index f98006a..0bb11f2 100644
--- a/internal/web/handlers/routes.go
+++ b/internal/web/handlers/routes.go
@@ -84,6 +84,10 @@ func (h *Handlers) RegisterRoutes(router *gin.Engine) {
authorized.POST("/jobs/:id/run", h.HandleRunJob)
authorized.GET("/history", h.HandleHistory)
authorized.GET("/job-runs/:id", h.HandleJobRunDetails)
+
+ // Calendar view route
+ authorized.GET("/calendar", h.HandleCalendarView)
+
authorized.GET("/profile", h.HandleProfile)
authorized.POST("/profile/theme", h.HandleUpdateTheme)
authorized.POST("/logout", h.HandleLogout)
diff --git a/package-lock.json b/package-lock.json
index 373ebaf..30b5bbb 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -7,13 +7,22 @@
"": {
"name": "gomft",
"version": "1.0.0",
+ "hasInstallScript": true,
"dependencies": {
"@fortawesome/fontawesome-free": "^6.4.0",
+ "@fullcalendar/core": "^6.1.17",
+ "@fullcalendar/daygrid": "^6.1.17",
+ "@fullcalendar/interaction": "^6.1.17",
+ "@fullcalendar/list": "^6.1.17",
+ "@fullcalendar/timegrid": "^6.1.17",
+ "@popperjs/core": "^2.11.8",
"alpinejs": "^3.13.5",
"esbuild": "^0.20.1",
"flowbite": "^2.2.1",
+ "fullcalendar": "^5.11.3",
"htmx.org": "^1.9.10",
- "tailwindcss": "^3.4.1"
+ "tailwindcss": "^3.4.1",
+ "tippy.js": "^6.3.7"
}
},
"node_modules/@alloc/quick-lru": {
@@ -406,6 +415,54 @@
"node": ">=6"
}
},
+ "node_modules/@fullcalendar/core": {
+ "version": "6.1.17",
+ "resolved": "https://registry.npmjs.org/@fullcalendar/core/-/core-6.1.17.tgz",
+ "integrity": "sha512-0W7lnIrv18ruJ5zeWBeNZXO8qCWlzxDdp9COFEsZnyNjiEhUVnrW/dPbjRKYpL0edGG0/Lhs0ghp1z/5ekt8ZA==",
+ "license": "MIT",
+ "dependencies": {
+ "preact": "~10.12.1"
+ }
+ },
+ "node_modules/@fullcalendar/daygrid": {
+ "version": "6.1.17",
+ "resolved": "https://registry.npmjs.org/@fullcalendar/daygrid/-/daygrid-6.1.17.tgz",
+ "integrity": "sha512-K7m+pd7oVJ9fW4h7CLDdDGJbc9szJ1xDU1DZ2ag+7oOo1aCNLv44CehzkkknM6r8EYlOOhgaelxQpKAI4glj7A==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@fullcalendar/core": "~6.1.17"
+ }
+ },
+ "node_modules/@fullcalendar/interaction": {
+ "version": "6.1.17",
+ "resolved": "https://registry.npmjs.org/@fullcalendar/interaction/-/interaction-6.1.17.tgz",
+ "integrity": "sha512-AudvQvgmJP2FU89wpSulUUjeWv24SuyCx8FzH2WIPVaYg+vDGGYarI7K6PcM3TH7B/CyaBjm5Rqw9lXgnwt5YA==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@fullcalendar/core": "~6.1.17"
+ }
+ },
+ "node_modules/@fullcalendar/list": {
+ "version": "6.1.17",
+ "resolved": "https://registry.npmjs.org/@fullcalendar/list/-/list-6.1.17.tgz",
+ "integrity": "sha512-fkyK49F9IxwlGUBVhJGsFpd/LTi/vRVERLIAe1HmBaGkjwpxnynm8TMLb9mZip97wvDk3CmZWduMe6PxscAlow==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@fullcalendar/core": "~6.1.17"
+ }
+ },
+ "node_modules/@fullcalendar/timegrid": {
+ "version": "6.1.17",
+ "resolved": "https://registry.npmjs.org/@fullcalendar/timegrid/-/timegrid-6.1.17.tgz",
+ "integrity": "sha512-K4PlA3L3lclLOs3IX8cvddeiJI9ZVMD7RA9IqaWwbvac771971foc9tFze9YY+Pqesf6S+vhS2dWtEVlERaGlQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@fullcalendar/daygrid": "~6.1.17"
+ },
+ "peerDependencies": {
+ "@fullcalendar/core": "~6.1.17"
+ }
+ },
"node_modules/@isaacs/cliui": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
@@ -887,6 +944,12 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
+ "node_modules/fullcalendar": {
+ "version": "5.11.3",
+ "resolved": "https://registry.npmjs.org/fullcalendar/-/fullcalendar-5.11.3.tgz",
+ "integrity": "sha512-SgqiMEA+lWLyEd2jEwtIxdfx41j2CZr4KK00D2Gepj1MnGOjaEi13athnU6xvqMQXXjgJNj+vmlUP69QiuGncQ==",
+ "license": "MIT"
+ },
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
@@ -1402,6 +1465,16 @@
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
"license": "MIT"
},
+ "node_modules/preact": {
+ "version": "10.12.1",
+ "resolved": "https://registry.npmjs.org/preact/-/preact-10.12.1.tgz",
+ "integrity": "sha512-l8386ixSsBdbreOAkqtrwqHwdvR35ID8c3rKPa8lCWuO86dBi32QWHV4vfsZK1utLLFMvw+Z5Ad4XLkZzchscg==",
+ "license": "MIT",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/preact"
+ }
+ },
"node_modules/queue-microtask": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
@@ -1726,6 +1799,15 @@
"node": ">=0.8"
}
},
+ "node_modules/tippy.js": {
+ "version": "6.3.7",
+ "resolved": "https://registry.npmjs.org/tippy.js/-/tippy.js-6.3.7.tgz",
+ "integrity": "sha512-E1d3oP2emgJ9dRQZdf3Kkn0qJgI6ZLpyS5z6ZkY1DF3kaQaBsGZsndEpHwx+eC+tYM41HaSNvNtLx8tU57FzTQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@popperjs/core": "^2.9.0"
+ }
+ },
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
diff --git a/package.json b/package.json
index d07a96f..e56c1ac 100644
--- a/package.json
+++ b/package.json
@@ -10,10 +10,18 @@
},
"dependencies": {
"@fortawesome/fontawesome-free": "^6.4.0",
+ "@fullcalendar/core": "^6.1.17",
+ "@fullcalendar/daygrid": "^6.1.17",
+ "@fullcalendar/interaction": "^6.1.17",
+ "@fullcalendar/list": "^6.1.17",
+ "@fullcalendar/timegrid": "^6.1.17",
+ "@popperjs/core": "^2.11.8",
"alpinejs": "^3.13.5",
"esbuild": "^0.20.1",
"flowbite": "^2.2.1",
+ "fullcalendar": "^5.11.3",
"htmx.org": "^1.9.10",
- "tailwindcss": "^3.4.1"
+ "tailwindcss": "^3.4.1",
+ "tippy.js": "^6.3.7"
}
}
diff --git a/static/css/app.css b/static/css/app.css
index 6bf16b7..89efe7d 100644
--- a/static/css/app.css
+++ b/static/css/app.css
@@ -2,6 +2,9 @@
@tailwind components;
@tailwind utilities;
+/* Import vendor CSS */
+@import './vendor/fullcalendar.css';
+
/* Custom styles */
html, body {
margin: 0;
diff --git a/static/css/vendor/fullcalendar.css b/static/css/vendor/fullcalendar.css
new file mode 100644
index 0000000..befd45c
--- /dev/null
+++ b/static/css/vendor/fullcalendar.css
@@ -0,0 +1,255 @@
+/* FullCalendar Basic Styles */
+
+.fc {
+ display: flex;
+ flex-direction: column;
+ font-size: 1em;
+ max-width: 100%;
+}
+
+.fc-view-harness {
+ flex-grow: 1;
+ position: relative;
+}
+
+.fc-scrollgrid {
+ border-collapse: collapse;
+ width: 100%;
+}
+
+.fc-scrollgrid, .fc-scrollgrid table {
+ table-layout: fixed;
+}
+
+.fc-scrollgrid, .fc-scrollgrid table {
+ border-style: solid;
+ border-color: #ddd;
+ border-width: 1px;
+}
+
+.fc-theme-standard td, .fc-theme-standard th {
+ border: 1px solid #ddd;
+}
+
+.fc-header-toolbar {
+ padding: 1em;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+}
+
+.fc-toolbar-title {
+ font-size: 1.5em;
+ font-weight: bold;
+}
+
+.fc-button-group {
+ display: inline-flex;
+}
+
+.fc-button {
+ background-color: #f3f4f6;
+ border: 1px solid #d1d5db;
+ color: #374151;
+ padding: 0.5em 0.75em;
+ cursor: pointer;
+ font-size: 0.9em;
+ margin: 0;
+ border-radius: 0.25em;
+}
+
+.fc-button:first-child {
+ border-top-right-radius: 0;
+ border-bottom-right-radius: 0;
+}
+
+.fc-button:not(:first-child):not(:last-child) {
+ border-radius: 0;
+ border-left: none;
+}
+
+.fc-button:last-child {
+ border-top-left-radius: 0;
+ border-bottom-left-radius: 0;
+ border-left: none;
+}
+
+.fc-button:hover {
+ background-color: #e5e7eb;
+ z-index: 1;
+}
+
+.fc-button-primary {
+ background-color: #3b82f6;
+ border-color: #2563eb;
+ color: #ffffff;
+}
+
+.fc-button-primary:hover {
+ background-color: #2563eb;
+}
+
+.fc-button-active {
+ background-color: #1d4ed8;
+ border-color: #1e40af;
+ color: #ffffff;
+ z-index: 2;
+}
+
+.fc-col-header-cell {
+ padding: 0.5em;
+ background-color: #f9fafb;
+ font-weight: bold;
+}
+
+.fc-col-header-cell-cushion {
+ display: block;
+ padding: 0.25em 0;
+ text-decoration: none;
+ color: #374151;
+}
+
+.fc-scrollgrid-sync-inner {
+ text-align: center;
+}
+
+.fc-daygrid-day-top {
+ padding: 0.5em;
+ text-align: right;
+}
+
+.fc-daygrid-day-number {
+ font-size: 0.9em;
+ color: #374151;
+ text-decoration: none;
+ font-weight: 500;
+}
+
+.fc-daygrid-day-events {
+ min-height: 2em;
+ position: relative;
+ padding: 0 0.5em;
+}
+
+.fc-daygrid-event {
+ margin-bottom: 1px;
+ font-size: 0.85em;
+ cursor: pointer;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.fc-h-event {
+ display: block;
+ border: 1px solid #3b82f6;
+ background-color: #3b82f6;
+ color: #fff;
+ margin-top: 1px;
+ margin-bottom: 1px;
+ padding: 2px 4px;
+ border-radius: 3px;
+}
+
+.fc-h-event .fc-event-main {
+ color: #fff;
+}
+
+.fc-day-today {
+ background-color: rgba(59, 130, 246, 0.1);
+}
+
+.fc-theme-standard .fc-list {
+ border: 1px solid #ddd;
+}
+
+.fc-list-day {
+ background-color: #f9fafb;
+}
+
+.fc-list-day-cushion {
+ padding: 0.75em 1em;
+}
+
+.fc-list-event {
+ cursor: pointer;
+}
+
+.fc-list-event:hover td {
+ background-color: #f3f4f6;
+}
+
+.fc-list-event-time {
+ white-space: nowrap;
+ width: 1px;
+ text-align: right;
+}
+
+.fc-list-event-title {
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+/* Dark mode */
+.dark .fc {
+ color: #e5e7eb;
+}
+
+.dark .fc-scrollgrid, .dark .fc-scrollgrid table {
+ border-color: #4b5563;
+}
+
+.dark .fc-theme-standard td, .dark .fc-theme-standard th {
+ border-color: #4b5563;
+}
+
+.dark .fc-button {
+ background-color: #374151;
+ border-color: #4b5563;
+ color: #e5e7eb;
+}
+
+.dark .fc-button:hover {
+ background-color: #4b5563;
+}
+
+.dark .fc-button-primary {
+ background-color: #3b82f6;
+ border-color: #2563eb;
+ color: #ffffff;
+}
+
+.dark .fc-button-primary:hover {
+ background-color: #2563eb;
+}
+
+.dark .fc-button-active {
+ background-color: #1d4ed8;
+ border-color: #1e40af;
+ color: #ffffff;
+}
+
+.dark .fc-col-header-cell {
+ background-color: #1f2937;
+}
+
+.dark .fc-col-header-cell-cushion {
+ color: #e5e7eb;
+}
+
+.dark .fc-day-today {
+ background-color: rgba(59, 130, 246, 0.15) !important;
+}
+
+.dark .fc-daygrid-day-number {
+ color: #e5e7eb;
+}
+
+.dark .fc-list-day {
+ background-color: #1f2937;
+}
+
+.dark .fc-list-event:hover td {
+ background-color: #374151;
+}
\ No newline at end of file
diff --git a/static/js/vendor.js b/static/js/vendor.js
index f3fc856..2909070 100644
--- a/static/js/vendor.js
+++ b/static/js/vendor.js
@@ -9,6 +9,32 @@ window.Alpine = Alpine;
import 'flowbite';
import 'flowbite/dist/flowbite.css';
+// Import FullCalendar
+import { Calendar } from '@fullcalendar/core';
+import dayGridPlugin from '@fullcalendar/daygrid';
+import timeGridPlugin from '@fullcalendar/timegrid';
+import listPlugin from '@fullcalendar/list';
+import interactionPlugin from '@fullcalendar/interaction';
+
+// Make FullCalendar available globally
+window.FullCalendar = {
+ Calendar,
+ dayGridPlugin,
+ timeGridPlugin,
+ listPlugin,
+ interactionPlugin
+};
+
+// Import Popper.js
+import * as Popper from '@popperjs/core';
+window.Popper = Popper;
+
+// Import Tippy.js
+import tippy from 'tippy.js';
+import 'tippy.js/dist/tippy.css';
+import 'tippy.js/themes/light.css';
+window.tippy = tippy;
+
// Initialize Flowbite components
document.addEventListener('DOMContentLoaded', () => {
// Initialize Alpine.js