feat: add calendar widget components

Add date picker and event calendar components inspired by shadcn/radix
Calendar and org-mode-agenda-cli.

Components:
- ui.calendar: Month grid date picker with navigation, today/selected
  highlighting, outside-month dimming. Pure date math utilities
  (days-in-month, day-of-week, calendar-days, etc.)
- ui.calendar-events: Event-aware grid with colored pills, horizontal
  day ticker strip with dot indicators, and agenda list view with
  grouped events by day

CSS: Token-based styling with dark mode support for event color
variants (accent/danger/success/warning). Responsive breakpoints.

All three targets supported (squint/cljs/clj). Dev pages show calendar
on its own page with interactive demos (date selection, month nav,
event grid, ticker, agenda list).

Tests: 27 new assertions covering date math, class generation,
component structure, event filtering/sorting.
This commit is contained in:
Florian Schroedl
2026-03-29 09:42:29 +02:00
parent d4f21f80a5
commit 25f868fb69
10 changed files with 1990 additions and 6 deletions

View File

@@ -21,7 +21,9 @@
[ui.form :as form]
[ui.sidebar :as sidebar]
[ui.icon :as icon]
[ui.separator :as separator]))
[ui.separator :as separator]
[ui.calendar :as calendar]
[ui.calendar-events :as cal-events]))
;; ── Query Params ────────────────────────────────────────────────────
@@ -314,6 +316,51 @@
(form/form-field {:label "Email" :error "Please enter a valid email address."}
(form/form-input {:type :email :error true :value "invalid-email"}))]))
(def sample-calendar-events
[{:title "Team standup" :date "2026-03-29" :time-start "09:00" :time-end "09:30" :color :accent}
{:title "Lunch with Alex" :date "2026-03-29" :time-start "12:00" :time-end "13:00" :color :success}
{:title "Deploy v2.0" :date "2026-03-29" :time-start "15:00" :color :danger}
{:title "Design review" :date "2026-03-30" :time-start "10:00" :color :warning}
{:title "All-day planning" :date "2026-03-31" :color nil :done? true}
{:title "Sprint retro" :date "2026-04-01" :time-start "14:00" :time-end "15:00" :color :accent}
{:title "1:1 with manager" :date "2026-04-02" :time-start "11:00" :color :success}
{:title "Release party" :date "2026-04-03" :time-start "17:00" :color :danger}])
(defn calendar-demo []
(section "Calendar"
[:h5 "Date Picker"]
[:div {:style "display: flex; gap: 1.5rem; flex-wrap: wrap;"}
(calendar/calendar {:year 2026 :month 3 :today-str "2026-03-29"
:selected-date "2026-03-29"})
(calendar/calendar {:year 2026 :month 4 :today-str "2026-03-29"})]
[:h5 "Event Grid"]
(cal-events/calendar-event-grid {:year 2026 :month 3 :today-str "2026-03-29"
:selected-date "2026-03-29"
:events sample-calendar-events})
[:h5 "Day Ticker"]
(cal-events/ticker-strip {:days [{:date "2026-03-27" :day-num 27 :day-label "Fr"}
{:date "2026-03-28" :day-num 28 :day-label "Sa"}
{:date "2026-03-29" :day-num 29 :day-label "Su"}
{:date "2026-03-30" :day-num 30 :day-label "Mo"}
{:date "2026-03-31" :day-num 31 :day-label "Tu"}
{:date "2026-04-01" :day-num 1 :day-label "We"}
{:date "2026-04-02" :day-num 2 :day-label "Th"}
{:date "2026-04-03" :day-num 3 :day-label "Fr"}]
:today-str "2026-03-29"
:selected "2026-03-29"
:events sample-calendar-events})
[:h5 "Agenda List"]
(cal-events/agenda-list {:days [{:date "2026-03-29" :label "Today"}
{:date "2026-03-30" :label "Tomorrow"}
{:date "2026-03-31" :label "Tue"}
{:date "2026-04-01" :label "Wed"}
{:date "2026-04-02" :label "Thu"}
{:date "2026-04-03" :label "Fri"}]
:events sample-calendar-events})))
;; ── Pages ───────────────────────────────────────────────────────────
(defn page-header [title subtitle]
@@ -362,6 +409,11 @@
["Dev & Technical"
[:code :terminal :database :globe :shield :zap :book-open :map-pin]]])
(defn calendar-page []
[:div
(page-header "Calendar" "Date picker, event grid, ticker strip, and agenda list.")
(calendar-demo)])
(defn icons-page []
[:div
(page-header "Icons" (str (count icon/icon-names) " icons based on Lucide. All render as inline SVG with stroke=\"currentColor\"."))
@@ -454,14 +506,16 @@
(def nav-items
[{:id :components :label "Components" :icon-name :package :href "/"}
{:id :calendar :label "Calendar" :icon-name :calendar :href "/calendar"}
{:id :icons :label "Icons" :icon-name :image :href "/icons"}
{:id :sidebar :label "Sidebar" :icon-name :layout-dashboard :href "/sidebar"}])
(defn resolve-page [uri]
(case uri
"/" :components
"/icons" :icons
"/sidebar" :sidebar
"/" :components
"/calendar" :calendar
"/icons" :icons
"/sidebar" :sidebar
nil))
;; ── App Shell ───────────────────────────────────────────────────────
@@ -549,6 +603,7 @@
(sidebar/sidebar-mobile-toggle {})]
(case active-page
:components (components-page)
:calendar (calendar-page)
:icons (icons-page)
:sidebar (sidebar-page)
[:div (page-header "Not Found" "This page doesn't exist.")])]))]]))))

