Skip to content

Commit d8471f5

Browse files
committed
init
0 parents  commit d8471f5

File tree

7 files changed

+200
-0
lines changed

7 files changed

+200
-0
lines changed

.gitignore

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
.calva/output-window/
2+
.idea
3+
*.iml
4+
.classpath
5+
.clj-kondo/.cache
6+
.cpcache
7+
.clerk/
8+
.eastwood
9+
.factorypath
10+
.hg/
11+
.hgignore
12+
.java-version
13+
.lein-*
14+
.lsp/.cache
15+
.lsp/sqlite.db
16+
.nrepl-history
17+
.nrepl-port
18+
.portal/vs-code.edn
19+
.project
20+
.DS_Store
21+
.rebel_readline_history
22+
.settings
23+
.socket-repl-port
24+
.sw*
25+
.vscode
26+
*.class
27+
*.jar
28+
*.swp
29+
*~
30+
/checkouts
31+
/classes
32+
/target
33+
.garden/

README.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# Calendula
2+
3+
A minimal application to book personal appointments for deployment on [application.garden](https://application.garden).
4+
5+
## Develop
6+
7+
Install the `garden` utility as described [here](https://docs.apps.garden/#installing-the-cli).
8+
9+
`garden run`
10+
11+
## Deploy
12+
13+
`garden deploy`
14+
15+
## Help
16+
17+
`garden help`
18+
19+
## License
20+
21+
Copyright © 2024 Nextjournal

deps.edn

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{:paths ["src" "resources"]
2+
:deps {ring/ring-core {:mvn/version "1.12.1"}
3+
io.github.nextjournal/impulse {:git/sha "83659db7d8037a86c9f3d9ec57f1080cf1b676fb"}}
4+
:aliases {:nextjournal/garden {:exec-fn calendula/start!}}}

garden.edn

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{:project "calendula"}

resources/.keep

Whitespace-only changes.

resources/calendula.svg

Lines changed: 3 additions & 0 deletions
Loading

src/calendula.clj

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
(ns calendula
2+
(:require [clojure.string :as str]
3+
[nextjournal.impulse :as impulse]
4+
[nextjournal.garden-id :as garden-id :refer [username displayname email]]
5+
[nextjournal.garden-email :as garden-email]
6+
[ring.util.codec :as codec])
7+
(:import (java.time LocalDate DayOfWeek)
8+
(java.time.temporal TemporalAdjusters)))
9+
10+
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
11+
;; configuration
12+
13+
(def availability {:monday [{:start 9 :end 10}
14+
{:start 12 :end 15}]
15+
:wednesday [{:start 8 :end 10}
16+
{:start 12 :end 14}]})
17+
18+
(def owner-email (or (System/getenv "GARDEN_OWNER_EMAIL") "my-email@example.com"))
19+
20+
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
21+
;; date stuff
22+
23+
(defn now [] (str (LocalDate/now)))
24+
25+
(defn before? [a b]
26+
(.isBefore (LocalDate/parse a) (LocalDate/parse b)))
27+
28+
(defn start-of-week [date]
29+
(str (.with (LocalDate/parse date) (TemporalAdjusters/previous DayOfWeek/MONDAY))))
30+
31+
(defn offset-week [date offset]
32+
(str (.plusWeeks (LocalDate/parse date) offset)))
33+
34+
(defn dates-of-week [start-date]
35+
(map (fn [offset] (str (.plusDays (LocalDate/parse start-date) offset))) (range 7)))
36+
37+
(defn date->keyword [day]
38+
(keyword (str/lower-case (str (.getDayOfWeek (LocalDate/parse day))))))
39+
40+
(defn free? [availability day hour]
41+
(and (some (fn [{:keys [start end]}] (<= start hour end)) (availability (date->keyword day)))
42+
(not (get-in @impulse/state [:appointments day hour]))))
43+
44+
(comment
45+
(before? "2024-09-16" "2024-09-17") ;=> true
46+
(before? "2024-09-17" "2024-09-16") ;=> false
47+
(dates-of-week (start-of-week (now)))
48+
(start-of-week (now))
49+
(offset-week (start-of-week (now)) 1)
50+
(dates-of-week (offset-week (start-of-week (now)) 1))
51+
(date->keyword "2024-01-01") ;=> :monday
52+
(free? {:monday [{:start 12 :end 15}]} "2024-01-01" 15))
53+
54+
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
55+
;; the app!
56+
57+
(defn render-day [req day]
58+
[:div {:id (str "date-" day)}
59+
[:strong (str (name (date->keyword day)))]
60+
[:span " (" day ")"]
61+
(for [hour (mapcat #(range (:start %) (:end %)) (availability (date->keyword day)))
62+
:let [status (cond (free? availability day hour) :free
63+
(= (get-in @impulse/state [:appointments day hour]) (email req)) :mine
64+
:else nil)]
65+
:when status] ; either free or booked by the current user
66+
[:div {:hx-post (str (if (= status :free)
67+
"/book-appointment?"
68+
"/unbook-appointment?")
69+
(codec/form-encode {:day day :hour hour}))
70+
:hx-target (str "#date-" day)}
71+
[:input {:type "checkbox"
72+
:checked (= status :mine)}]
73+
(str hour ":00")])])
74+
75+
(defn render-week [req start-of-week]
76+
(let [today (now)]
77+
[:div#week
78+
[:div.grid
79+
(for [date (dates-of-week start-of-week)
80+
:when (and (availability (date->keyword date))
81+
(not (before? date today)))]
82+
(render-day req date))]
83+
[:div {:role "group" :style "margin-top: 1em"}
84+
(if (before? start-of-week today)
85+
[:button.outline {:disabled true} "← prev week"]
86+
[:button.outline {:hx-post "/prev" :hx-target "#week"} "← prev week"])
87+
[:button.outline {:hx-post "/next" :hx-target "#week"} "next week →"]]]))
88+
89+
(defn index [req]
90+
(let [uname (username req)]
91+
(impulse/page
92+
[:nav
93+
[:ul
94+
[:li
95+
[:svg {:xmlns "http://www.w3.org/2000/svg" :viewbox "0 0 60 75" :x "0px" :y "0px" :width "2em" :style "margin-right: 0.5em"}
96+
[:path {:d "M49.279,22.01c4.121-4.266,5.006-10.147,1.937-13.228S42.256,6.6,37.99,10.721C37.88,4.785,34.343,0,30,0s-7.88,4.785-7.99,10.721C17.745,6.6,11.864,5.715,8.782,8.784,5.715,11.863,6.6,17.745,10.721,22.01,4.785,22.12,0,25.657,0,30s4.785,7.88,10.721,7.99C6.6,42.255,5.715,48.137,8.784,51.218a6.649,6.649,0,0,0,4.8,1.832,12.563,12.563,0,0,0,8.423-3.774C22.118,55.213,25.657,60,30,60s7.882-4.787,7.99-10.724a12.561,12.561,0,0,0,8.422,3.774,6.663,6.663,0,0,0,4.806-1.834c3.067-3.079,2.182-8.961-1.939-13.226C55.215,37.88,60,34.343,60,30S55.215,22.12,49.279,22.01ZM39.2,12.326c3.5-3.511,8.259-4.467,10.6-2.13s1.383,7.1-2.129,10.607c-.46.46-.921.888-1.38,1.306A21.6,21.6,0,0,0,36.228,24.99a8.155,8.155,0,0,0-1.218-1.218,21.6,21.6,0,0,0,2.881-10.065C38.309,13.248,38.737,12.787,39.2,12.326ZM36,30a6,6,0,1,1-6-6A6.006,6.006,0,0,1,36,30ZM30,2c3.309,0,6,4.037,6,9,0,4.9-1,9.186-2.7,11.723a7.911,7.911,0,0,0-6.606,0C25,20.185,24,15.9,24,11,24,6.037,26.691,2,30,2ZM10.2,10.2c2.347-2.336,7.1-1.383,10.607,2.129.46.46.888.921,1.306,1.38A21.6,21.6,0,0,0,24.99,23.772a8.155,8.155,0,0,0-1.218,1.218,21.6,21.6,0,0,0-10.065-2.881c-.459-.418-.92-.846-1.381-1.307C8.815,17.3,7.86,12.542,10.2,10.2ZM2,30c0-3.309,4.037-6,9-6,4.9,0,9.186,1,11.723,2.7a7.911,7.911,0,0,0,0,6.606C20.185,35,15.9,36,11,36,6.037,36,2,33.309,2,30ZM20.8,47.674c-3.5,3.511-8.259,4.467-10.6,2.13S8.815,42.7,12.327,39.2c.46-.46.921-.888,1.38-1.306A21.6,21.6,0,0,0,23.772,35.01a8.155,8.155,0,0,0,1.218,1.218,21.6,21.6,0,0,0-2.881,10.065C21.691,46.752,21.263,47.213,20.8,47.674ZM30,58c-3.309,0-6-4.037-6-9,0-4.9,1-9.185,2.7-11.722a7.911,7.911,0,0,0,6.606,0C35,39.814,36,44.1,36,49,36,53.963,33.309,58,30,58Zm19.8-8.2C47.458,52.137,42.7,51.185,39.2,47.673c-.46-.46-.888-.921-1.306-1.38A21.6,21.6,0,0,0,35.01,36.228a8.155,8.155,0,0,0,1.218-1.218,21.6,21.6,0,0,0,10.065,2.881c.459.418.92.846,1.381,1.307C51.185,42.7,52.14,47.458,49.8,49.8ZM49,36c-4.9,0-9.185-1-11.722-2.7a7.911,7.911,0,0,0,0-6.606C39.814,25,44.1,24,49,24c4.963,0,9,2.691,9,6S53.963,36,49,36Z"}]]
97+
[:strong "Calendula"]]]
98+
[:ul
99+
[:li (if uname
100+
[:span (str uname " ") [:a {:href garden-id/logout-uri} "(logout)"]]
101+
[:a {:href garden-id/login-uri} "login"])]]]
102+
(when uname
103+
(render-week req (get-in @impulse/state [:user-state uname :week] (start-of-week (now))))))))
104+
105+
(defn book-appointment [{:as req :keys [params]}]
106+
(let [{:strs [day hour]} params]
107+
;; XXX race condition if multiple users are booking appointments at the same time
108+
(swap! impulse/state assoc-in [:appointments (str day) (parse-long hour)] (email req))
109+
(garden-email/send-email! {:to {:email owner-email}
110+
:subject (format "%s (%s) booked appointment at %s %s:00" (displayname req) (email req) day hour)})
111+
(garden-email/send-email! {:to {:email (email req)}
112+
:subject (format "You booked an appointment at %s %s:00" day hour)})
113+
(render-day req day)))
114+
115+
(defn unbook-appointment [{:as req :keys [params]}]
116+
(let [{:strs [day hour]} params]
117+
(swap! impulse/state update-in [:appointments (str day)] dissoc (parse-long hour))
118+
(garden-email/send-email! {:to {:email owner-email}
119+
:subject (format "%s (%s) has canceled their appointment at %s %s:00" (displayname req) (email req) day hour)})
120+
(garden-email/send-email! {:to {:email (email req)}
121+
:subject (format "You cancelled an appointment at %s %s:00" day hour)})
122+
(render-day req day)))
123+
124+
(defn update-week [offset req]
125+
(render-week req (impulse/swap-in! impulse/state
126+
[:user-state (username req) :week]
127+
(fnil offset-week (start-of-week (now)))
128+
offset)))
129+
130+
(def routes
131+
[["/" #'index]
132+
["/book-appointment" {:post #'book-appointment}]
133+
["/unbook-appointment" {:post #'unbook-appointment}]
134+
["/next" {:post (partial update-week 1)}]
135+
["/prev" {:post (partial update-week -1)}]])
136+
137+
(defn start! [opts]
138+
(impulse/start! #'routes opts))

0 commit comments

Comments
 (0)