Selaa lähdekoodia

Add always-visible column totals row (todo 7.1)

Computes each column's sum reactively from a new subs.cljs subscription,
skipping non-numeric/blank cells and surfacing an error indicator if any
cell in the column is in an error state. Renders as a view-only footer
row (never stored in :table-data, not selectable/editable) pinned via
position: sticky just above the control bar, sharing a --control-bar-height
CSS variable so the two can't drift apart. Styled distinctly from both the
header and data rows per the "not confused for another row" requirement.

Records the view-only design choice as ADR 0001 and adds the first
CONTEXT.md glossary entries (Totals Row, Numeric Cell).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TGk94jbWGvBsQmnQXFeksz
Brandon Wong 1 viikko sitten
vanhempi
commit
560ee4b0c4

+ 12 - 0
CONTEXT.md

@@ -0,0 +1,12 @@
+# Microtables Frontend
+
+The spreadsheet grid: cell data, formula evaluation, and keyboard/selection interaction for a lightweight spreadsheet app.
+
+## Language
+
+**Totals Row**:
+A display-only summary rendered below the grid, viewport-pinned (sticking just above the control bar) so it stays visible while scrolling. Shows one sum per column, computed from that column's numeric cells; non-numeric and blank cells are skipped. It is not stored in `:table-data` and has no row number — it cannot be selected, edited, or referenced by a formula. Styled distinctly from both the header row and data rows so it isn't mistaken for either.
+_Avoid_: treating it as "row 21" or as a stored/data row
+
+**Numeric Cell** (for Totals Row purposes):
+A cell whose display value is a number, whether typed directly or produced by a formula. If any cell in a column is in an error state, that column's total shows an error indicator instead of a sum — an errored cell is never silently skipped.

Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 5 - 0
docs/adr/0001-totals-row-view-only.md


+ 31 - 4
frontend/resources/public/site.css

@@ -1,8 +1,12 @@
+:root {
+  --control-bar-height: 44px;
+}
+
 body {
   font-family: 'Helvetica Neue', Verdana, Helvetica, Arial, sans-serif;
   max-width: 600px;
   margin: 0;
-  padding-bottom: 44px;
+  padding-bottom: var(--control-bar-height);
   -webkit-font-smoothing: antialiased;
   font-size: 1.125em;
   color: #333;
@@ -32,7 +36,7 @@ body {
     flex: 1;
     overflow: auto;
     min-height: 0;
-    padding-bottom: 44px;
+    padding-bottom: var(--control-bar-height);
   }
 }
 
@@ -98,6 +102,29 @@ td, th {
     left: 0;
     z-index: 2;
 }
+
+/* Totals row: pinned to the bottom of the viewport, just above the control bar */
+#main-table .totals-row th,
+#main-table .totals-row td {
+    position: sticky;
+    bottom: var(--control-bar-height);
+    z-index: 1;
+    background: #e8edf5;
+    border-top: 2px solid #333;
+}
+/* Bottom-left corner: sticky in both directions, like the top-left corner cell */
+#main-table .totals-row th:first-child {
+    z-index: 2;
+}
+.totals-row td {
+    padding: 5px;
+    text-align: right;
+    font-weight: 600;
+}
+.totals-row .totals-error {
+    color: #b00020;
+}
+
 th {
     text-align: center;
     min-width: 40px;
@@ -133,7 +160,7 @@ td input:not(:focus) {
     bottom: 0;
     left: 0;
     right: 0;
-    height: 44px;
+    height: var(--control-bar-height);
     display: flex;
     align-items: center;
     background: #f5f5f5;
@@ -142,7 +169,7 @@ td input:not(:focus) {
 }
 
 #bar-logo {
-    height: 44px;
+    height: var(--control-bar-height);
     width: 44px;
     flex-shrink: 0;
     display: flex;

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

@@ -1,6 +1,8 @@
 (ns microtables-frontend.subs
   (:require
+   [clojure.string :as string]
    [microtables-frontend.utils.coordinates :as coords]
+   [microtables-frontend.utils.data :as data-utils]
    [re-frame.core :as re-frame]))
 
 (re-frame/reg-sub
@@ -40,4 +42,47 @@
        (assoc-in highlighted [(:col cursor) (:row cursor) :view] :value)
        highlighted))))
 
+(defn- numeric-cell-value
+  "Returns the numeric value of a datum for totals purposes: a number, :error, or nil (not numeric)."
+  [datum]
+  (let [value (:value datum)
+        formula (data-utils/formula? value)
+        raw (if formula (:display datum) value)]
+    (cond
+      (keyword? raw) :error
+      (number? raw) raw
+      (and (string? raw) (not (string/blank? raw)))
+      (let [n (js/Number raw)]
+        (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)."
+  [column]
+  (reduce (fn [acc [_ datum]]
+            (if (= acc :error)
+              acc
+              (let [v (numeric-cell-value datum)]
+                (cond
+                  (= v :error) :error
+                  (number? v) (+ (or acc 0) v)
+                  :else acc))))
+          nil
+          column))
+
+(defn- round-for-display
+  "Rounds a sum for display, clearing floating-point artifacts (e.g. 0.1 + 0.2) without truncating real precision."
+  [n]
+  (js/parseFloat (.toFixed n 10)))
+
+(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))))
+              {}
+              (:table-data db))))
+
 

+ 2 - 1
frontend/src/cljs/microtables_frontend/views.cljs

@@ -7,9 +7,10 @@
 
 (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])]
     [:div#main-layout
-     [:div.sheet-container [sheet @data]]
+     [:div.sheet-container [sheet @data @totals]]
      [control-panel @controls-state]]))
 
 

+ 20 - 4
frontend/src/cljs/microtables_frontend/views/sheet.cljs

@@ -48,7 +48,22 @@
                      ^{:key "corner"} [:th]
                      (map (fn [c] ^{:key (str "col-head-" c)} [:th c]) cols))])
 
-(defn sheet [data]
+(defn totals-cell [c totals]
+  (let [total (get totals c)]
+    ^{:key (str "totals-" c)}
+    [:td {:class (when (= total :error) "totals-error")}
+     (cond
+       (= total :error) "Error"
+       (number? total) (str total)
+       :else "")]))
+
+(defn totals-row [cols totals]
+  ^{:key "totals"} [:tr.totals-row
+                    (cons
+                     ^{:key "totals-corner"} [:th "Σ"]
+                     (map #(totals-cell % totals) cols))])
+
+(defn sheet [data totals]
   [:table
    {:id "main-table"}
    [:tbody
@@ -57,6 +72,7 @@
     (let [maxrow 20;(coords/highest-row data)
           maxcol "G";(coords/highest-col data)
           cols (take-while (partial not= (coords/next-letter maxcol)) coords/col-letters)]
-      (cons
-       (header-row cols)
-       (map #(row % cols maxrow data) (range 1 (inc maxrow)))))]])
+      (concat
+       [(header-row cols)]
+       (map #(row % cols maxrow data) (range 1 (inc maxrow)))
+       [(totals-row cols totals)]))]])

+ 3 - 5
todo.md

@@ -130,15 +130,13 @@ When a cell or range is selected, show a compact set of floating action buttons
 
 ## 7. Column & Row Aggregates
 
-*Under consideration.*
+- [x] **7.1 Always-visible column totals row**
 
-- [ ] **7.1 Always-visible column totals row**
-
-  A pinned row below the table showing the sum (and possibly average) of each column, updating reactively. Show only for columns that contain numbers.
+  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**
 
-  Equivalent pinned column to the right of the table for row aggregates.
+  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.
 
 ---