3 コミット 043570b1c8 ... 4cb9c77425

作者 SHA1 メッセージ 日付
  Brandon Wong 4cb9c77425 Add local-storage table persistence with save/rename/delete (todo 11.1) 1 ヶ月 前
  Brandon Wong c1eba663b9 updated to-do file 1 ヶ月 前
  Brandon Wong 7d9e58ba34 Add toggleable sum/average/both modes to the totals row (todo 7.2) 1 ヶ月 前

File diff suppressed because it is too large
+ 1 - 1
CONTEXT.md


+ 210 - 0
frontend/resources/public/site.css

@@ -135,6 +135,22 @@ td, th {
     color: #b00020;
 }
 
+/* Totals row header cell: click to cycle sum / average / both */
+#main-table .totals-row th.totals-toggle {
+    cursor: pointer;
+}
+#main-table .totals-row th.totals-toggle:hover {
+    background: #dde5f0;
+}
+
+/* "Both" mode: sum and average stacked in smaller font */
+.totals-row--both td {
+    font-size: 0.75em;
+}
+.totals-row--both .totals-value {
+    line-height: 1.3;
+}
+
 th {
     text-align: center;
     min-width: 40px;
@@ -239,6 +255,200 @@ td input:not(:focus) {
     background: #e0e0e0;
 }
 
+#active-table-name {
+    padding: 0 12px;
+    font-size: 0.85em;
+    color: #555;
+    white-space: nowrap;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    max-width: 140px;
+}
+
+/* ── Table modal (todo 11.1) ── */
+#table-modal-overlay {
+    position: fixed;
+    inset: 0;
+    background: rgba(0, 0, 0, 0.45);
+    z-index: 1000;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    padding: 16px;
+}
+#table-modal {
+    background: white;
+    border-radius: 8px;
+    padding: 24px;
+    max-width: 480px;
+    width: 100%;
+    max-height: 80vh;
+    overflow-y: auto;
+    box-shadow: 0 4px 24px rgba(0,0,0,0.2);
+}
+
+.table-modal-tabs {
+    display: flex;
+    border-bottom: 1px solid #e0e0e0;
+    margin-bottom: 12px;
+}
+.table-tab {
+    padding: 8px 16px;
+    border: none;
+    background: none;
+    cursor: pointer;
+    font-size: 0.95em;
+    color: #666;
+    border-bottom: 2px solid transparent;
+}
+.table-tab--active {
+    color: #333;
+    border-bottom-color: #09f;
+    font-weight: 600;
+}
+
+.table-modal-empty {
+    color: #777;
+}
+
+.table-list {
+    display: flex;
+    flex-direction: column;
+    gap: 4px;
+}
+
+.table-row {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    border: 1px solid #eee;
+    border-radius: 6px;
+    padding: 8px 10px;
+}
+.table-row--active {
+    background: #eef6ff;
+    border-color: #bcdcff;
+}
+.table-row-main {
+    flex: 1;
+    cursor: pointer;
+    min-width: 0;
+}
+.table-row-name {
+    font-weight: 500;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+}
+.table-row-meta {
+    font-size: 0.75em;
+    color: #888;
+}
+.table-row-menu-wrap {
+    position: relative;
+    flex-shrink: 0;
+}
+.icon-btn {
+    border: none;
+    background: none;
+    cursor: pointer;
+    font-size: 1.1em;
+    padding: 4px 8px;
+    color: #555;
+}
+.icon-btn:hover {
+    background: #eee;
+    border-radius: 4px;
+}
+.table-row-menu {
+    position: absolute;
+    right: 0;
+    top: 100%;
+    background: white;
+    border: 1px solid #ccc;
+    border-radius: 6px;
+    box-shadow: 0 2px 8px rgba(0,0,0,0.15);
+    display: flex;
+    flex-direction: column;
+    z-index: 1001;
+    min-width: 100px;
+}
+.table-row-menu button {
+    border: none;
+    background: none;
+    text-align: left;
+    padding: 8px 12px;
+    cursor: pointer;
+}
+.table-row-menu button:hover {
+    background: #f0f0f0;
+}
+.table-row-edit {
+    display: flex;
+    align-items: center;
+    gap: 4px;
+    width: 100%;
+}
+.table-row-edit input {
+    flex: 1;
+    padding: 6px 8px;
+    border: 1px solid #ccc;
+    border-radius: 4px;
+}
+
+.table-modal-help p {
+    margin: 0 0 12px;
+}
+
+.table-modal-close {
+    margin-top: 16px;
+    padding: 8px 20px;
+    border: 1px solid #ccc;
+    border-radius: 4px;
+    background: #f5f5f5;
+    cursor: pointer;
+}
+.table-modal-close:hover {
+    background: #e0e0e0;
+}
+
+/* ── Delete confirmation modal ── */
+#table-delete-overlay {
+    position: fixed;
+    inset: 0;
+    background: rgba(0, 0, 0, 0.55);
+    z-index: 1100;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    padding: 16px;
+}
+#table-delete-modal {
+    background: white;
+    border-radius: 8px;
+    padding: 20px;
+    max-width: 360px;
+    width: 100%;
+    box-shadow: 0 4px 24px rgba(0,0,0,0.25);
+}
+#table-delete-modal button {
+    margin-top: 12px;
+    margin-right: 8px;
+    padding: 8px 16px;
+    border: 1px solid #ccc;
+    border-radius: 4px;
+    background: #f5f5f5;
+    cursor: pointer;
+}
+#table-delete-modal button.danger {
+    background: #d32f2f;
+    color: white;
+    border-color: #d32f2f;
+}
+#table-delete-modal button:hover {
+    filter: brightness(0.95);
+}
+
 
 .smartborder {
     width: calc(100% + 2px);

+ 17 - 1
frontend/src/cljs/microtables_frontend/core.cljs

@@ -2,6 +2,7 @@
   (:require
    [microtables-frontend.config :as config]
    [microtables-frontend.events :as events]
+   [microtables-frontend.utils.storage :as storage]
    [microtables-frontend.views :as views]
    [re-frame.core :as re-frame]
    [reagent.core :as reagent]))
