(ns pocketbook.todomvc "TodoMVC built on Pocketbook — offline-first, synced, Clojure-native." (:require [pocketbook.core :as pb] [pocketbook.store.idb :as idb] [pocketbook.hiccup :refer [html]] [promesa.core :as p] [clojure.string :as str])) ;; --------------------------------------------------------------------------- ;; State ;; --------------------------------------------------------------------------- (defonce !conn (atom nil)) (defonce !todos (atom nil)) ;; the SyncedAtom (defonce !filter (atom :all)) ;; :all | :active | :completed (defonce !editing (atom nil)) ;; id of todo being edited, or nil ;; --------------------------------------------------------------------------- ;; Helpers ;; --------------------------------------------------------------------------- (defn- todos-list "Return todos as sorted vec of [id doc] pairs." [] (->> @@!todos (sort-by (fn [[_ doc]] (:created doc))) vec)) (defn- active-todos [] (remove (fn [[_ doc]] (:completed doc)) (todos-list))) (defn- completed-todos [] (filter (fn [[_ doc]] (:completed doc)) (todos-list))) (defn- visible-todos [] (case @!filter :all (todos-list) :active (active-todos) :completed (completed-todos))) (defn- all-completed? [] (let [ts (todos-list)] (and (seq ts) (every? (fn [[_ doc]] (:completed doc)) ts)))) ;; --------------------------------------------------------------------------- ;; Actions ;; --------------------------------------------------------------------------- (defn- add-todo! [text] (let [text (str/trim text)] (when (seq text) (let [id (str "todo:" (random-uuid))] (swap! @!todos assoc id {:text text :completed false :created (.now js/Date)}))))) (defn- toggle-todo! [id] (swap! @!todos update-in [id :completed] not)) (defn- toggle-all! [] (let [target (not (all-completed?))] (swap! @!todos (fn [m] (reduce-kv (fn [acc k v] (assoc acc k (assoc v :completed target))) {} m))))) (defn- destroy-todo! [id] (swap! @!todos dissoc id)) (defn- edit-todo! [id new-text] (let [text (str/trim new-text)] (if (seq text) (swap! @!todos assoc-in [id :text] text) (destroy-todo! id)) (reset! !editing nil))) (defn- clear-completed! [] (swap! @!todos (fn [m] (into {} (remove (fn [[_ v]] (:completed v))) m)))) ;; --------------------------------------------------------------------------- ;; Rendering ;; --------------------------------------------------------------------------- (defn- render-todo-item [[id doc]] (let [editing? (= id @!editing)] [:li {:class (str "todo-item" (when (:completed doc) " completed") (when editing? " editing")) :data-id id} [:div.view [:button {:class "toggle" :data-action "toggle" :data-id id} (if (:completed doc) "◉" "○")] [:label {:class "todo-label" :data-action "edit-start" :data-id id} (:text doc)] [:button {:class "destroy" :data-action "destroy" :data-id id} "×"]] (when editing? [:input {:class "edit-input" :data-action "edit-input" :data-id id :value (:text doc)}])])) (defn- filter-btn [label filter-kw current] [:button {:class (str "filter-btn" (when (= filter-kw current) " selected")) :data-action "filter" :data-filter (name filter-kw)} label]) (defn- render-footer [active-count total-count] (let [current @!filter] [:footer.app-footer [:span.todo-count [:strong active-count] " " (if (= 1 active-count) "item" "items") " left"] [:nav.filters (filter-btn "All" :all current) (filter-btn "Active" :active current) (filter-btn "Completed" :completed current)] (when (pos? (- total-count active-count)) [:button {:class "clear-completed" :data-action "clear-completed"} "Clear completed"])])) (defn- render-sync-status [] (let [pending (when @!todos (pb/pending-count @!todos)) online? (.-onLine js/navigator)] [:div.sync-bar [:span {:class (str "sync-dot " (if online? "online" "offline"))}] [:span.sync-text (cond (not online?) "Offline — changes saved locally" (and pending (pos? pending)) (str "Syncing " pending " change" (when (> pending 1) "s") "…") :else "Synced")]])) (defn- render! [] (let [container (js/document.getElementById "app") todos (visible-todos) total (count (todos-list)) active (count (active-todos))] (when container (set! (.-innerHTML container) (html [:div [:header.app-header [:h1 "todos"] [:div.input-row (when (pos? total) [:button {:class (str "toggle-all" (when (all-completed?) " checked")) :data-action "toggle-all"} "❯"]) [:input {:id "new-todo" :class "new-todo" :placeholder "What needs to be done?" :autofocus true}]]] (when (pos? total) [:section.main [:ul.todo-list (map render-todo-item todos)] (render-footer active total)]) (render-sync-status)]))))) ;; --------------------------------------------------------------------------- ;; Event delegation ;; --------------------------------------------------------------------------- (defn- find-action "Walk up from target to find an element with data-action." [el] (loop [node el] (when (and node (not= node js/document.body)) (if-let [action (.. node -dataset -action)] [action node] (recur (.-parentElement node)))))) (defn- handle-click [e] (when-let [[action el] (find-action (.-target e))] (let [id (.. el -dataset -id)] (case action "toggle" (toggle-todo! id) "destroy" (destroy-todo! id) "toggle-all" (toggle-all!) "clear-completed" (clear-completed!) "filter" (reset! !filter (keyword (.. el -dataset -filter))) nil)))) (defn- handle-dblclick [e] (when-let [[action el] (find-action (.-target e))] (when (= action "edit-start") (let [id (.. el -dataset -id)] (reset! !editing id) (render!) ;; Focus the edit input after render (js/requestAnimationFrame (fn [] (when-let [input (.querySelector js/document ".edit-input")] (.focus input) ;; Move cursor to end (let [len (.-length (.-value input))] (.setSelectionRange input len len))))))))) (defn- handle-keydown [e] (let [key (.-key e)] (cond ;; Enter on new-todo input (and (= key "Enter") (= "new-todo" (.. e -target -id))) (let [input (.-target e)] (add-todo! (.-value input)) (set! (.-value input) "")) ;; Enter on edit input (and (= key "Enter") (.. e -target -dataset -action) (= "edit-input" (.. e -target -dataset -action))) (let [el (.-target e)] (edit-todo! (.. el -dataset -id) (.-value el))) ;; Escape cancels edit (and (= key "Escape") @!editing) (do (reset! !editing nil) (render!))))) (defn- handle-blur [e] (when (and @!editing (.. e -target -dataset -action) (= "edit-input" (.. e -target -dataset -action))) (let [el (.-target e)] (edit-todo! (.. el -dataset -id) (.-value el))))) (defn- bind-events! [] (let [app (js/document.getElementById "app")] (.addEventListener app "click" handle-click) (.addEventListener app "dblclick" handle-dblclick) (.addEventListener js/document "keydown" handle-keydown) (.addEventListener app "focusout" handle-blur true))) ;; --------------------------------------------------------------------------- ;; Init ;; --------------------------------------------------------------------------- (defn ^:export init [] (p/let [store (idb/open "pocketbook-todomvc")] (let [todos (pb/synced-atom store "todo" {:server "http://localhost:8090/sync" :interval 15000})] (reset! !conn store) (reset! !todos todos) ;; Render + bind immediately (empty or stale is fine) (render!) (bind-events!) ;; Re-render on any data change (fires when IDB loads + server syncs) (add-watch todos :render (fn [_ _ _ _] (render!))) (add-watch !filter :render (fn [_ _ _ _] (render!))) (.addEventListener js/window "online" (fn [_] (render!))) (.addEventListener js/window "offline" (fn [_] (render!))) ;; Wait for IDB — watch triggers render automatically (p/let [_ (pb/ready? todos)] (js/console.log "🔶 Pocketbook TodoMVC loaded —" (count @todos) "todos"))))) (init)