Backend api

This commit is contained in:
Daniel O'Connell 2021-02-06 21:53:04 +01:00
parent f361882a3f
commit 0f21522a90
12 changed files with 326 additions and 183 deletions

View File

@ -7,10 +7,13 @@
[thheller/shadow-cljs "2.11.0"]
[reagent "0.10.0"]
[re-frame "1.1.1"]
[day8.re-frame/http-fx "0.2.2"]
[garden "1.3.10"]
[ns-tracker "0.4.0"]
[compojure "1.6.2"]
[yogthos/config "1.1.7"]
[ring-basic-authentication "1.1.0"]
[ring-cors "0.1.13"]
[ring "1.8.1"]]
:plugins [[lein-shadow "0.2.2"]

View File

@ -1,15 +1,81 @@
(ns chicken-master.handler
(:require
[compojure.core :refer [GET defroutes]]
(:require [chicken-master.mocks :as mocks]
[clojure.edn :as edn]
[compojure.core :refer [GET POST PUT DELETE defroutes]]
[compojure.route :refer [resources]]
[compojure.handler :refer [api]]
[ring.util.response :refer [resource-response]]
[ring.middleware.reload :refer [wrap-reload]]
[shadow.http.push-state :as push-state]))
[ring.middleware.basic-authentication :refer [wrap-basic-authentication]]
[ring.middleware.cors :refer [wrap-cors]]))
(defn get-customers [] {:body (mocks/fetch-customers {})})
(defn add-customer [request] {:body (some-> request :body :name mocks/add-customer)})
(defn get-products [_] (prn _){:body (mocks/get-all-products)})
(defn save-products [request] {:body (some-> request :body mocks/save-stocks)})
(defn get-orders [params] {:body {:orders (mocks/get-orders params)}})
(defn update-order [request]
(let [id (some-> request :route-params :id (Integer/parseInt))
body (some->> request :body)]
{:body (mocks/replace-order id body)}))
(defn delete-order [id] {:body (mocks/delete-order (edn/read-string id))})
(defn set-order-state [id status] (prn "asd"){:body (mocks/order-state (edn/read-string id) status)})
(defn get-stock [params]
{:body
{:customers (:body (get-customers))
:products (:body (get-products params))}})
(defroutes routes
(GET "/stock" {params :query-params} (get-stock params))
(GET "/customers" [] (get-customers))
(POST "/customers" request (add-customer request))
(GET "/products" request (get-products request))
(POST "/products" request (save-products request))
(GET "/orders" {params :query-params} (get-orders params))
(POST "/orders" request (update-order request))
(PUT "/orders/:id" request (update-order request))
(DELETE "/orders/:id" [id] (delete-order id))
(POST "/orders/:id/:status" [id status] (set-order-state id status))
(GET "/" [] (resource-response "index.html" {:root "public"}))
(resources "/"))
(def dev-handler (-> #'routes wrap-reload push-state/handle))
(def handler routes)
(defn- handle-edn [response]
(if (string? (:body response))
response
(-> response
(assoc-in [:headers "Content-Type"] "application/edn")
(update :body pr-str))))
(defn wrap-edn-response [handler]
(fn
([request]
(-> request handler handle-edn))
([request respond raise]
(-> request handler handle-edn respond))))
(defn wrap-edn-request [handler]
(fn
([request]
(handler
(if (= (:content-type request) "application/edn")
(update request :body (comp edn/read-string slurp))
request)))))
(defn authenticated? [name pass]
(and (= name "siloa")
(= pass "krach")))
(def handler (-> routes
(wrap-basic-authentication authenticated?)
(wrap-cors :access-control-allow-origin [#"http://localhost:8280"]
:access-control-allow-methods [:get :put :post :delete :options])
api
wrap-edn-request
wrap-edn-response))

View File

@ -0,0 +1,95 @@
(ns chicken-master.mocks
(:import [java.time Instant]
[java.time.temporal ChronoUnit]))
(defn format-date [d] (-> d str (subs 0 10)))
(defn days-range [days date ]
(map #(.plus date % ChronoUnit/DAYS) (range days)))
;;;; Stock
(def stock-products (atom {:eggs 22 :milk 32 :cabbage 54 :carrots 11 :cows 32 :ants 21}))
(defn get-all-products [] @stock-products)
(defn save-stocks [new-products] (reset! stock-products new-products))
;;; Orders
(def id-counter (atom -1))
(def notes ["bezglutenowy"
"tylko z robakami"
"przyjdzie wieczorem"
"wisi 2.50"
"chciała ukraść kozę"])
(def products (atom [:eggs :milk :cabbage :carrots]))
(def customers (atom [{:id 1 :name "mr.blobby (649 234 234)"}
{:id 2 :name "da police (0118 999 881 999 119 725 123123 12 3123 123 )"}
{:id 3 :name "johnny"}]))
(def orders
(atom
(->> (-> (Instant/now) (.minus 50 ChronoUnit/DAYS))
(days-range 90)
(map (fn [date]
[(format-date date) (repeatedly (rand-int 6) #(swap! id-counter inc))]))
(map (fn [[day ids]]
(map (fn [i]
{:id i :day day
:notes (when (> (rand) 0.7) (rand-nth notes))
:state :waiting
:who (rand-nth @customers)
:products (->> @products
(random-sample 0.4)
(map #(vector % (rand-int 10)))
(into {}))
}) ids)
))
flatten
(map #(vector (:id %) %))
(into {}))))
(defn fetch-customers [_]
@customers)
(defn fetch-stock [params]
{:customers (fetch-customers params)
:products (get-all-products)})
(defn add-customer [customer-name]
(swap! customers conj {:id (->> @customers (map :id) (apply max) inc)
:name customer-name})
(fetch-stock {}))
(defn day-customers [day] [day (->> @orders vals (filter (comp #{day} :day)))])
(defn get-orders [params] @orders)
(defn replace-order [id order]
(println "replacing order" order)
(let [prev-day (:day (@orders (:id order)))
order (update order :id #(or % (swap! id-counter inc)))]
(prn "order 1" order)
(swap! orders assoc (:id order) order)
(prn "order 2" (@orders (:id order)))
(if (or (not prev-day) (= prev-day (:day order)))
{(:day order) (->> order :day day-customers second)}
{prev-day (->> prev-day day-customers second)
(:day order) (->> order :day day-customers second)})))
(defn delete-order [id]
(println "deleting order" id)
(let [day (-> (get @orders id) :day)]
(swap! orders #(dissoc % id))
{day (->> day day-customers second)}))
(defn order-state [id state]
(prn "fulfilling order" id state)
(condp = state
"fulfilled" (->> id (get @orders) :products (swap! stock-products #(merge-with - %1 %2)))
"waiting" (->> id (get @orders) :products (swap! stock-products #(merge-with + %1 %2))))
(let [day (-> (get @orders id) :day)]
(swap! orders #(assoc-in % [id :state] (keyword state)))
(println id (get @orders id))
{day (->> day day-customers second)}))

View File

@ -7,3 +7,8 @@
(defn -main [& _args]
(let [port (or (env :port) 3000)]
(run-jetty handler {:port port :join? false})))
(def h
(let [port (or (env :port) 3000)]
(run-jetty handler {:port port :join? false})))
(.stop h)

View File

@ -6,17 +6,12 @@
(def stock-products (atom {:eggs 22 :milk 32 :cabbage 54 :carrots 11 :cows 32 :ants 21}))
(defn get-all-products [] @stock-products)
(defn save-stocks [new-products] (reset! stock-products new-products))
(defn save-stocks [new-products]
(reset! stock-products new-products))
;;; Orders
(def id-counter (atom -1))
;; (def days (atom
;; (->> (time/date-offset (new js/Date) -50)
;; (time/days-range 90)
;; (map (fn [date]
;; [(time/iso-date date) (repeatedly (rand-int 6) #(swap! id-counter inc))]))
;; (into {}))))
(def notes ["bezglutenowy"
"tylko z robakami"
"przyjdzie wieczorem"
@ -56,9 +51,11 @@
{:customers (fetch-customers params)
:products (get-all-products)})
(defn add-customer [{:keys [customer-name] :as params}]
(defn add-customer [{:keys [name] :as params}]
(prn name)
(swap! customers conj {:id (->> @customers (map :id) (apply max) inc)
:name customer-name})
:name name})
(prn @customers)
(fetch-stock params))
(defn- day-customers [day] [day (->> @orders vals (filter (comp #{day} :day)))])
@ -67,31 +64,21 @@
(int (/ (- (time/parse-date to) (time/parse-date from)) (* 24 3600000)))
(time/parse-date from)))
(defn fetch-days [{:keys [from to]}]
(->> (days-between from to)
(map time/iso-date)
(map day-customers)
(into {})))
(defn fetch-orders [{:keys [from to]}]
{:orders @orders})
(defn- replace-order [{start-from :start-from :as order}]
(defn- replace-order [id order]
(println "replacing order" order)
(let [order (-> order
(dissoc :start-from)
(update :id #(or % (swap! id-counter inc))))]
(prn "order 1" order)
(let [prev-day (:day (get @orders id))
order (update order :id #(or % (swap! id-counter inc)))]
(swap! orders assoc (:id order) order)
(prn "order 2" (@orders (:id order)))
(if start-from
(->> start-from
time/start-of-week
(time/days-range 28)
(map time/iso-date)
(map day-customers)
(into {}))
(if prev-day
{prev-day (->> prev-day day-customers second)
(:day order) (->> order :day day-customers second)}
{(:day order) (->> order :day day-customers second)})))
(defn- delete-order [{id :id}]
(println "deleting order" id)
(defn- delete-order [id]
(println "deleting order" id (get @orders id))
(let [day (-> (get @orders id) :day)]
(swap! orders #(dissoc % id))
{day (->> day day-customers second)}))

View File

@ -51,13 +51,13 @@
(map prod/format-product)
(into [:div {:class :products}]))])
(defn day [{:keys [date orders]}]
[:div {:class [:day (when (time/today? date) :today)]
(defn day [[date orders]]
[:div {:class [:day (when (-> date time/parse-date time/today?) :today)]
:on-drag-over #(.preventDefault %)
:on-drop #(let [id (-> % .-dataTransfer (.getData "text") prod/num-or-nil)]
(.preventDefault %)
(re-frame/dispatch [::event/move-order id (time/iso-date date)]))}
[:div {:class :day-header} (time/format-date date)]
(re-frame/dispatch [::event/move-order id date]))}
[:div {:class :day-header} (-> date time/parse-date time/format-date)]
[:div
[:div {:class :orders}
(if (settings :hide-fulfilled-orders)
@ -65,7 +65,7 @@
(map format-order orders))
(when (settings :show-day-add-order)
[:button {:type :button
:on-click #(re-frame/dispatch [::event/edit-order (time/iso-date date)])} "+"])
:on-click #(re-frame/dispatch [::event/edit-order date])} "+"])
(when (seq (map :products orders))
[:div {:class :summary}
[:hr {:class :day-seperator}]
@ -75,8 +75,7 @@
(apply merge-with +)
(sort-by first)
(map prod/format-product)
(into [:div {:class :products-sum}]))])
]]])
(into [:div {:class :products-sum}]))])]]])
(defn calendar-header []
(->> (:day-names settings)

View File

@ -14,4 +14,7 @@
:show-order-notes true ; display notes
:editable-number-inputs false ; only allow number modifications in the edit modal
:hide-fulfilled-orders false
:http-dispatch :http;-xhrio
:backend-url "http://localhost:3000/"
})

View File

@ -11,7 +11,7 @@
(defn order-adder [order]
(let [state (reagent/atom order)]
(fn []
[:details {:class :customer-order :key (gensym) :open (:open @state)}
[:details {:class (or (:class order) :customer-order) :key (gensym) :open (:open @state)}
[:summary {:on-click #(swap! state update :open not)}
[prod/item-adder
:type :date
@ -34,8 +34,8 @@
(for [{:keys [name id] :as who} @(re-frame/subscribe [::subs/available-customers])]
[:details {:class "client" :key (gensym)}
[:summary name]
(for [order (sort-by :day (client-orders id))]
[order-adder {:who who}]
(for [order (reverse (sort-by :day (client-orders id)))]
[order-adder (assoc order :key (gensym))])
[order-adder who :class :new-user]
]))]
))

View File

@ -3,20 +3,40 @@
[re-frame.core :as re-frame]
[chicken-master.db :as db]
[chicken-master.time :as time]
[chicken-master.config :refer [settings]]
[day8.re-frame.http-fx]
[ajax.edn :as edn]
;; required for http mocks
[chicken-master.backend-mocks :as mocks]))
(defn http-request [method endpoint & {:keys [params body on-success on-failure]
:or {on-success ::process-fetched-days
on-failure ::failed-blah}}]
{:method method
:uri (str (settings :backend-url) endpoint)
:headers {"Content-Type" "application/edn"
"authorization" "Basic c2lsb2E6a3JhY2g="}
:format (edn/edn-request-format)
:body (some-> body pr-str)
:params params
:response-format (edn/edn-response-format)
:on-success [on-success]
:on-fail [on-failure]})
(defn http-get [endpoint params on-success]
(http-request :get endpoint :params params :on-success on-success))
(defn http-post [endpoint body]
(http-request :post endpoint :body body))
(re-frame/reg-event-fx
::initialize-db
(fn [_ _]
{:db db/default-db
:dispatch [::show-from-date (new js/Date)]
:http {:method :post
:url "get-stock"
:params {}
:on-success [::process-stock]
:on-fail [::failed-blah]}}))
:fx [[:dispatch [::show-from-date (time/iso-date (time/today))]]
[:dispatch [::fetch-stock]]
[:dispatch [::fetch-orders]]]}))
(re-frame/reg-event-db ::hide-modal (fn [db [_ modal]] (assoc-in db [modal :show] nil)))
@ -29,20 +49,14 @@
(re-frame/reg-event-fx
::remove-order
(fn [_ [_ id]]
{:http {:method :delete
:url "delete-order"
:params {:id id}
:on-success [::process-fetched-days]
:on-fail [::failed-blah]}}))
{(settings :http-dispatch) (http-request :delete (str "orders/" id))}))
(re-frame/reg-event-fx
::move-order
(fn [{{orders :orders start-date :start-date} :db} [_ id day]]
{:http {:method :post
:url "save-order"
:params (-> id orders (assoc :day day :start-from start-date))
:on-success [::process-fetched-days]
:on-fail [::failed-blah]}}))
{(settings :http-dispatch)
(http-request :put (str "orders/" id)
:body (-> id orders (assoc :day day :start-from start-date)))}))
(re-frame/reg-event-db
::edit-order
@ -56,107 +70,57 @@
::fulfill-order
(fn [{db :db} [_ id]]
{:db (assoc-in db [:orders id :state] :pending)
:dispatch [::set-current-days]
:http {:method :post
:url "fulfill-order"
:params {:id id}
:on-success [::process-fetched-days]
:on-fail [::failed-blah]}}))
(settings :http-dispatch) (http-request :post (str "orders/" id "/fulfilled"))}))
(re-frame/reg-event-fx
::reset-order
(fn [{db :db} [_ id]]
{:db (assoc-in db [:orders id :state] :waiting)
:fx [[:dispatch [::set-current-days]]]
:http {:method :post
:url "reset-order"
:params {:id id}
:on-success [::process-fetched-days]
:on-fail [::failed-blah]}}))
(settings :http-dispatch) (http-request :post (str "orders/" id "/waiting"))}))
(re-frame/reg-event-fx
::save-order
(fn [{{order :order-edit} :db} [_ form]]
{:dispatch [::hide-modal :order-edit]
:http {:method :post
:url "save-order"
:params (merge
(settings :http-dispatch) (http-post (str "orders")
(merge
(select-keys order [:id :day :hour :state])
(select-keys form [:id :day :hour :state :who :notes :products]))
:on-success [::process-fetched-days]
:on-fail [::failed-blah]}}))
(select-keys form [:id :day :hour :state :who :notes :products])))}))
(re-frame/reg-event-db
::selected-product
(fn [db [_ product product-no]]
(let [db (assoc-in db [:order-edit :products product-no :prod] product)]
(if (-> db :order-edit :products last :prod)
(update-in db [:order-edit :products] conj {})
db))))
(re-frame/reg-event-db
::changed-amount
(fn [db [_ amount product-no]]
(assoc-in db [:order-edit :products product-no :amount] amount)))
(defn get-day [{:keys [days orders]} date]
{:date date
:orders (->> date
time/iso-date
(get days)
(map orders))})
(re-frame/reg-event-db
::set-current-days
(fn [{start-day :start-date :as db} _]
(->> start-day
time/parse-date
time/start-of-week
(time/days-range 14)
(map (partial get-day db))
(assoc db :current-days))))
(re-frame/reg-event-fx
::process-fetched-days
(fn [{db :db} [_ days]]
(println "fetched days" days)
{:db (-> db
(update :days #(reduce-kv (fn [m k v] (assoc m k (map :id v))) % days))
(update :orders #(reduce (fn [m cust] (assoc m (:id cust) cust)) % (-> days vals flatten))))
:fx [[:dispatch [::set-current-days]]
[:dispatch [::fetch-stock]]]}))
(defn missing-days
"Return a map of missing days to be fetched."
[db day]
(let [missing-days (->> day
time/parse-date
time/start-of-week
(time/days-range 28)
(remove (comp (:days db {}) time/iso-date)))]
(when (seq missing-days)
{:from (->> missing-days (sort time/before?) first time/iso-date)
:to (->> missing-days (sort time/before?) last time/iso-date)})))
(fn [db [_ days]]
(-> db
(update :current-days #(map (fn [[day orders]]
[day (if (contains? days day)
(days day) orders)]) %))
(update :orders #(reduce (fn [m cust] (assoc m (:id cust) cust)) % (-> days vals flatten))))))
(re-frame/reg-event-fx
::scroll-weeks
(fn [{db :db} [_ offset]]
{:dispatch [::show-from-date (-> db :start-date time/parse-date (time/date-offset (* 7 offset)))]}))
{:fx [[:dispatch [::fetch-stock]]
[:dispatch [::show-from-date (-> db
:start-date
time/parse-date
(time/date-offset (* 7 offset))
time/iso-date)]]]}))
(re-frame/reg-event-db
::show-from-date
(fn [{:keys [start-date orders] :as db} [_ day]]
(let [day (or day start-date)
days (into #{} (time/get-weeks day 2))
filtered-orders (->> orders vals (filter days))]
(assoc db
:start-date day
:current-days (map #(vector % (get filtered-orders %)) days)))))
(re-frame/reg-event-fx
::show-from-date
(fn [{db :db} [_ day]]
(let [missing (missing-days db day)
effects {:db (assoc db :start-date day)
:dispatch [::set-current-days]}]
(if-not missing
effects
(assoc effects :http {:method :get
:url "get-days"
:params missing
:on-success [::process-fetched-days]
:on-fail [::failed-blah]})))))
::fetch-orders
(fn [_ [_ from to]]
{(settings :http-dispatch) (http-get "orders" {} ::process-stock)}))
;; Customers events
(re-frame/reg-event-fx
::show-customers
@ -167,11 +131,9 @@
(re-frame/reg-event-fx
::add-customer
(fn [_ [_ customer-name]]
{:http {:method :post
:url "add-customer"
:params {:customer-name customer-name}
:on-success [::process-stock]
:on-fail [::failed-blah]}}))
{(settings :http-dispatch) (http-request :post "customers"
:body {:name customer-name}
:on-success ::process-stock)}))
;;; Storage events
@ -184,18 +146,19 @@
(re-frame/reg-event-fx
::fetch-stock
(fn [_ _]
{:http {:method :get
:url "get-stock"
:on-success [::process-stock]
:on-fail [::failed-blah]}}))
{(settings :http-dispatch) (http-get "stock" {} ::process-stock)}))
(re-frame/reg-event-db
(defn assoc-if [coll key val] (if val (assoc coll key val) coll))
(re-frame/reg-event-fx
::process-stock
(fn [db [_ {:keys [products customers]}]]
(println "fetched stock" products)
(assoc db
:products products
:customers customers)))
(fn [{db :db} [_ {:keys [products customers orders]}]]
(prn products customers orders)
{:db (-> db
(assoc-if :products products)
(assoc-if :customers customers)
(assoc-if :orders orders))
:dispatch [::process-fetched-days (group-by :day (vals orders))]
}))
(re-frame/reg-event-db
::update-product-stock
@ -215,13 +178,9 @@
(re-frame/reg-event-fx
::save-stock
(fn [{db :db} [_ products]]
(fn [_ [_ products]]
{:dispatch [::hide-modal :stock]
:http {:method :post
:url "save-stock"
:body products
:on-success [::process-fetched-days]
:on-fail [::failed-blah]}}))
(settings :http-dispatch) (http-request :post "products" :body products :on-sucess ::process-stock)}))
@ -236,17 +195,33 @@
(re-frame/reg-fx
:http
(fn [{:keys [method url params body on-success on-fail]}]
(condp = url
"get-days" (re-frame/dispatch (conj on-success (mocks/fetch-days params)))
"save-order" (re-frame/dispatch (conj on-success (mocks/replace-order params)))
"delete-order" (re-frame/dispatch (conj on-success (mocks/delete-order params)))
"fulfill-order" (re-frame/dispatch (conj on-success (mocks/order-state (assoc params :state :fulfilled))))
"reset-order" (re-frame/dispatch (conj on-success (mocks/order-state (assoc params :state :waiting))))
(fn [{:keys [method uri params body on-success on-fail]}]
(condp = uri
"http://localhost:3000/stock" (re-frame/dispatch (conj on-success (mocks/fetch-stock params)))
"get-stock" (re-frame/dispatch (conj on-success (mocks/fetch-stock params)))
"get-customers" (re-frame/dispatch (conj on-success (mocks/fetch-customers params)))
"add-customer" (re-frame/dispatch (conj on-success (mocks/add-customer params)))
"get-all-products" (re-frame/dispatch (conj on-success (mocks/get-all-products)))
"save-stock" (re-frame/dispatch (conj on-success (mocks/save-stocks body)))
(let [parts (clojure.string/split uri "/")]
(cond
(and (= method :get) (= uri "http://localhost:3000/orders"))
(re-frame/dispatch (conj on-success (mocks/fetch-orders params)))
(and (= method :post) (= uri "http://localhost:3000/orders"))
(re-frame/dispatch (conj on-success (mocks/replace-order nil (cljs.reader/read-string body))))
(and (= method :post) (= uri "http://localhost:3000/products"))
(re-frame/dispatch (conj on-success (mocks/save-stocks (cljs.reader/read-string body))))
(= method :delete)
(re-frame/dispatch (conj on-success (mocks/delete-order (-> parts (nth 4) (js/parseInt)))))
(and (= method :post) (= uri "http://localhost:3000/customers"))
(re-frame/dispatch (conj on-success (mocks/add-customer (cljs.reader/read-string body))))
(-> parts last #{"fulfilled" "waiting"})
(re-frame/dispatch (conj on-success (mocks/order-state {:id (-> parts (nth 4) (js/parseInt)) :state (keyword (last parts))})))
true (prn "unhandled" method uri)
))
)))

View File

@ -34,15 +34,16 @@
(defn modal
([modal-id content]
[:div {:class :popup}
[:div {:class :popup-content}
[:div {:class :popup :on-click #(re-frame/dispatch [::event/hide-modal modal-id])}
[:div {:class :popup-content :on-click #(.stopPropagation %)}
content
[:div {:class :form-buttons}
[:button {:type :button :on-click #(re-frame/dispatch [::event/hide-modal modal-id])} "ok"]]]])
([modal-id content on-submit]
[:div {:class :popup}
[:div {:class :popup :on-click #(re-frame/dispatch [::event/hide-modal modal-id])}
[:form {:action "#"
:class :popup-content
:on-click #(.stopPropagation %)
:on-submit (fn [e]
(.preventDefault e)
(when (-> e .-target form-values on-submit)

View File

@ -10,7 +10,7 @@
(re-frame/reg-sub ::show-stock-modal (fn [db] (-> db :stock :show)))
(re-frame/reg-sub ::show-customers-modal (fn [db] (-> db :clients :show)))
(re-frame/reg-sub ::order-edit-who (fn [db] (println (:order-edit db)) (-> db :order-edit :who)))
(re-frame/reg-sub ::order-edit-who (fn [db] (-> db :order-edit :who)))
(re-frame/reg-sub ::order-edit-notes (fn [db] (-> db :order-edit :notes)))
(re-frame/reg-sub ::order-edit-products (fn [db] (-> db :order-edit :products)))

View File

@ -2,6 +2,8 @@
(:require [chicken-master.config :refer [settings]])
(:import [goog.date DateTime Date Interval]))
(defn today [] (new Date))
(defn parse-date [date] (new Date (js/Date. date)))
(defn date-offset
@ -42,6 +44,13 @@
(defn iso-date [date] (.toIsoString ^js/goog.date.Date date true))
(defn get-weeks [from n]
(->> from
parse-date
start-of-week
(days-range (* 7 n))
(map iso-date)))
(comment
(with-redefs [settings {:first-day-offset 0}]