@@ -15,7 +16,22 @@
   (reagent/render [views/main-panel]
                   (.getElementById js/document "app")))
 
+(defn- most-recent-table
+  "Reads the most-recently-opened saved table (id/name/table-data) from
+  localStorage, or nil if none exists, storage is unavailable (e.g. some
+  incognito modes), or the stored data is corrupt."
+  []
+  (try
+    (let [index (storage/deserialize (.getItem js/localStorage storage/index-key))
+          latest (first (storage/sort-by-recency (or index [])))]
+      (when latest
+        (when-let [data (storage/deserialize (.getItem js/localStorage (storage/table-key (:id latest))))]
+          {:id (:id latest)
+           :name (:name latest)
+           :table-data (:table-data data)})))
+    (catch js/Error _ nil)))
+
 (defn init []
-  (re-frame/dispatch-sync [::events/initialize-db])
+  (re-frame/dispatch-sync [::events/initialize-db (most-recent-table)])
   (dev-setup)
   (mount-root))

+ 8 - 0
frontend/src/cljs/microtables_frontend/db.cljs

@@ -8,10 +8,18 @@
 
 (def default-db
   {:controls nil
+   :totals-mode :sum
    ;TODO: add "start" and "end" corners as selection
    :position {:cursor nil
               :selection nil #_{:start {:col "A" :row 5}
                                 :end {:col "C" :row 8}}}
+   :active-table {:id nil :name nil}
+   :table-modal {:open? false
+                 :tab :tables
+                 :menu-open nil
+                 :editing nil
+                 :pending-delete nil
+                 :tables []}
    :table-data nil #_{"A" {1 {:value "59"}
                            12 {:value "2405"}}
                       "B" {4 {:value "7893"}

+ 208 - 18
frontend/src/cljs/microtables_frontend/events.cljs

@@ -1,25 +1,58 @@
 (ns microtables-frontend.events
   (:require
+   [clojure.string :as string]
    [microtables-frontend.db :as db]
    [microtables-frontend.evaluation :as eval]
    [microtables-frontend.utils.coordinates :as coords]
    [microtables-frontend.utils.data :as data-utils]
+   [microtables-frontend.utils.storage :as storage]
    [re-frame.core :as re-frame]))
 
+(defn rebuild-table-data
+  "Rebuilds :refs/:inbound/:dirty/:display for a table-data map from :value alone.
+  Used both at boot and when opening a saved table, so persisted state never needs
+  to carry (or trust) the derived fields - only :value round-trips through storage."
+  [table-data]
+  (-> table-data
+      (data-utils/walk-modify-data
+       (fn [_c _r datum]
+         (if (data-utils/formula? (:value datum))
+           (assoc datum :dirty true)
+           datum)))
+      data-utils/create-all-references
+      eval/create-all-back-references
+      eval/evaluate-all))
+
+(defn- fresh-active-table
+  []
+  {:id (storage/new-table-id) :name (storage/default-table-name)})
+
+; The read side of local storage: kept here (rather than as reg-cofx, which this
+; app has no other precedent for) since these are simple one-shot reads, matching
+; the existing reg-fx-only style used for :focus-on-cell below.
+(defn- read-index
+  []
+  (or (storage/deserialize (.getItem js/localStorage storage/index-key)) []))
+
+(defn- write-index!
+  [index]
+  (.setItem js/localStorage storage/index-key (storage/serialize index)))
+
+(defn- read-table-data
+  [id]
+  (:table-data (storage/deserialize (.getItem js/localStorage (storage/table-key id)))))
+
 (re-frame/reg-event-db
  ::initialize-db
- (fn [_ _]
+ (fn [_ [_ restored]]
    (println "initializing db")
-   (-> db/default-db
-       (update-in [:table-data] #(data-utils/walk-modify-data
-                                  %
-                                  (fn [_c _r datum]
-                                    (if (= (first (:value datum)) "=")
-                                      (assoc datum :dirty true)
-                                      datum))))
-       (update-in [:table-data] data-utils/create-all-references)
-       (update-in [:table-data] eval/create-all-back-references)
-       (update-in [:table-data] eval/evaluate-all))))
+   (if restored
+     (-> db/default-db
+         (assoc :table-data (rebuild-table-data (:table-data restored)))
+         (assoc :active-table {:id (:id restored) :name (:name restored)}))
+     (-> db/default-db
+         (update :table-data rebuild-table-data)
+         (assoc :active-table (fresh-active-table))))))
 
 (re-frame/reg-event-db
  ::movement-enter-cell
@@ -27,15 +60,17 @@
    (println "::movement-enter-cell" c r)
    (assoc-in db [:position :cursor] {:col c :row r})))
 