View File

@@ -18,7 +18,9 @@
[ui.form :as form]
[ui.sidebar :as sidebar]
[ui.icon :as icon]
[ui.separator :as separator]))
[ui.separator :as separator]
[ui.calendar :as calendar]
[ui.calendar-events :as cal-events]))
;; ── State ───────────────────────────────────────────────────────────
@@ -276,6 +278,76 @@
(form/form-field {:label "Email" :error "Please enter a valid email address."}
(form/form-input {:type :email :error true :value "invalid-email"}))]))
(defonce !cal-state (atom {:year 2026 :month 3 :selected-date nil}))
(def sample-calendar-events
[{:title "Team standup" :date "2026-03-29" :time-start "09:00" :time-end "09:30" :color :accent}
{:title "Lunch with Alex" :date "2026-03-29" :time-start "12:00" :time-end "13:00" :color :success}
{:title "Deploy v2.0" :date "2026-03-29" :time-start "15:00" :color :danger}
{:title "Design review" :date "2026-03-30" :time-start "10:00" :color :warning}
{:title "All-day planning" :date "2026-03-31" :color nil :done? true}
{:title "Sprint retro" :date "2026-04-01" :time-start "14:00" :time-end "15:00" :color :accent}
{:title "1:1 with manager" :date "2026-04-02" :time-start "11:00" :color :success}
{:title "Release party" :date "2026-04-03" :time-start "17:00" :color :danger}])
(defn calendar-demo []
(let [{:keys [year month selected-date]} @!cal-state
today-str "2026-03-29"]
(section "Calendar"
[:h5 "Date Picker (interactive)"]
[:div {:style {:display "flex" :gap "1.5rem" :flex-wrap "wrap"}}
(calendar/calendar {:year year :month month
:today-str today-str
:selected-date selected-date
:on-select (fn [d] (swap! !cal-state assoc :selected-date d))
:on-prev-month (fn [_]
(let [[ny nm] (calendar/prev-month year month)]
(swap! !cal-state assoc :year ny :month nm)))
:on-next-month (fn [_]
(let [[ny nm] (calendar/next-month year month)]
(swap! !cal-state assoc :year ny :month nm)))})]
(when selected-date
[:p {:style {:color "var(--fg-1)" :font-size "var(--font-sm)"}}
(str "Selected: " selected-date)])
[:h5 "Event Grid"]
(cal-events/calendar-event-grid {:year year :month month
:today-str today-str
:selected-date selected-date
:events sample-calendar-events
:on-select (fn [d] (swap! !cal-state assoc :selected-date d))
:on-prev-month (fn [_]
(let [[ny nm] (calendar/prev-month year month)]
(swap! !cal-state assoc :year ny :month nm)))
:on-next-month (fn [_]
(let [[ny nm] (calendar/next-month year month)]
(swap! !cal-state assoc :year ny :month nm)))
:on-event-click (fn [evt] (js/console.log "Event clicked:" (:title evt)))})
[:h5 "Day Ticker"]
(cal-events/ticker-strip {:days [{:date "2026-03-27" :day-num 27 :day-label "Fr"}
{:date "2026-03-28" :day-num 28 :day-label "Sa"}
{:date "2026-03-29" :day-num 29 :day-label "Su"}
{:date "2026-03-30" :day-num 30 :day-label "Mo"}
{:date "2026-03-31" :day-num 31 :day-label "Tu"}
{:date "2026-04-01" :day-num 1 :day-label "We"}
{:date "2026-04-02" :day-num 2 :day-label "Th"}
{:date "2026-04-03" :day-num 3 :day-label "Fr"}]
:today-str today-str
:selected (or selected-date today-str)
:events sample-calendar-events
:on-select (fn [d] (swap! !cal-state assoc :selected-date d))})
[:h5 "Agenda List"]
(cal-events/agenda-list {:days [{:date "2026-03-29" :label "Today"}
{:date "2026-03-30" :label "Tomorrow"}
{:date "2026-03-31" :label "Tue"}
{:date "2026-04-01" :label "Wed"}
{:date "2026-04-02" :label "Thu"}
{:date "2026-04-03" :label "Fri"}]
:events sample-calendar-events
:on-event-click (fn [evt] (js/console.log "Agenda event:" (:title evt)))}))))
;; ── Pages ───────────────────────────────────────────────────────────
(defn components-page []
@@ -318,6 +390,11 @@
["Dev & Technical"
[:code :terminal :database :globe :shield :zap :book-open :map-pin]]])
(defn calendar-page []
[:div
(page-header "Calendar" "Date picker, event grid, ticker strip, and agenda list.")
(calendar-demo)])
(defn icons-page []
[:div
(page-header "Icons" (str (count icon/icon-names) " icons based on Lucide. All render as inline SVG with stroke=\"currentColor\"."))
@@ -409,6 +486,7 @@
(def nav-items
[{:id :components :label "Components" :icon-name :package}
{:id :calendar :label "Calendar" :icon-name :calendar}
{:id :icons :label "Icons" :icon-name :image}
{:id :sidebar :label "Sidebar" :icon-name :layout-dashboard}])
@@ -504,6 +582,7 @@
(sidebar/sidebar-mobile-toggle {:on-click toggle-sidebar!})]
(case active-page
:components (components-page)
:calendar (calendar-page)
:icons (icons-page)
:sidebar (sidebar-page)
(components-page))]))))
@@ -516,6 +595,7 @@
(defn ^:export init! []
(d/set-dispatch! (fn [_ _]))
(add-watch !page :render (fn [_ _ _ _] (render!)))
(add-watch !cal-state :render (fn [_ _ _ _] (render!)))
(render!))
(defn ^:export reload! []

View File

@@ -17,7 +17,9 @@
[ui.form :as form]
[ui.sidebar :as sidebar]
[ui.icon :as icon]
[ui.separator :as separator]))
[ui.separator :as separator]
[ui.calendar :as calendar]
[ui.calendar-events :as cal-events]))
;; ── State ───────────────────────────────────────────────────────────
@@ -294,6 +296,86 @@
(form/form-field {:label "Email" :error "Please enter a valid email address."}
(form/form-input {:type "email" :error true :value "invalid-email"}))]))
(def !cal-state (atom {:year 2026 :month 3 :selected-date nil}))
(def sample-calendar-events
[{:title "Team standup" :date "2026-03-29" :time-start "09:00" :time-end "09:30" :color "accent"}
{:title "Lunch with Alex" :date "2026-03-29" :time-start "12:00" :time-end "13:00" :color "success"}
{:title "Deploy v2.0" :date "2026-03-29" :time-start "15:00" :color "danger"}
{:title "Design review" :date "2026-03-30" :time-start "10:00" :color "warning"}
{:title "All-day planning" :date "2026-03-31" :color nil :done? true}
{:title "Sprint retro" :date "2026-04-01" :time-start "14:00" :time-end "15:00" :color "accent"}
{:title "1:1 with manager" :date "2026-04-02" :time-start "11:00" :color "success"}
{:title "Release party" :date "2026-04-03" :time-start "17:00" :color "danger"}])
(defn calendar-demo []
(let [{:keys [year month selected-date]} @!cal-state
today-str "2026-03-29"]
(section "Calendar"
[:h5 "Date Picker (interactive)"]
[:div {:style {"display" "flex" "gap" "1.5rem" "flex-wrap" "wrap"}}
(calendar/calendar {:year year :month month
:today-str today-str
:selected-date selected-date
:on-select (fn [d]
(swap! !cal-state assoc :selected-date d)
(render!))
:on-prev-month (fn [_]
(let [[ny nm] (calendar/prev-month year month)]
(swap! !cal-state assoc :year ny :month nm)
(render!)))
:on-next-month (fn [_]
(let [[ny nm] (calendar/next-month year month)]
(swap! !cal-state assoc :year ny :month nm)
(render!)))})]
(when selected-date
[:p {:style {"color" "var(--fg-1)" "font-size" "var(--font-sm)"}}
(str "Selected: " selected-date)])
[:h5 "Event Grid"]
(cal-events/calendar-event-grid {:year year :month month
:today-str today-str
:selected-date selected-date
:events sample-calendar-events
:on-select (fn [d]
(swap! !cal-state assoc :selected-date d)
(render!))
:on-prev-month (fn [_]
(let [[ny nm] (calendar/prev-month year month)]
(swap! !cal-state assoc :year ny :month nm)
(render!)))
:on-next-month (fn [_]
(let [[ny nm] (calendar/next-month year month)]
(swap! !cal-state assoc :year ny :month nm)
(render!)))
:on-event-click (fn [evt] (js/console.log "Event clicked:" (:title evt)))})
[:h5 "Day Ticker"]
(cal-events/ticker-strip {:days [{:date "2026-03-27" :day-num 27 :day-label "Fr"}
{:date "2026-03-28" :day-num 28 :day-label "Sa"}
{:date "2026-03-29" :day-num 29 :day-label "Su"}
{:date "2026-03-30" :day-num 30 :day-label "Mo"}
{:date "2026-03-31" :day-num 31 :day-label "Tu"}
{:date "2026-04-01" :day-num 1 :day-label "We"}
{:date "2026-04-02" :day-num 2 :day-label "Th"}
{:date "2026-04-03" :day-num 3 :day-label "Fr"}]
:today-str today-str
:selected (or selected-date today-str)
:events sample-calendar-events
:on-select (fn [d]
(swap! !cal-state assoc :selected-date d)
(render!))})
[:h5 "Agenda List"]
(cal-events/agenda-list {:days [{:date "2026-03-29" :label "Today"}
{:date "2026-03-30" :label "Tomorrow"}
{:date "2026-03-31" :label "Tue"}
{:date "2026-04-01" :label "Wed"}
{:date "2026-04-02" :label "Thu"}
{:date "2026-04-03" :label "Fri"}]
:events sample-calendar-events
:on-event-click (fn [evt] (js/console.log "Agenda event:" (:title evt)))}))))
;; ── Pages ───────────────────────────────────────────────────────────
(defn components-page []
@@ -351,6 +433,11 @@
(into [:div {:style {"display" "grid" "grid-template-columns" "repeat(auto-fill, minmax(5rem, 1fr))" "gap" "var(--size-4)"}}]
(map icon-card icons)))))
(defn calendar-page []
[:div
(page-header "Calendar" "Date picker, event grid, ticker strip, and agenda list.")
(calendar-demo)])
(defn icons-page []
[:div
(page-header "Icons" (str (count icon/icon-names) " icons based on Lucide. All render as inline SVG with stroke=\"currentColor\"."))
@@ -435,6 +522,7 @@
(def nav-items
[{:id "components" :label "Components" :icon-name "package"}
{:id "calendar" :label "Calendar" :icon-name "calendar"}
{:id "icons" :label "Icons" :icon-name "image"}
{:id "sidebar" :label "Sidebar" :icon-name "layout-dashboard"}])
@@ -519,6 +607,7 @@
(sidebar/sidebar-mobile-toggle {:on-click toggle-sidebar!})]
(case active-page
"components" (components-page)
"calendar" (calendar-page)
"icons" (icons-page)
"sidebar" (sidebar-page)
(components-page))]))))