| 1 | # Brainstorm — Fable's answer |
| 2 | |
| 3 | Read in the brief's order, then `handlers_grid.go`, `viewspec.go`, |
| 4 | `grid.go`, `store/handle*.go`, `store/history.go`, `calc/registry.go`, |
| 5 | `calc/graph.go`, `calc/translate.go`, the four chrome modules, and the |
| 6 | `sheet.html`, `table.html` and `comments.html` templates. I did not open |
| 7 | `BRAINSTORM-GPT.md`; the brief wants two independent voices. |
| 8 | |
| 9 | ## 1. Insert and delete a row or a column |
| 10 | |
| 11 | **What it is.** Right-click a row, or open Edit, and put a new empty row |
| 12 | above it; every formula still points at the numbers it pointed at |
| 13 | before. Delete works the same way, and a formula that referred to the |
| 14 | deleted row shows `#REF!` rather than a wrong number. |
| 15 | |
| 16 | **Why it is first.** It is the most basic act in a spreadsheet after |
| 17 | typing, and the app cannot do it: `grid.js` sets `allowInsertRow: |
| 18 | false`, no route touches `Row.Position`, and `handle_open.go`'s binder |
| 19 | comment says exactly why. What people do instead is retype a block one |
| 20 | row down — which writes an input event per moved cell, detaches every |
| 21 | comment (`comments.row_id`) from the data it was about, and leaves |
| 22 | history describing a hundred edits nobody made on purpose. PRODUCT.md's |
| 23 | job is "put numbers in a shared grid"; a grid you cannot make room in is |
| 24 | a form. |
| 25 | |
| 26 | **What it costs.** This is §14's subsystem wearing a feature's name, and |
| 27 | I am saying so: calc's key space rebuilt on stable ids. Today |
| 28 | `calc.Ref` carries positions, `refKey` and `rangeCells` compute |
| 29 | `CellKey{pos+1}` deep inside Eval, `rawText` and `cells.input_sealed` |
| 30 | hold A1 text, and `events.payload_sealed` holds A1 text. Moving a row |
| 31 | therefore needs one of two designs, and **Paul has to rule which**: |
| 32 | |
| 33 | - *(a)* rewrite the A1 text of every affected formula at insert time — |
| 34 | Excel's mechanism. Cheap to start, but either the log grows by one |
| 35 | event per rewritten formula or it does not and a restored old formula |
| 36 | points at the wrong row. |
| 37 | - *(b)* bind references to `(row_id, col_id)` at write time, persist the |
| 38 | id-bound form, render A1 from the position map. This is what §4.2 |
| 39 | already claims the design does. Insert becomes one `UPDATE` on |
| 40 | `rows.position` and no graph change. Events written before the change |
| 41 | stay A1 and are "coarse before the horizon", which §4.3's retention |
| 42 | language already permits. |
| 43 | |
| 44 | I would argue for (b). Touches `calc/ref.go`, `eval.go`, `fn_lookup.go`, |
| 45 | `graph.go`; `store/handle*.go`, `events.go`, `history.go`; |
| 46 | `xlsx/export.go` (renders A1); `grid.js`, the Edit and Insert menus, and |
| 47 | `table.html` for the scriptless twin. No SQL migration is forced; one |
| 48 | additive column marking payload encoding is likely. It is the largest |
| 49 | item here by a factor of three and needs a §1 ruling plus a §Review pass |
| 50 | on the spec change before code. |
| 51 | |
| 52 | **What breaks.** A wrong binding silently repoints every formula — the |
| 53 | exact failure §14 names — and nothing on screen says so. The one thing |
| 54 | that would make me drop it: if the dogfood sheets turn out to be |
| 55 | append-only (rows added at the bottom, never between), `EnsureRow` |
| 56 | already covers that and the subsystem can wait for a second ask. |
| 57 | |
| 58 | **First slice.** Insert one row above / one column left, on the |
| 59 | id-bound key space, from the Edit menu and from a form on the table |
| 60 | view. Delete and `#REF!` second. No destructive sort at all (see below). |
| 61 | |
| 62 | ## 2. The functions people's existing spreadsheets already use |
| 63 | |
| 64 | **What it is.** `=SUMIF(A:A,">100",B:B)`, `=COUNTIF`, `=YEAR(A2)`, |
| 65 | `=EOMONTH`, `=ISBLANK`, `=TEXT` — the everyday tail. A workbook you |
| 66 | import stops showing `#NAME?` where its author had totals. |
| 67 | |
| 68 | **Why it is second.** The engine registers 32 functions |
| 69 | (`calc/registry.go` and `fn_*.go`: SUM through VLOOKUP, NOW, TODAY); |
| 70 | spec §5 said "~120 to start"; the LibreOffice corpus has 60 entries over |
| 71 | 30 of them. An unknown name evaluates to `#NAME?` (`eval_call.go`), so |
| 72 | `xlsx.Import` faithfully turns a real budget into a sheet of errors and |
| 73 | the person blames the app, not the gap. This is the cheapest item on the |
| 74 | list and has the best test story in the repo: `tools/gencorpus` produces |
| 75 | vectors from LibreOffice headless (gate 4), so every function ships |
| 76 | proven against a real spreadsheet rather than our reading of a spec. |
| 77 | |
| 78 | **What it costs.** `calc` only. New family files, self-registering; a |
| 79 | shared criteria matcher for `">100"` and `"*x*"`; date functions ride |
| 80 | the serial model `format/dates.go` already displays. Corpus regen. No |
| 81 | migration, no UI, no ruling, no chrome. A few sessions. |
| 82 | |
| 83 | **What breaks.** A function that disagrees with LibreOffice on a corner |
| 84 | case — the corpus catches it if the case is in the corpus, which is the |
| 85 | discipline `volatile-excluded.txt` already models. I would drop it only |
| 86 | if the event log showed the dogfood sheets were literal-only, and it |
| 87 | does not. |
| 88 | |
| 89 | **First slice.** SUMIF, COUNTIF, AVERAGEIF, SUMIFS, COUNTIFS; DATE, |
| 90 | YEAR, MONTH, DAY, DAYS, EOMONTH, WEEKDAY; ISBLANK, ISNUMBER, ISTEXT, |
| 91 | ISERROR; TEXT, VALUE. About twenty. |
| 92 | |
| 93 | ## 3. A second tab |
| 94 | |
| 95 | **What it is.** A `+` on the tab strip, a name you can change, and a |
| 96 | strip you can switch with. Importing a three-worksheet file gives you |
| 97 | three tabs, not one. |
| 98 | |
| 99 | **Why it is third.** It fires a written trigger — §14: "a second tab is |
| 100 | reachable in the UI" — and the tab strip in `sheet.html` says in a |
| 101 | comment that it deliberately has no `+` for exactly that reason. The |
| 102 | `tabs` table (sealed name, position) exists; the API routes already |
| 103 | carry `{tabID}`; `xlsx.Import` reads `GetSheetList()[0]` and stops. Real |
| 104 | workbooks have tabs, and the import form's hint is an honest apology |
| 105 | that should retire. |
| 106 | |
| 107 | **What it costs.** Medium, no migration, no ruling. The page routes |
| 108 | (`/table`, `/history`, `/comments`, `/view`) use `SheetSummary.TabID` = |
| 109 | first tab (`sheetsvc.go`); they need a tab parameter. Create and rename |
| 110 | are forms, so the scriptless path is free. Import loops over |
| 111 | worksheets. **Cross-tab references (`Sheet2!A1`) are not in this** and I |
| 112 | would refuse to build them before pick 1: real `Row.ID`/`Col.ID` are |
| 113 | globally unique, so an id-bound key space is already tab-agnostic and |
| 114 | cross-tab refs become "one handle per sheet". Built on the position key |
| 115 | space they would be a second binding to unpick later. |
| 116 | |
| 117 | **What breaks.** A tab you can create in the grid but not from the |
| 118 | table view — principle 2. Drop it if none of the dogfooders' imported |
| 119 | files has a second worksheet. |
| 120 | |
| 121 | **First slice.** `+`, rename, switch, and import of every worksheet. |
| 122 | No reorder, no delete, no cross-tab refs. |
| 123 | |
| 124 | ## Quietly unfinished: the grid shows A1:Z100 and says nothing |
| 125 | |
| 126 | `sheetsvc.go` sets `defaultWindowRows = 100`, `defaultWindowCols = 26`; |
| 127 | `handlers_sheets.go:142` hands exactly those to the page; `grid.js` |
| 128 | fetches exactly those; `snapshot` (`grid.go`) drops every cell outside |
| 129 | them. Meanwhile `handleCellPost` accepts row 4,999, the table paginates |
| 130 | to 500, import caps at 200,000 cells, the thumbnail (`preview.go`) draws |
| 131 | the sheet's *true* shape, and export carries everything. Plan 2 item 5 |
| 132 | promised the window would be "extendable by an explicit button"; there |
| 133 | is no such button in `grid.js` or `sheet.html`. So an imported 300-row |
| 134 | sheet opens showing 100 rows, with no line of copy anywhere saying rows |
| 135 | 101–300 exist. That is principle 1 broken by omission, and it matters |
| 136 | more than tabs because it makes the shipped product wrong rather than |
| 137 | merely smaller. Smallest fix: the page passes the tab's extent (from |
| 138 | `GridRows()`, plus a buffer, clamped at `maxWindowRows`), the grid sizes |
| 139 | to it, and a status line says "rows 1–500 of 1,240" with a "Show more" |
| 140 | button as the menu equivalent. About sixty lines. Virtualise later. |
| 141 | |
| 142 | ## Would not build: destructive sort |
| 143 | |
| 144 | §14 defers it behind the key-space subsystem. I would not build it even |
| 145 | after pick 1 lands, and I think the trigger is wrong. §4.2 rules "values |
| 146 | move, ids stay with positions", so a sort is a cell-content rewrite: |
| 147 | 500 rows by 10 columns is 5,000 sealed input events per sort in a log |
| 148 | Litestream ships continuously; undo is own-edits, cell-level (§7), so |
| 149 | undoing a sort is 5,000 undos or a new compound event kind; and |
| 150 | comments anchor to `row_id`, so the note about Acme's row stays at |
| 151 | position 7 while Acme's numbers move to 30 — the anchoring that stable |
| 152 | ids were bought for, defeated by the sort semantics in the same |
| 153 | section. The non-destructive view (`viewspec.go`) already gives people |
| 154 | what they want and tells them the truth about it. The Google pull is |
| 155 | "sort should stick"; it does. If Paul wants it anyway, the trigger |
| 156 | should read "a compound event kind with its own undo", not "stable ids". |
| 157 | |
| 158 | ## What I would cut from a longer list |
| 159 | |
| 160 | Comments visible in the grid (a per-cell count is shape, not content; |
| 161 | worth an afternoon, not a slot), presence, freeze panes, find, undo for |
| 162 | formatting, conditional formatting, charts. All real; none moves the |
| 163 | dogfood job. |
| 164 | |