-(re-frame/reg-event-db
+(re-frame/reg-event-fx
  ::movement-leave-cell
- (fn [db [_ c r]]
+ (fn [{:keys [db]} [_ c r]]
    (println "::movement-leave-cell" c r)
-   (-> db
-       (assoc-in [:position :cursor] nil)
-       (assoc-in [:position :selection] nil)
-       (update-in [:table-data] #(eval/reset-references % c r))
-       (update-in [:table-data] #(eval/evaluate-from-cell % c r)))))
+   (let [new-db (-> db
+                     (assoc-in [:position :cursor] nil)
+                     (assoc-in [:position :selection] nil)
+                     (update-in [:table-data] #(eval/reset-references % c r))
+                     (update-in [:table-data] #(eval/evaluate-from-cell % c r)))]
+     {:db new-db
+      :save-table! {:table (:active-table new-db) :table-data (:table-data new-db)}})))
 
 (re-frame/reg-event-db
  ::edit-cell-value
@@ -87,3 +122,158 @@
  (fn [db [_ new-state]]
    (println "::set-controls-state" new-state)
    (assoc-in db [:controls] new-state)))
+
+(def totals-mode-cycle
+  {:sum :average, :average :both, :both :sum})
+
+(re-frame/reg-event-db
+ ::cycle-totals-mode
+ (fn [db _]
+   (update db :totals-mode totals-mode-cycle)))
+
+;; ── Local storage table persistence (todo 11.1) ──
+
+; Writes a table's data (skipping tables that have never been saved and are
+; still empty) and touches its index entry's :last-opened-at. Registered under
+; two effect keys (:save-table! and :touch-table!) so a single event can fire
+; it twice with different args - this re-frame version has no built-in :fx
+; vector-of-effects to repeat one key.
+(defn- save-table-fx!
+  [{:keys [table table-data]}]
+  (let [index (read-index)
+        already-saved? (some #(= (:id %) (:id table)) index)]
+    (when (or already-saved? (not (storage/table-empty? table-data)))
+      (write-index! (storage/save-entry index (:id table) (:name table) (storage/now-ms)))
+      (.setItem js/localStorage (storage/table-key (:id table)) (storage/serialize {:table-data table-data})))))
+
+(re-frame/reg-fx :save-table! save-table-fx!)
+(re-frame/reg-fx :touch-table! save-table-fx!)
+
+(re-frame/reg-fx
+ :save-tables-index!
+ (fn [index]
+   (write-index! index)))
+
+(re-frame/reg-fx
+ :remove-table!
+ (fn [id]
+   (.removeItem js/localStorage (storage/table-key id))
+   (write-index! (storage/remove-entry (read-index) id))))
+
+(re-frame/reg-event-db
+ ::open-table-modal
+ (fn [db _]
+   (-> db
+       (assoc-in [:table-modal :open?] true)
+       (assoc-in [:table-modal :tab] :tables)
+       (assoc-in [:table-modal :tables] (storage/sort-by-recency (read-index))))))
+
+(re-frame/reg-event-db
+ ::close-table-modal
+ (fn [db _]
+   (-> db
+       (assoc-in [:table-modal :open?] false)
+       (assoc-in [:table-modal :menu-open] nil)
+       (assoc-in [:table-modal :editing] nil)
+       (assoc-in [:table-modal :pending-delete] nil))))
+
+(re-frame/reg-event-db
+ ::set-table-modal-tab
+ (fn [db [_ tab]]
+   (assoc-in db [:table-modal :tab] tab)))
+
+(re-frame/reg-event-db
+ ::toggle-table-menu
+ (fn [db [_ table-id]]
+   (update-in db [:table-modal :menu-open] #(if (= % table-id) nil table-id))))
+
+(re-frame/reg-event-fx
+ ::new-table
+ (fn [{:keys [db]} _]
+   {:db (assoc db
+               :table-data nil
+               :position (:position db/default-db)
+               :active-table (fresh-active-table))
+    :save-table! {:table (:active-table db) :table-data (:table-data db)}}))
+
+(re-frame/reg-event-fx
+ ::switch-to-table
+ (fn [{:keys [db]} [_ table-id]]
+   (let [entries (get-in db [:table-modal :tables])
+         entry (first (filter #(= (:id %) table-id) entries))
+         restored (read-table-data table-id)
+         rebuilt (rebuild-table-data restored)
+         new-active {:id table-id :name (:name entry)}]
+     {:db (-> db
+              (assoc :table-data rebuilt)
+              (assoc :position (:position db/default-db))
+              (assoc :active-table new-active)
+              (assoc-in [:table-modal :open?] false)
+              (assoc-in [:table-modal :menu-open] nil))
+      ; save the outgoing table (if it warrants saving), then touch the incoming
+      ; table's :last-opened-at so it sorts to the top next time the modal opens
+      ; (this re-frame version has no built-in :fx vector-of-effects, so these
+      ; are two distinct effect keys sharing one handler, not one repeated key)
+      :save-table! {:table (:active-table db) :table-data (:table-data db)}
+      :touch-table! {:table new-active :table-data restored}})))
+
+(re-frame/reg-event-db
+ ::start-rename-table
+ (fn [db [_ table-id current-name]]
+   (-> db
+       (assoc-in [:table-modal :editing] {:id table-id :value current-name})
+       (assoc-in [:table-modal :menu-open] nil))))
+
+(re-frame/reg-event-db
+ ::update-rename-value
+ (fn [db [_ value]]
+   (assoc-in db [:table-modal :editing :value] value)))
+
+(re-frame/reg-event-db
+ ::cancel-rename-table
+ (fn [db _]
+   (assoc-in db [:table-modal :editing] nil)))
+
+(re-frame/reg-event-fx
+ ::confirm-rename-table
+ (fn [{:keys [db]} _]
+   (let [{:keys [id value]} (get-in db [:table-modal :editing])
+         trimmed (string/trim (or value ""))]
+     (if (or (nil? id) (string/blank? trimmed))
+       {:db (assoc-in db [:table-modal :editing] nil)}
+       (let [updated-tables (storage/rename-entry (get-in db [:table-modal :tables]) id trimmed)
+             active? (= id (get-in db [:active-table :id]))
+             new-db (cond-> (-> db
+                                 (assoc-in [:table-modal :tables] updated-tables)
+                                 (assoc-in [:table-modal :editing] nil))
+                       active? (assoc-in [:active-table :name] trimmed))]
+         {:db new-db
+          :save-tables-index! updated-tables})))))
+
+(re-frame/reg-event-db
+ ::request-delete-table
+ (fn [db [_ table-id]]
+   (-> db
+       (assoc-in [:table-modal :pending-delete] table-id)
+       (assoc-in [:table-modal :menu-open] nil))))
+
+(re-frame/reg-event-db
+ ::cancel-delete-table
+ (fn [db _]
+   (assoc-in db [:table-modal :pending-delete] nil)))
+
+(re-frame/reg-event-fx
+ ::confirm-delete-table
+ (fn [{:keys [db]} [_ table-id]]
+   (let [was-active? (= table-id (get-in db [:active-table :id]))
+         updated-tables (storage/remove-entry (get-in db [:table-modal :tables]) table-id)
+         base-db (-> db
+                     (assoc-in [:table-modal :tables] updated-tables)
+                     (assoc-in [:table-modal :pending-delete] nil))]
+     {:db (if was-active?
+            (assoc base-db
+                   :table-data nil
+                   :position (:position db/default-db)
+                   :active-table (fresh-active-table))
+            base-db)
+      :remove-table! table-id})))

+ 63 - 6
frontend/src/cljs/microtables_frontend/subs.cljs

@@ -56,9 +56,9 @@
         (if (js/isNaN n) nil n))
       :else nil)))
 
-(defn column-total
-  "Sums the numeric cells in a column (a {row-num datum} map).
-  Returns a number, :error (if any cell is in an error state), or nil (no numeric cells)."
+(defn column-aggregate
+  "Aggregates the numeric cells in a column (a {row-num datum} map).
+  Returns {:sum n :count n}, :error (if any cell is in an error state), or nil (no numeric cells)."
   [column]
   (reduce (fn [acc [_ datum]]
             (if (= acc :error)
@@ -66,7 +66,9 @@
               (let [v (numeric-cell-value datum)]
                 (cond
                   (= v :error) :error
-                  (number? v) (+ (or acc 0) v)
+                  (number? v) (-> (or acc {:sum 0 :count 0})
+                                  (update :sum + v)
+                                  (update :count inc))
                   :else acc))))
           nil
           column))
@@ -76,13 +78,68 @@
   [n]
   (js/parseFloat (.toFixed n 10)))
 
+(defn- finalize-aggregate
+  "Turns a column-aggregate result into {:sum n :avg n} for display, passing :error/nil through unchanged."
+  [agg]
+  (cond
+    (= agg :error) :error
+    (map? agg) {:sum (round-for-display (:sum agg))
+                :avg (round-for-display (/ (:sum agg) (:count agg)))}
+    :else nil))
+
 (re-frame/reg-sub
  ::totals-row
  (fn [db]
    (reduce-kv (fn [acc col rows]
-                (let [total (column-total rows)]
-                  (assoc acc col (if (number? total) (round-for-display total) total))))
+                (assoc acc col (finalize-aggregate (column-aggregate rows))))
               {}
               (:table-data db))))
 
+(re-frame/reg-sub
+ ::totals-mode
+ (fn [db]
+   (:totals-mode db :sum)))
+
+;; ── Local storage table persistence (todo 11.1) ──
+
+(re-frame/reg-sub
+ ::active-table-id
+ (fn [db]
+   (get-in db [:active-table :id])))
+
+(re-frame/reg-sub
+ ::active-table-name
+ (fn [db]
+   (get-in db [:active-table :name])))
+
+(re-frame/reg-sub
+ ::table-modal-open?
+ (fn [db]
+   (get-in db [:table-modal :open?] false)))
+
+(re-frame/reg-sub
+ ::table-modal-tab
+ (fn [db]
+   (get-in db [:table-modal :tab] :tables)))
+
+(re-frame/reg-sub
+ ::table-list
+ (fn [db]
+   (get-in db [:table-modal :tables] [])))
+
+(re-frame/reg-sub
+ ::table-menu-open
+ (fn [db]
+   (get-in db [:table-modal :menu-open])))
+
+(re-frame/reg-sub
+ ::table-editing
+ (fn [db]
+   (get-in db [:table-modal :editing])))
+
+(re-frame/reg-sub
+ ::table-pending-delete
+ (fn [db]
+   (get-in db [:table-modal :pending-delete])))
+
 

+ 72 - 0
frontend/src/cljs/microtables_frontend/utils/storage.cljs

@@ -0,0 +1,72 @@
+(ns microtables-frontend.utils.storage
+  (:require
+   [cljs.reader :as reader]
+   [clojure.string :as string]))
+
+; Pure helpers for the local-storage-backed table list (todo 11.1).
+; No js/localStorage calls live here, so all of this is testable under :node-test;
+; the actual .getItem/.setItem/.removeItem calls live in events.cljs's reg-fx handlers.
+
+(def index-key "microtables/tables-index")
+
+(defn table-key
+  [id]
+  (str "microtables/table/" id))
+
+(defn new-table-id
+  []
+  (str (.getTime (js/Date.)) "-" (subs (.toString (js/Math.random) 36) 2 8)))
+
+(defn now-ms
+  []
+  (.getTime (js/Date.)))
+
+(defn- pad2
+  [n]
+  (if (< n 10) (str "0" n) (str n)))
+
+(defn default-table-name
+  []
+  (let [d (js/Date.)]
+    (str (.getFullYear d) "-" (pad2 (inc (.getMonth d))) "-" (pad2 (.getDate d))
+         " " (pad2 (.getHours d)) ":" (pad2 (.getMinutes d)))))
+
+(defn table-empty?
+  "True if a table-data map is nil, or every cell in it has a blank :value."
+  [table-data]
+  (or (nil? table-data)
+      (every? (fn [[_col rows]]
+                (every? (fn [[_row datum]] (string/blank? (:value datum ""))) rows))
+              table-data)))
+
+(defn serialize
+  [data]
+  (pr-str data))
+
+(defn deserialize
+  "Reads an edn string, returning nil instead of throwing on missing/corrupt input."
+  [s]
+  (when s
+    (try
+      (reader/read-string s)
+      (catch js/Error _ nil))))
+
+(defn rename-entry
+  [index id new-name]
+  (mapv (fn [entry] (if (= (:id entry) id) (assoc entry :name new-name) entry)) index))
+
+(defn remove-entry
+  [index id]
+  (vec (remove #(= (:id %) id) index)))
+
+(defn save-entry
+  "Upserts the index entry for id/name at the given timestamp, preserving :created-at if the entry already exists."
+  [index id name timestamp]
+  (let [existing (first (filter #(= (:id %) id) index))
+        created-at (or (:created-at existing) timestamp)
+        entry {:id id :name name :created-at created-at :last-opened-at timestamp}]
+    (conj (vec (remove #(= (:id %) id) index)) entry)))
+
+(defn sort-by-recency
+  [index]
+  (vec (sort-by :last-opened-at > index)))

+ 20 - 5
frontend/src/cljs/microtables_frontend/views.cljs

@@ -3,14 +3,29 @@
    [microtables-frontend.subs :as subs]
    [microtables-frontend.views.control-panel :refer [control-panel]]
    [microtables-frontend.views.sheet :refer [sheet]]
+   [microtables-frontend.views.table-modal :refer [table-modal]]
    [re-frame.core :as re-frame]))
 
 (defn main-panel []
   (let [data (re-frame/subscribe [::subs/table-data])
         totals (re-frame/subscribe [::subs/totals-row])
-        controls-state (re-frame/subscribe [::subs/controls-state])]
+        totals-mode (re-frame/subscribe [::subs/totals-mode])
+        controls-state (re-frame/subscribe [::subs/controls-state])
+        active-table-name (re-frame/subscribe [::subs/active-table-name])
+        active-table-id (re-frame/subscribe [::subs/active-table-id])
+        table-modal-open? (re-frame/subscribe [::subs/table-modal-open?])
+        table-modal-tab (re-frame/subscribe [::subs/table-modal-tab])
+        table-list (re-frame/subscribe [::subs/table-list])
+        table-menu-open (re-frame/subscribe [::subs/table-menu-open])
+        table-editing (re-frame/subscribe [::subs/table-editing])
+        table-pending-delete (re-frame/subscribe [::subs/table-pending-delete])]
     [:div#main-layout
-     [:div.sheet-container [sheet @data @totals]]
-     [control-panel @controls-state]]))
-
-
+     [:div.sheet-container [sheet @data @totals @totals-mode]]
+     [control-panel @controls-state @active-table-name]
+     [table-modal {:open? @table-modal-open?
+                   :tab @table-modal-tab
+                   :tables @table-list
+                   :active-id @active-table-id
+                   :menu-open @table-menu-open
+                   :editing @table-editing
+                   :pending-delete @table-pending-delete}]]))

+ 10 - 2
frontend/src/cljs/microtables_frontend/views/control_panel.cljs

@@ -8,15 +8,23 @@
    {:on-click #(re-frame/dispatch [::events/set-controls-state nil])}
    [:div#about-modal
     {:on-click #(.stopPropagation %)}
-    [:p "This is a demo version only, and still in development. Nothing gets saved for the moment."]
+    [:p "Your tables are saved automatically to this browser. Use \"New Table\" to start a fresh one, or \"Open Table\" to switch to a saved one (see its Help tab for details and caveats)."]
     [:p "Try adding values (plain numbers) or formulae (ex: \"=B2 + sum(A1:A6)\") into the cells."]
     [:p "Created by "
      [:a {:href "https://betweentwocommits.com/about" :target "_blank"} "Brandon Wong"]]
     [:button {:on-click #(re-frame/dispatch [::events/set-controls-state nil])} "Close"]]])
 
-(defn control-panel [state]
+(defn control-panel [state active-table-name]
   [:div#control-bar
    [:div#bar-logo [:img {:src "logo.svg" :alt "Microtables"}]]
+   (when active-table-name
+     [:div#active-table-name active-table-name])
+   [:button.bar-btn
+    {:on-click #(re-frame/dispatch [::events/new-table])}
+    "New Table"]
+   [:button.bar-btn
+    {:on-click #(re-frame/dispatch [::events/open-table-modal])}
+    "Open Table"]
    [:button.bar-btn
     {:on-click #(re-frame/dispatch [::events/set-controls-state :about])}
     "About"]

+ 23 - 12
frontend/src/cljs/microtables_frontend/views/sheet.cljs

@@ -48,22 +48,33 @@
                      ^{:key "corner"} [:th]
                      (map (fn [c] ^{:key (str "col-head-" c)} [:th c]) cols))])
 
-(defn totals-cell [c totals]
-  (let [total (get totals c)]
+(defn totals-cell [c totals mode]
+  (let [agg (get totals c)]
     ^{:key (str "totals-" c)}
-    [:td {:class (when (= total :error) "totals-error")}
+    [:td {:class (when (= agg :error) "totals-error")}
      (cond
-       (= total :error) "Error"
-       (number? total) (str total)
-       :else "")]))
+       (= agg :error) "Error"
+       (nil? agg) ""
+       (= mode :both) [:<>
+                       [:div.totals-value (str (:sum agg))]
+                       [:div.totals-value (str (:avg agg))]]
+       (= mode :average) (str (:avg agg))
+       :else (str (:sum agg)))]))
 
-(defn totals-row [cols totals]
-  ^{:key "totals"} [:tr.totals-row
+(def totals-mode-symbol
+  {:sum "Σ" :average "x̄" :both "Σ / x̄"})
+
+(defn totals-row [cols totals mode]
+  ^{:key "totals"} [:tr.totals-row {:class (when (= mode :both) "totals-row--both")}
                     (cons
-                     ^{:key "totals-corner"} [:th "Σ"]
-                     (map #(totals-cell % totals) cols))])
+                     ^{:key "totals-corner"}
+                     [:th.totals-toggle
+                      {:on-click #(re-frame/dispatch [::events/cycle-totals-mode])
+                       :title "Click to cycle sum / average / both"}
+                      (totals-mode-symbol mode)]
+                     (map #(totals-cell % totals mode) cols))])
 
-(defn sheet [data totals]
+(defn sheet [data totals mode]
   [:table
    {:id "main-table"}
    [:tbody
@@ -75,4 +86,4 @@
       (concat
        [(header-row cols)]
        (map #(row % cols maxrow data) (range 1 (inc maxrow)))
-       [(totals-row cols totals)]))]])
+       [(totals-row cols totals mode)]))]])

+ 89 - 0
frontend/src/cljs/microtables_frontend/views/table_modal.cljs

@@ -0,0 +1,89 @@
+(ns microtables-frontend.views.table-modal
+  (:require
+   [microtables-frontend.events :as events]
+   [re-frame.core :as re-frame]))
+
+(defn- format-opened
+  [ms]
+  (if ms (.toLocaleString (js/Date. ms)) ""))
+
+(defn- table-row
+  [{:keys [id name last-opened-at]} active-id menu-open editing]
+  (let [editing? (= (:id editing) id)]
+    ^{:key id}
+    [:div.table-row {:class (when (= id active-id) "table-row--active")}
+     (if editing?
+       [:div.table-row-edit
+        [:input {:auto-focus true
+                 :value (:value editing)
+                 :on-change #(re-frame/dispatch [::events/update-rename-value (.. % -target -value)])
+                 :on-key-down #(case (.-key %)
+                                 "Enter" (re-frame/dispatch [::events/confirm-rename-table])
+                                 "Escape" (re-frame/dispatch [::events/cancel-rename-table])
+                                 nil)}]
+        [:button.icon-btn {:on-click #(re-frame/dispatch [::events/confirm-rename-table])} "✓"]
+        [:button.icon-btn {:on-click #(re-frame/dispatch [::events/cancel-rename-table])} "✕"]]
+       [:<>
+        [:div.table-row-main
+         {:on-click #(re-frame/dispatch [::events/switch-to-table id])}
+         [:div.table-row-name name]
+         [:div.table-row-meta (str "Opened " (format-opened last-opened-at))]]
+        [:div.table-row-menu-wrap
+         [:button.icon-btn
+          {:on-click #(do (.stopPropagation %)
+                          (re-frame/dispatch [::events/toggle-table-menu id]))}
+          "⋮"]
+         (when (= menu-open id)
+           [:div.table-row-menu
+            {:on-click #(.stopPropagation %)}
+            [:button {:on-click #(re-frame/dispatch [::events/start-rename-table id name])} "Rename"]
+            [:button {:on-click #(re-frame/dispatch [::events/request-delete-table id])} "Delete"]])]])]))
+
+(defn- tables-tab
+  [tables active-id menu-open editing]
+  (if (empty? tables)
+    [:p.table-modal-empty "No saved tables yet."]
+    [:div.table-list
+     (for [t tables] (table-row t active-id menu-open editing))]))
+
+(defn- help-tab
+  []
+  [:div.table-modal-help
+   [:p "Tables are saved automatically to this browser as you edit them (when you leave a cell)."]
+   [:p "Use \"New Table\" to start a fresh table, and \"Open Table\" to come back to this list. Click a table to open it, or use the "
+    [:strong "⋮"] " menu on a row to rename or delete it."]
+   [:p "Caveats: tables are stored only in this browser via localStorage. They will disappear if you (or your browser) clear this site's data, and they do not sync between browsers or devices. In a private/incognito window, saved tables may not persist once the window is closed."]])
+
+(defn- delete-confirm-modal
+  [table-id tables]
+  (let [name (:name (first (filter #(= (:id %) table-id) tables)))]
+    [:div#table-delete-overlay
+     {:on-click #(re-frame/dispatch [::events/cancel-delete-table])}
+     [:div#table-delete-modal
+      {:on-click #(.stopPropagation %)}
+      [:p (str "Delete \"" name "\"? This can't be undone.")]
+      [:button {:on-click #(re-frame/dispatch [::events/cancel-delete-table])} "Cancel"]
+      [:button.danger {:on-click #(re-frame/dispatch [::events/confirm-delete-table table-id])} "Delete"]]]))
+
+(defn table-modal
+  [{:keys [open? tab tables active-id menu-open editing pending-delete]}]
+  (when open?
+    [:div#table-modal-overlay
+     {:on-click #(re-frame/dispatch [::events/close-table-modal])}
+     [:div#table-modal
+      {:on-click #(.stopPropagation %)}
+      [:div.table-modal-tabs
+       [:button.table-tab
+        {:class (when (= tab :tables) "table-tab--active")
+         :on-click #(re-frame/dispatch [::events/set-table-modal-tab :tables])}
+        "Tables"]
+       [:button.table-tab
+        {:class (when (= tab :help) "table-tab--active")
+         :on-click #(re-frame/dispatch [::events/set-table-modal-tab :help])}
+        "Help"]]
+      (if (= tab :help)
+        [help-tab]
+        [tables-tab tables active-id menu-open editing])
+      [:button.table-modal-close {:on-click #(re-frame/dispatch [::events/close-table-modal])} "Close"]
+      (when pending-delete
+        [delete-confirm-modal pending-delete tables])]]))

+ 13 - 0
frontend/test/microtables_frontend/events_test.cljs

@@ -2,6 +2,7 @@
   (:require
    [cljs.test :refer-macros [deftest testing is]]
    [microtables-frontend.evaluation :as evaluation]
+   [microtables-frontend.events :as events]
    [microtables-frontend.utils.data :as data-utils]))
 
 ; Tests for the state-transition pipelines that event handlers compose.
@@ -88,3 +89,15 @@
                     (evaluation/reset-references "A" 1)
                     (evaluation/evaluate-from-cell "A" 1))]
     (is (= 13 (get-in updated ["D" 1 :display])))))
+
+;; --- Restoring a saved table (todo 11.1) ---
+
+(deftest rebuild-table-data-matches-fresh-typing
+  ; A table restored from localStorage carries :value only. Rebuilding it via
+  ; events/rebuild-table-data should produce the same refs/inbound/display as
+  ; a table built by typing the same values into a fresh table.
+  (let [raw     {"A" {1 {:value "3"}}
+                 "B" {1 {:value "=A1*2"}}}
+        rebuilt (events/rebuild-table-data raw)
+        fresh   (initial-table raw)]
+    (is (= fresh rebuilt))))

+ 46 - 0
frontend/test/microtables_frontend/storage_test.cljs

@@ -0,0 +1,46 @@
+(ns microtables-frontend.storage-test
+  (:require
+   [cljs.test :refer-macros [deftest is]]
+   [microtables-frontend.utils.storage :as storage]))
+
+(deftest table-empty-nil-table
+  (is (true? (storage/table-empty? nil))))
+
+(deftest table-empty-blank-values
+  (is (true? (storage/table-empty? {"A" {1 {:value ""} 2 {:value nil}}}))))
+
+(deftest table-empty-with-content
+  (is (false? (storage/table-empty? {"A" {1 {:value "42"}}}))))
+
+(deftest serialize-round-trip
+  (let [data {"A" {1 {:value "59"} 12 {:value "=A1+3"}}}]
+    (is (= data (storage/deserialize (storage/serialize data))))))
+
+(deftest deserialize-corrupt-returns-nil
+  (is (nil? (storage/deserialize "{:a 1")))  ; unterminated map, e.g. a write truncated by quota
+  (is (nil? (storage/deserialize nil))))
+
+(deftest rename-entry-updates-matching-id
+  (let [index [{:id "a" :name "Old"} {:id "b" :name "Other"}]
+        renamed (storage/rename-entry index "a" "New")]
+    (is (= "New" (:name (first (filter #(= (:id %) "a") renamed)))))
+    (is (= "Other" (:name (first (filter #(= (:id %) "b") renamed)))))))
+
+(deftest remove-entry-removes-matching-id
+  (let [index [{:id "a"} {:id "b"}]]
+    (is (= [{:id "b"}] (storage/remove-entry index "a")))))
+
+(deftest sort-by-recency-orders-descending
+  (let [index [{:id "a" :last-opened-at 100} {:id "b" :last-opened-at 300} {:id "c" :last-opened-at 200}]]
+    (is (= ["b" "c" "a"] (map :id (storage/sort-by-recency index))))))
+
+(deftest save-entry-preserves-created-at
+  (let [index [{:id "a" :name "Old" :created-at 50 :last-opened-at 50}]
+        saved (storage/save-entry index "a" "Renamed" 999)]
+    (is (= 50 (:created-at (first saved))))
+    (is (= 999 (:last-opened-at (first saved))))
+    (is (= "Renamed" (:name (first saved))))))
+
+(deftest save-entry-creates-new
+  (let [saved (storage/save-entry [] "x" "New Table" 10)]
+    (is (= [{:id "x" :name "New Table" :created-at 10 :last-opened-at 10}] saved))))

+ 24 - 2
todo.md

@@ -134,7 +134,21 @@ When a cell or range is selected, show a compact set of floating action buttons
 
   A pinned row below the table showing the sum (and possibly average) of each column, updating reactively. Show only for columns that contain numbers. Make sure that it is not visually confused for another cell/row.
 
-- [ ] **7.2 Always-visible row totals column**
+- [x] **7.2 Toggleable column totals row**
+
+  By default, the fixed totals row shows the sum of any non-empty column. Clicking on that row's header (the capital sigma symbol on the left) (which should of course have the right "clickable" mouse cursor when hovering) changes it to the average of any non-empty column. Clicking a second time changes it to both the sum and the average (with a smaller font). Clicking a third time changes it back to the sum, and subsequent clicks continue cycling between these modes.
+
+- [ ] **7.2.1 Adaptive decimal precision for the average display**
+
+  Sums are shown exactly as computed — no rounding beyond clearing floating-point artifacts (existing `round-for-display` behaviour). Averages, however, can produce long repeating decimals (e.g. `10 / 3`) that are distracting for a "quick calculation" tool. Compromise:
+  - Display precision for a column's average = (the largest number of decimal digits among that column's numeric cells, as **raw typed text** — not the parsed/rounded number) **+ 2**.
+  - All-integer columns (the majority case) therefore show 2 decimal places.
+  - A column with one input typed as e.g. `4.25` (2 decimal digits) shows the average at 4 decimal places.
+  - Cap the result at **6 decimal places total**, regardless of how many decimal digits the input had, so a single messy/edge-case input can't blow up the column's display width.
+  - Basis is the raw string as typed (so `3.00` counts as 2 decimal digits, not 0, since that reflects user intent) rather than the parsed numeric value.
+  - Open question to resolve during implementation: what counts as "raw typed text" for a formula-derived cell, which has no literal decimal string the user typed — likely its computed `:display` value's string form, but worth confirming it doesn't defeat the cap.
+
+- [ ] **7.3 Always-visible row totals column**
 
   Equivalent pinned column to the right of the table for row aggregates. Perhaps, due to concerns about mobile screen real estate, the row totals could be toggleable (not "always-visible" after all) - maybe behind the row header.
 
@@ -274,9 +288,17 @@ The goal is intentional minimalism — not bare HTML. The current stylesheet has
 
 *Long-term consideration — not yet committed.*
 
-- [ ] **11.1 Local storage persistence**
+- [x] **11.1 Local storage persistence**
 
   Save the table to the browser's `localStorage` on change and restore on page load. Provides durable-but-local storage with no backend required.
+  - Save session by default by date and time, but renameable
+  - A control button in the toolbar for creating a brand new session
+    - If the current session's table was completly empty (and no changes), don't bother saving it before opening a fresh session
+  - Another control button in the toolbar for opening a saved session
+    - This opens a modal with a most-recently-opened-ordered list of sessions
+    - There are options to rename or to delete (with a confirmation modal)
+    - For now, this modal has two tabs: one is the list of sessions, and the second is a help tab with information about how the save feature is to be used, and caveats about using localStorage (ie: to the effect of, sessions don't transfer between devices, disappear if browser data is wiped or was in incognito mode, etc)
+  - When the page is loaded, it opens the most recent session by default
 
 - [ ] **11.2 Server-side persistence**