Bladeren bron

Add local-storage table persistence with save/rename/delete (todo 11.1)

Tables now autosave to the browser's localStorage on cell blur, keyed
by a small index plus one storage key per table. Boot reopens the
most-recently-opened table. Adds "New Table"/"Open Table" toolbar
buttons and a two-tab (Tables/Help) modal for switching, renaming, and
deleting saved tables, with a nested delete confirmation.

Only :value is persisted per cell; :refs/:inbound/:display are rebuilt
through the existing evaluation pipeline (now extracted as
rebuild-table-data) rather than trusted from storage.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jc3ocGYfboSgear11YZcDv
Brandon Wong 2 dagen geleden
bovenliggende
commit
4cb9c77425

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

@@ -255,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))

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

@@ -13,6 +13,13 @@
    :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"}

+ 200 - 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
@@ -95,3 +130,150 @@
  ::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})))

+ 42 - 0
frontend/src/cljs/microtables_frontend/subs.cljs

@@ -100,4 +100,46 @@
  (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)))

+ 18 - 4
frontend/src/cljs/microtables_frontend/views.cljs

@@ -3,15 +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])
         totals-mode (re-frame/subscribe [::subs/totals-mode])
-        controls-state (re-frame/subscribe [::subs/controls-state])]
+        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 @totals-mode]]
-     [control-panel @controls-state]]))
-
-
+     [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"]

+ 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))))

+ 1 - 1
todo.md

@@ -288,7 +288,7 @@ 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