about summary refs log tree commit diff
path: root/users/grfn/bbbg/src
diff options
context:
space:
mode:
authorGriffin Smith <grfn@gws.fyi>2021-12-14T02·28-0500
committerGriffin Smith <grfn@gws.fyi>2021-12-14T02·45-0500
commitc3cb7b0df82479016c252ef45a302f566bd569f6 (patch)
tree2f60fac21680a379950c7d34c2e12304e10cc115 /users/grfn/bbbg/src
parent479e9ea279a157d81964a9b8cc97423b484921e6 (diff)
feat(grfn/bbbg): Init r/3233
This will eventually become a signup sheet + no-show tracker for my
local board game meetup group

Change-Id: Id8d1d80d95d1e2fda5041275cff2fecfd6fa43f1
Diffstat (limited to 'users/grfn/bbbg/src')
-rw-r--r--users/grfn/bbbg/src/bbbg/attendee.clj4
-rw-r--r--users/grfn/bbbg/src/bbbg/core.clj58
-rw-r--r--users/grfn/bbbg/src/bbbg/db.clj357
-rw-r--r--users/grfn/bbbg/src/bbbg/db/attendee.clj29
-rw-r--r--users/grfn/bbbg/src/bbbg/db/event.clj50
-rw-r--r--users/grfn/bbbg/src/bbbg/event.clj4
-rw-r--r--users/grfn/bbbg/src/bbbg/event_attendee.clj4
-rw-r--r--users/grfn/bbbg/src/bbbg/handlers/attendees.clj40
-rw-r--r--users/grfn/bbbg/src/bbbg/handlers/core.clj34
-rw-r--r--users/grfn/bbbg/src/bbbg/handlers/events.clj44
-rw-r--r--users/grfn/bbbg/src/bbbg/handlers/home.clj17
-rw-r--r--users/grfn/bbbg/src/bbbg/handlers/signup_form.clj57
-rw-r--r--users/grfn/bbbg/src/bbbg/styles.clj9
-rw-r--r--users/grfn/bbbg/src/bbbg/util/core.clj117
-rw-r--r--users/grfn/bbbg/src/bbbg/web.clj77
15 files changed, 901 insertions, 0 deletions
diff --git a/users/grfn/bbbg/src/bbbg/attendee.clj b/users/grfn/bbbg/src/bbbg/attendee.clj
new file mode 100644
index 000000000000..fabedb0a910d
--- /dev/null
+++ b/users/grfn/bbbg/src/bbbg/attendee.clj
@@ -0,0 +1,4 @@
+(ns bbbg.attendee
+  (:require [clojure.spec.alpha :as s]))
+
+(s/def ::id uuid?)
diff --git a/users/grfn/bbbg/src/bbbg/core.clj b/users/grfn/bbbg/src/bbbg/core.clj
new file mode 100644
index 000000000000..70c7da50d502
--- /dev/null
+++ b/users/grfn/bbbg/src/bbbg/core.clj
@@ -0,0 +1,58 @@
+(ns bbbg.core
+  (:gen-class)
+  (:require
+   [bbbg.db :as db]
+   [bbbg.web :as web]
+   [clojure.spec.alpha :as s]
+   [clojure.spec.test.alpha :as stest]
+   [com.stuartsierra.component :as component]
+   [expound.alpha :as exp]))
+
+(s/def ::config
+  (s/merge
+   ::db/config
+   ::web/config))
+
+(defn make-system [config]
+  (component/system-map
+   :db (db/make-database config)
+   :web (web/make-server config)))
+
+(defn env->config []
+  (s/assert
+   ::config
+   (merge
+    (db/env->config)
+    (web/env->config))))
+
+(defn dev-config []
+  (s/assert
+   ::config
+   (merge
+    (db/dev-config)
+    (web/dev-config))))
+
+(defonce system nil)
+
+(defn init-dev []
+  (s/check-asserts true)
+  (set! s/*explain-out* exp/printer)
+  (stest/instrument))
+
+(defn run-dev []
+  (init-dev)
+  (alter-var-root
+   #'system
+   (fn [sys]
+     (when sys
+       (component/start sys))
+     (component/start (make-system (dev-config))))))
+
+(defn -main [& _args]
+  (alter-var-root
+   #'system
+   (constantly (component/start (make-system (env->config))))))
+
+(comment
+  (run-dev)
+  )
diff --git a/users/grfn/bbbg/src/bbbg/db.clj b/users/grfn/bbbg/src/bbbg/db.clj
new file mode 100644
index 000000000000..03c86d6fb965
--- /dev/null
+++ b/users/grfn/bbbg/src/bbbg/db.clj
@@ -0,0 +1,357 @@
+(ns bbbg.db
+  (:gen-class)
+  (:refer-clojure :exclude [get list])
+  (:require [camel-snake-kebab.core :as csk :refer [->kebab-case ->snake_case]]
+            [bbbg.util.core :as u]
+            [clojure.set :as set]
+            [clojure.spec.alpha :as s]
+            [clojure.string :as str]
+            [com.stuartsierra.component :as component]
+            [config.core :refer [env]]
+            [honeysql.format :as hformat]
+            [migratus.core :as migratus]
+            [next.jdbc :as jdbc]
+            [next.jdbc.connection :as jdbc.conn]
+            next.jdbc.date-time
+            [next.jdbc.optional :as jdbc.opt]
+            [next.jdbc.result-set :as rs]
+            [next.jdbc.sql :as sql])
+  (:import [com.impossibl.postgres.jdbc PGSQLSimpleException]
+           com.zaxxer.hikari.HikariDataSource
+           [java.sql Connection ResultSet Types]
+           javax.sql.DataSource))
+
+(s/def ::host string?)
+(s/def ::database string?)
+(s/def ::user string?)
+(s/def ::password string?)
+
+(s/def ::config
+  (s/keys :opt [::host
+                ::database
+                ::user
+                ::password]))
+
+(s/fdef make-database
+  :args
+  (s/cat :config (s/keys :opt [::config])))
+
+(s/fdef env->config :ret ::config)
+
+(s/def ::db any?)
+
+;;;
+
+(def default-config
+  (s/assert
+   ::config
+   {::host "localhost"
+    ::database "bbbg"
+    ::user "bbbg"
+    ::password "password"}))
+
+(defn dev-config [] default-config)
+
+(defn env->config []
+  (->>
+   {::host (:pghost env)
+    ::database (:pgdatabase env)
+    ::user (:pguser env)
+    ::password (:pgpassword env)}
+   u/remove-nils
+   (s/assert ::config)))
+
+(defn ->db-spec [config]
+  (-> default-config
+      (merge config)
+      (set/rename-keys
+       {::host :host
+        ::database :dbname
+        ::user :username
+        ::password :password})
+      (assoc :dbtype "pgsql")))
+
+(defn connection
+  "Make a one-off connection from the given `::config` map, or the environment
+  if not provided"
+  ([] (connection (env->config)))
+  ([config]
+   (-> config
+       ->db-spec
+       (set/rename-keys {:username :user})
+       jdbc/get-datasource
+       jdbc/get-connection)))
+
+(defrecord Database [config]
+  component/Lifecycle
+  (start [this]
+    (assoc this :pool (jdbc.conn/->pool HikariDataSource (->db-spec config))))
+  (stop [this]
+    (some-> this :pool .close)
+    (dissoc this :pool))
+
+  clojure.lang.IFn
+  (invoke [this] (:pool this)))
+
+(defn make-database [config]
+  (map->Database {:config config}))
+
+;;;
+;;; Migrations
+;;;
+
+(defn migratus-config
+  [db]
+  {:store :database
+   :migration-dir "migrations/"
+   :migration-table-name "__migrations__"
+   :db
+   (let [db (if (ifn? db) (db) db)]
+     (cond
+       (.isInstance Connection db)
+       {:connection db}
+       (.isInstance DataSource db)
+       {:datasource db}
+       :else (throw
+              (ex-info "migratus-config called with value of unrecognized type"
+                       {:value db}))))})
+
+(defn generate-migration
+  ([db name] (generate-migration db name :sql))
+  ([db name type] (migratus/create (migratus-config db) name type)))
+
+(defn migrate!
+  [db] (migratus/migrate (migratus-config db)))
+
+(defn rollback!
+  [db] (migratus/rollback (migratus-config db)))
+
+;;;
+;;; Database interaction
+;;;
+
+(defn ->key-ns [tn]
+  (let [tn (name tn)
+        tn (if (str/starts-with? tn "public.")
+             (second (str/split tn #"\." 2))
+             tn)]
+    (str "bbbg." (->kebab-case tn))))
+
+(defn ->table-name [kns]
+  (let [kns (name kns)]
+    (->snake_case
+     (if (str/starts-with? kns "public.")
+       kns
+       (str "public." (last (str/split kns #"\.")))))))
+
+(defn ->column
+  ([col] (->column nil col))
+  ([table col]
+   (let [col-table (some-> col namespace ->table-name)
+         snake-col (-> col name ->snake_case (str/replace #"\?$" ""))]
+     (if (or (not (namespace col))
+             (not table)
+             (= (->table-name table) col-table))
+       snake-col
+       ;; different table, assume fk
+       (str
+        (str/replace-first col-table "public." "")
+        "_"
+        snake-col)))))
+
+(defn ->value [v]
+  (if (keyword? v)
+    (-> v name csk/->snake_case_string)
+    v))
+
+(defn process-key-map [table key-map]
+  (into {}
+        (map (fn [[k v]] [(->column table k)
+                          (->value v)]))
+        key-map))
+
+(defn fkize [col]
+  (if (str/ends-with? col "-id")
+    (let [table (str/join "-" (butlast (str/split (name col) #"-")))]
+      (keyword (->key-ns table) "id"))
+    col))
+
+(def ^:private enum-members-cache (atom {}))
+(defn- enum-members
+  "Returns a set of enum members as strings for the enum with the given name"
+  [db name]
+  (if-let [e (find @enum-members-cache name)]
+    (val e)
+    (let [r (try
+              (-> (jdbc/execute-one!
+                   (db)
+                   [(format "select enum_range(null::%s) as members" name)])
+                  :members
+                  .getArray
+                  set)
+              (catch PGSQLSimpleException _
+                nil))]
+      (swap! enum-members-cache assoc name r)
+      r)))
+
+(def ^{:private true
+       :dynamic true}
+  *meta-db*
+  "Database connection to use to query metadata"
+  nil)
+
+(extend-protocol rs/ReadableColumn
+  String
+  (read-column-by-label [x _] x)
+  (read-column-by-index [x rsmeta idx]
+    (if-not *meta-db*
+      x
+      (let [typ (.getColumnTypeName rsmeta idx)]
+        ;; TODO: Is there a better way to figure out if a type is an enum?
+        (if (enum-members *meta-db* typ)
+          (keyword (csk/->kebab-case-string typ)
+                   (csk/->kebab-case-string x))
+          x)))))
+
+(comment
+  (->key-ns :public.user)
+  (->key-ns :public.api-token)
+  (->key-ns :api-token)
+  (->table-name :api-token)
+  (->table-name :public.user)
+  (->table-name :bbbg.user)
+  )
+
+(defn as-fq-maps [^ResultSet rs _opts]
+  (let [qualify #(when (seq %) (str "bbbg." (->kebab-case %)))
+        rsmeta (.getMetaData rs)
+        cols (mapv
+              (fn [^Integer i]
+                (let [ty (.getColumnType rsmeta i)
+                      lab (.getColumnLabel rsmeta i)
+                      n (str (->kebab-case lab)
+                             (when (= ty Types/BOOLEAN) "?"))]
+                  (fkize
+                   (if-let [q (some-> rsmeta (.getTableName i) qualify not-empty)]
+                     (keyword q n)
+                     (keyword n)))))
+              (range 1 (inc (.getColumnCount rsmeta))))]
+    (jdbc.opt/->MapResultSetOptionalBuilder rs rsmeta cols)))
+
+(def jdbc-opts
+  {:builder-fn as-fq-maps
+   :column-fn ->snake_case
+   :table-fn ->snake_case})
+
+(defmethod hformat/fn-handler "count-distinct" [_ field]
+  (str "count(distinct " (hformat/to-sql field) ")"))
+
+(defn fetch
+  "Fetch a single row from the db matching the given `sql-map` or query"
+  [db sql-map & [opts]]
+  (s/assert
+   (s/nilable (s/keys))
+   (binding [*meta-db* db]
+     (jdbc/execute-one!
+      (db)
+      (if (map? sql-map)
+        (hformat/format sql-map)
+        sql-map)
+      (merge jdbc-opts opts)))))
+
+(defn get
+  "Retrieve a single record from the given table by ID"
+  [db table id & [opts]]
+  (when id
+    (fetch
+     db
+     {:select [:*]
+      :from [table]
+      :where [:= :id id]}
+     opts)))
+
+(defn list
+  "Returns a list of rows from the db matching the given sql-map, table or
+  query"
+  [db sql-map-or-table & [opts]]
+  (s/assert
+   (s/coll-of (s/keys))
+   (binding [*meta-db* db]
+     (jdbc/execute!
+      (db)
+      (cond
+        (map? sql-map-or-table)
+        (hformat/format sql-map-or-table)
+        (keyword? sql-map-or-table)
+        (hformat/format {:select [:*] :from [sql-map-or-table]})
+        :else
+        sql-map-or-table)
+      (merge jdbc-opts opts)))))
+
+(defn exists?
+  "Returns true if the given sql query-map would return any results"
+  [db sql-map]
+  (binding [*meta-db* db]
+    (pos?
+     (:count
+      (fetch db {:select [[:%count.* :count]], :from [[sql-map :sq]]})))))
+
+(defn execute!
+  "Given a database and a honeysql query map, perform an operation on the
+  database and discard the results"
+  [db sql-map & [opts]]
+  (jdbc/execute!
+   (db)
+   (hformat/format sql-map)
+   (merge jdbc-opts opts)))
+
+(defn insert!
+  "Given a database, a table name, and a data hash map, inserts the
+  data as a single row in the database and attempts to return a map of generated
+  keys."
+  [db table key-map & [opts]]
+  (binding [*meta-db* db]
+    (sql/insert!
+     (db)
+     table
+     (process-key-map table key-map)
+     (merge jdbc-opts opts))))
+
+(defn update!
+  "Given a database, a table name, a hash map of columns and values
+  to set, and a honeysql predicate, perform an update on the table.
+  Will "
+  [db table key-map where-params & [opts]]
+  (binding [*meta-db* db]
+    (execute! db
+              {:update table
+               :set (u/map-keys keyword (process-key-map table key-map))
+               :where where-params
+               :returning [:id]}
+              opts)))
+
+(defn delete!
+  "Delete all rows from the given table matching the given where clause"
+  [db table where-clause]
+  (binding [*meta-db* db]
+    (sql/delete! (db) table (hformat/format-predicate where-clause))))
+
+(defmacro with-transaction [[sym db opts] & body]
+  `(jdbc/with-transaction
+     [tx# (~db) ~opts]
+     (let [~sym (constantly tx#)]
+       ~@body)))
+
+(defn -main [& args]
+  (let [db (component/start (make-database {::config (env->config)}))]
+    (case (first args)
+      "migrate" (migrate! db)
+      "rollback" (rollback! db))))
+
+(comment
+  (def db (:db bbbg.core/system))
+  (generate-migration db "init-schema")
+  (migrate! db)
+
+
+  )
diff --git a/users/grfn/bbbg/src/bbbg/db/attendee.clj b/users/grfn/bbbg/src/bbbg/db/attendee.clj
new file mode 100644
index 000000000000..7584b1cceb8e
--- /dev/null
+++ b/users/grfn/bbbg/src/bbbg/db/attendee.clj
@@ -0,0 +1,29 @@
+(ns bbbg.db.attendee
+  (:require
+   [bbbg.db :as db]
+   honeysql-postgres.helpers
+   [honeysql.helpers :refer [merge-join merge-where]]))
+
+(defn search
+  ([query]
+   (cond->
+       {:select [:attendee.*]
+        :from [:attendee]}
+     query
+     (assoc
+      :where [:or
+              [:ilike :meetup_name (str "%" query "%")]
+              [:ilike :discord_name (str "%" query "%")]])))
+  ([db query]
+   (db/list db (search query))))
+
+(defn for-event
+  ([query event-id]
+   (-> query
+       (merge-join :event_attendee [:= :attendee.id :event_attendee.attendee_id])
+       (merge-where [:= :event_attendee.event_id event-id]))))
+
+(comment
+  (def db (:db bbbg.core/system))
+  (search db "gri")
+  )
diff --git a/users/grfn/bbbg/src/bbbg/db/event.clj b/users/grfn/bbbg/src/bbbg/db/event.clj
new file mode 100644
index 000000000000..a2aa30fd0dad
--- /dev/null
+++ b/users/grfn/bbbg/src/bbbg/db/event.clj
@@ -0,0 +1,50 @@
+(ns bbbg.db.event
+  (:require
+   [bbbg.attendee :as attendee]
+   [bbbg.db :as db]
+   [bbbg.event :as event]
+   [honeysql.helpers :refer [merge-group-by merge-join merge-select]]
+   [java-time :refer [local-date]]))
+
+(defn create! [db event]
+  (db/insert! db :event (select-keys event [::event/date])))
+
+(defn attended!
+  [db params]
+  (db/execute!
+   db
+   {:insert-into :event-attendee
+    :values [{:event_id (::event/id params)
+              :attendee_id (::attendee/id params)
+              :attended true}]
+    :upsert {:on-conflict [:event-id :attendee-id]
+             :do-update-set! {:attended true}}}))
+
+(defn on-day
+  ([day] {:select [:event.*]
+          :from [:event]
+          :where [:= :date (str day)]})
+  ([db day]
+   (db/list db (on-day day))))
+
+(defn today
+  ([] (on-day (local-date)))
+  ([db] (db/list db (today))))
+
+(defn with-attendee-counts
+  [query]
+  (-> query
+      (merge-join :event_attendee [:= :event.id :event_attendee.event-id])
+      (merge-select :%count.event_attendee.attendee_id)
+      (merge-group-by :event.id :event_attendee.event-id)))
+
+(comment
+  (def db (:db bbbg.core/system))
+  (db/list db (-> (today) (with-attendee-counts)))
+
+  (honeysql.format/format
+   (honeysql-postgres.helpers/upsert {:insert-into :foo
+                                      :values {:bar 1}}
+                                     (-> (honeysql-postgres.helpers/on-conflict :did)
+                                         (honeysql-postgres.helpers/do-update-set! [:did true]))))
+  )
diff --git a/users/grfn/bbbg/src/bbbg/event.clj b/users/grfn/bbbg/src/bbbg/event.clj
new file mode 100644
index 000000000000..aa0578f3546b
--- /dev/null
+++ b/users/grfn/bbbg/src/bbbg/event.clj
@@ -0,0 +1,4 @@
+(ns bbbg.event
+  (:require [clojure.spec.alpha :as s]))
+
+(s/def ::id uuid?)
diff --git a/users/grfn/bbbg/src/bbbg/event_attendee.clj b/users/grfn/bbbg/src/bbbg/event_attendee.clj
new file mode 100644
index 000000000000..af37bf01c023
--- /dev/null
+++ b/users/grfn/bbbg/src/bbbg/event_attendee.clj
@@ -0,0 +1,4 @@
+(ns bbbg.event-attendee
+  (:require [clojure.spec.alpha :as s]))
+
+(s/def ::attended? boolean?)
diff --git a/users/grfn/bbbg/src/bbbg/handlers/attendees.clj b/users/grfn/bbbg/src/bbbg/handlers/attendees.clj
new file mode 100644
index 000000000000..00a8a5908046
--- /dev/null
+++ b/users/grfn/bbbg/src/bbbg/handlers/attendees.clj
@@ -0,0 +1,40 @@
+(ns bbbg.handlers.attendees
+  (:require
+   [bbbg.attendee :as attendee]
+   [bbbg.db :as db]
+   [bbbg.db.attendee :as db.attendee]
+   [bbbg.db.event :as db.event]
+   [bbbg.event :as event]
+   [cheshire.core :as json]
+   [compojure.core :refer [GET POST routes]]
+   [honeysql.helpers :refer [merge-where]]
+   [ring.util.response :refer [content-type redirect response]]))
+
+(defn attendees-routes [{:keys [db]}]
+  (routes
+   (GET "/attendees.json" [q event_id attended]
+     (let [results
+           (db/list
+            db
+            (cond->
+                (if q
+                  (db.attendee/search q)
+                  {:select [:attendee.*] :from [:attendee]})
+              event_id (db.attendee/for-event event_id)
+              (some? attended) (merge-where [:= :attended (case attended
+                                                            "true" true
+                                                            "false" false)])))]
+       (-> {:results results}
+           json/generate-string
+           response
+           (content-type "application/json"))))
+
+   (POST "/event_attendees" [event_id attendee_id]
+     (if (and (db/exists? db {:select [:id] :from [:event] :where [:= :id event_id]})
+              (db/exists? db {:select [:id] :from [:attendee] :where [:= :id attendee_id]}))
+       (do
+         (db.event/attended! db {::event/id event_id
+                                 ::attendee/id attendee_id})
+         (-> (redirect (str "/signup-forms/" event_id))
+             (assoc :flash "Thank you for signing in! Enjoy the event.")))
+       (response "Something went wrong")))))
diff --git a/users/grfn/bbbg/src/bbbg/handlers/core.clj b/users/grfn/bbbg/src/bbbg/handlers/core.clj
new file mode 100644
index 000000000000..34ab74553fdd
--- /dev/null
+++ b/users/grfn/bbbg/src/bbbg/handlers/core.clj
@@ -0,0 +1,34 @@
+(ns bbbg.handlers.core
+  (:require
+   [hiccup.core :refer [html]]
+   [ring.util.response :refer [content-type response]]))
+
+(defn render-page [opts & body]
+  (let [[{:keys [title]} body]
+        (if (map? opts)
+          [opts body]
+          [{} (into [opts] body)])]
+    (html
+     [:html {:lang "en"}
+      [:head
+       [:meta {:charset "UTF-8"}]
+       [:title (if title
+                 (str title " - BBBG")
+                 "BBBG")]
+       [:link {:rel "stylesheet"
+               :type "text/css"
+               :href "/main.css"}]]
+      [:body
+       (into [:div.content] body)
+       [:script {:src "https://cdnjs.cloudflare.com/ajax/libs/tarekraafat-autocomplete.js/10.2.6/autoComplete.js"}]
+       [:script {:src "/main.js"}]]])))
+
+(defn page-response [& render-page-args]
+  (-> (apply render-page render-page-args)
+      response
+      (content-type "text/html")))
+
+(comment
+  (render-page
+   [:h1 "hi"])
+  )
diff --git a/users/grfn/bbbg/src/bbbg/handlers/events.clj b/users/grfn/bbbg/src/bbbg/handlers/events.clj
new file mode 100644
index 000000000000..f42b7bea2c7e
--- /dev/null
+++ b/users/grfn/bbbg/src/bbbg/handlers/events.clj
@@ -0,0 +1,44 @@
+(ns bbbg.handlers.events
+  (:require
+   [bbbg.db :as db]
+   [bbbg.db.event :as db.event]
+   [bbbg.event :as event]
+   [bbbg.handlers.core :refer [page-response]]
+   [compojure.core :refer [context GET POST]]
+   [ring.util.response :refer [redirect]]))
+
+(defn events-index [events]
+  [:ul.events-list
+   (for [event events]
+     [:li (::event/date event)])])
+
+(defn event-form
+  ([] (event-form {}))
+  ([event]
+   [:form {:method "POST" :action "/events"}
+    [:div.form-group
+     [:label "Date"
+      [:input {:type "date"
+               :id "date"
+               :name "date"
+               :value (str (::event/date event))}]]]
+    [:div.form-group
+     [:input {:type "submit"
+              :value "Create Event"}]]]))
+
+(defn events-routes [{:keys [db]}]
+  (context "/events" []
+    (GET "/" []
+      (let [events (db/list db :event)]
+        (events-index events)))
+
+    (GET "/new" [date]
+      (page-response
+       {:title "New Event"}
+       (event-form {::event/date date})))
+
+    (POST "/" [date]
+      (let [event (db.event/create! db {::event/date date})]
+        (-> (str "/signup-forms/" (::event/id event))
+            redirect
+            (assoc-in [:flash :message] "Event Created"))))))
diff --git a/users/grfn/bbbg/src/bbbg/handlers/home.clj b/users/grfn/bbbg/src/bbbg/handlers/home.clj
new file mode 100644
index 000000000000..d5ba72878ab1
--- /dev/null
+++ b/users/grfn/bbbg/src/bbbg/handlers/home.clj
@@ -0,0 +1,17 @@
+(ns bbbg.handlers.home
+  (:require
+   [bbbg.handlers.core :refer [page-response]]
+   [compojure.core :refer [GET routes]]))
+
+(defn- home-page []
+  [:nav.home-nav
+   [:ul
+    [:li [:a {:href "/signup-forms"}
+          "Event Signup Form"]]
+    [:li [:a {:href "/login"}
+          "Sign In"]]]])
+
+(defn home-routes [_env]
+  (routes
+   (GET "/" []
+     (page-response (home-page)))))
diff --git a/users/grfn/bbbg/src/bbbg/handlers/signup_form.clj b/users/grfn/bbbg/src/bbbg/handlers/signup_form.clj
new file mode 100644
index 000000000000..8c4958f1035a
--- /dev/null
+++ b/users/grfn/bbbg/src/bbbg/handlers/signup_form.clj
@@ -0,0 +1,57 @@
+(ns bbbg.handlers.signup-form
+  (:require
+   [bbbg.db :as db]
+   [bbbg.db.event :as db.event]
+   [bbbg.event :as event]
+   [bbbg.handlers.core :refer [page-response]]
+   [compojure.core :refer [GET context]]
+   [java-time :refer [local-date]]
+   [ring.util.response :refer [redirect]]))
+
+(defn no-events-page []
+  [:div.no-events
+   [:p
+    "There are no events for today"]
+   [:p
+    [:a {:href (str "/events/new?date=" (str (local-date)))} "Create Event"]
+    [:a {:href "/events"} "All Events"]]])
+
+(defn signup-page [event]
+  [:div.signup-page
+   [:form#signup-form
+    {:method "POST"
+     :action "/event_attendees"
+     :disabled "disabled"}
+    [:input#event-id {:type "hidden" :name "event_id" :value (::event/id event)}]
+    [:input#attendee-id {:type "hidden" :name "attendee_id"}]
+    [:label "Name"
+     [:input#name-autocomplete
+      {:type "search"
+       :name "name"
+       :spellcheck "false"
+       :autocorrect "off"
+       :autocomplete "off"
+       :autocapitalize "off"
+       :maxlength "2048"}]]
+    [:input {:type "submit"
+             :value "Sign In"
+             :disabled "disabled"}]]])
+
+(defn event-not-found []
+  [:div.event-not-found
+   [:p "Event not found"]
+   [:p [:a {:href (str "/events/new")} "Create a new event"]]])
+
+;;;
+
+(defn signup-form-routes [{:keys [db]}]
+  (context "/signup-forms" []
+   (GET "/" []
+     (if-let [event (db/fetch db (db.event/today))]
+       (redirect (str "/signup-forms/" (::event/id event)))
+       (page-response (no-events-page))))
+
+   (GET "/:event-id" [event-id]
+     (if-let [event (db/get db :event event-id)]
+       (page-response (signup-page event))
+       (event-not-found)))))
diff --git a/users/grfn/bbbg/src/bbbg/styles.clj b/users/grfn/bbbg/src/bbbg/styles.clj
new file mode 100644
index 000000000000..07ed87ba1a40
--- /dev/null
+++ b/users/grfn/bbbg/src/bbbg/styles.clj
@@ -0,0 +1,9 @@
+(ns bbbg.styles
+  (:require [garden.def :refer [defstyles]]
+            [garden.compiler :refer [compile-css]]))
+
+(defstyles styles
+  )
+
+(def stylesheet
+  (compile-css styles))
diff --git a/users/grfn/bbbg/src/bbbg/util/core.clj b/users/grfn/bbbg/src/bbbg/util/core.clj
new file mode 100644
index 000000000000..7f2a8516bf86
--- /dev/null
+++ b/users/grfn/bbbg/src/bbbg/util/core.clj
@@ -0,0 +1,117 @@
+(ns bbbg.util.core
+  (:import java.util.UUID))
+
+(defn remove-nils
+  "Remove all keys with nil values from m"
+  [m]
+  (let [!m (transient m)]
+    (doseq [[k v] m]
+      (when (nil? v)
+        (dissoc! !m k)))
+    (persistent! !m)))
+
+
+(defn alongside
+  "Apply a pair of functions to the first and second element of a two element
+  vector, respectively. The two argument form partially applies, such that:
+
+  ((alongside f g) xy) ≡ (alongside f g xy)
+
+  This is equivalent to (***) in haskell's Control.Arrow"
+  ([f g] (partial alongside f g))
+  ([f g [x y]] [(f x) (g y)]))
+
+(defn map-kv
+  "Map a pair of functions over the keys and values of a map, respectively.
+  Preserves metadata on the incoming map.
+  The two argument form returns a transducer that yields map-entries.
+
+  (partial map-kv identity identity) ≡ identity"
+  ([kf vf]
+   (map (fn [[k v]]
+          ;; important to return a map-entry here so that callers down the road
+          ;; can use `key` or `val`
+          (first {(kf k) (vf v)}))))
+  ([kf vf m]
+   (into (empty m) (map-kv kf vf) m)))
+
+(defn filter-kv
+  "Returns a map containing the elements of m for which (f k v) returns logical
+  true. The one-argument form returns a transducer that yields map entries"
+  ([f] (filter (partial apply f)))
+  ([f m]
+   (into (empty m) (filter-kv f) m)))
+
+(defn map-keys
+  "Map f over the keys of m. Preserves metadata on the incoming map. The
+  one-argument form returns a transducer that yields map-entries."
+  ([f] (map-kv f identity))
+  ([f m] (map-kv f identity m)))
+
+(defn map-vals
+  "Map f over the values of m. Preserves metadata on the incoming map. The
+  one-argument form returns a transducer that yields map-entries."
+  ([f] (map-kv identity f))
+  ([f m] (map-kv identity f m)))
+
+(defn map-keys-recursive [f x]
+  (cond
+    (map? x) (map-kv f (partial map-keys-recursive f) x)
+    (sequential? x) (map (partial map-keys-recursive f) x)
+    :else x))
+
+(defn denamespace [x]
+  (if (keyword? x)
+    (keyword (name x))
+    (map-keys-recursive denamespace x)))
+
+(defn reverse-merge
+  "Like `clojure.core/merge`, except duplicate keys from maps earlier in the
+  argument list take precedence
+
+    => (merge {:x 1} {:x 2})
+    {:x 2}
+
+    => (sut/reverse-merge {:x 1} {:x 2})
+    {:x 1}"
+  [& ms]
+  (apply merge (reverse ms)))
+
+(defn invert-map
+  "Invert the keys and vals of m. Behavior with duplicate vals is undefined.
+
+  => (sut/invert-map {:x 1 :y 2})
+  {1 :x 2 :y}"
+  [m]
+  (into {} (map (comp vec reverse)) m))
+
+(defn ->uuid
+  "Converts x to uuid, returning nil if x is nil or empty"
+  [x]
+  (cond
+    (not x) nil
+    (uuid? x) x
+    (and (string? x) (seq x))
+    (UUID/fromString x)))
+
+(defn key-by
+  "Create a map from a seq obtaining keys via f
+
+    => (sut/key-by :x [{:x 1} {:x 2 :y 3}])
+    {1 {:x 1}, 2 {:x 2 :y 3}}"
+  [f l]
+  (into {} (map (juxt f identity)) l))
+
+(defn distinct-by
+  "Like clojure.core/distinct, but can take a function f by which
+  distinctiveness is calculated"
+  [distinction-fn coll]
+  (let [step (fn step [xs seen]
+               (lazy-seq
+                ((fn [[f :as xs] seen]
+                   (when-let [s (seq xs)]
+                     (if (contains? seen (distinction-fn f))
+                       (recur (rest s) seen)
+                       (cons f (step (rest s) (conj seen (distinction-fn f)))))))
+                 xs seen)))]
+    (step coll #{})))
diff --git a/users/grfn/bbbg/src/bbbg/web.clj b/users/grfn/bbbg/src/bbbg/web.clj
new file mode 100644
index 000000000000..4e0566bcc3c6
--- /dev/null
+++ b/users/grfn/bbbg/src/bbbg/web.clj
@@ -0,0 +1,77 @@
+(ns bbbg.web
+  (:require
+   [bbbg.handlers.attendees :as attendees]
+   [bbbg.handlers.events :as events]
+   [bbbg.handlers.home :as home]
+   [bbbg.handlers.signup-form :as signup-form]
+   [bbbg.styles :refer [stylesheet]]
+   [clojure.spec.alpha :as s]
+   [com.stuartsierra.component :as component]
+   [compojure.core :refer [GET routes]]
+   [config.core :refer [env]]
+   [org.httpkit.server :as http-kit]
+   [ring.middleware.flash :refer [wrap-flash]]
+   [ring.middleware.keyword-params :refer [wrap-keyword-params]]
+   [ring.middleware.params :refer [wrap-params]]
+   [ring.util.response :refer [content-type response resource-response]]))
+
+(s/def ::port pos-int?)
+
+(s/def ::config
+  (s/keys :req [::port]))
+
+(s/fdef make-server
+  :args (s/cat :config ::config))
+
+(defn env->config []
+  (s/assert
+   ::config
+   {::port (:port env 8888)}))
+
+(defn dev-config []
+  (s/assert ::config {::port 8888}))
+
+;;;
+
+(defn app-routes [env]
+  (routes
+   (GET "/main.css" []
+     (-> (response stylesheet)
+         (content-type "text/css")))
+   (GET "/main.js" []
+     (-> (resource-response "main.js")
+         (content-type "text/javascript")))
+
+   (attendees/attendees-routes env)
+   (signup-form/signup-form-routes env)
+   (events/events-routes env)
+   (home/home-routes env)))
+
+(defn middleware [app]
+  (-> app
+      wrap-keyword-params
+      wrap-params
+      wrap-flash))
+
+(defn handler [this]
+  (middleware
+   (app-routes this)))
+
+(defrecord WebServer [port db]
+  component/Lifecycle
+  (start [this]
+    (assoc this
+           ::shutdown-fn
+           (http-kit/run-server
+            (fn [r] ((handler this) r))
+            {:port port})))
+  (stop [this]
+    (if-let [shutdown-fn (::shutdown-fn this)]
+      (do (shutdown-fn :timeout 100)
+          (dissoc this ::shutdown-fn))
+      this)))
+
+(defn make-server [{::keys [port]}]
+  (component/using
+   (map->WebServer {:port port})
+   [:db]))