Skip to content

Add pivot table feature (Phase 1)#26

Merged
hackerwins merged 2 commits into
mainfrom
pivot-table
Mar 9, 2026
Merged

Add pivot table feature (Phase 1)#26
hackerwins merged 2 commits into
mainfrom
pivot-table

Conversation

@hackerwins

@hackerwins hackerwins commented Mar 8, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Add pivot table calculation pipeline (parse, group, aggregate, calculate, materialize) to the sheet package
  • Add pivot editor side panel with field configuration (row/column/value/filter fields) and aggregation support (SUM, COUNT, COUNTA, AVERAGE, MIN, MAX)
  • Wire pivot creation into context menu and document tabs with pivot sheet protection (block direct cell edits)
  • Extend Store interface with setPivotDefinition/getPivotDefinition and invalidate() for external write support

Test plan

  • All 976 existing tests pass (pnpm verify:fast)
  • 7 new pivot test files covering parse, group, aggregate, calculate, materialize, e2e, and protection
  • No regressions to existing features (mobile handles, absolute references, backend tests preserved)
  • Manual: select a data range → right-click → "Insert pivot table" → configure fields in editor panel
  • Manual: verify pivot sheet blocks direct cell edits, row/column insert/delete, and paste

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Pivot table functionality with a right-side editor for rows, columns, values, filters, and a Refresh action
    • Support for SUM, COUNT, COUNTA, AVERAGE, MIN, MAX aggregations; multi-value pivots and totals display
    • Insert pivot table option in the sheet context menu and create-from-selection flow
    • Pivot results materialize as a protected pivot sheet (read-only for structural edits)
  • UX

    • Deletion confirmation that lists and can cascade-delete dependent pivot tabs when removing source tabs
  • Tests

    • Comprehensive unit and end-to-end tests for pivot parsing, grouping, aggregation, materialization, and protection

Port pivot table functionality from pivot-table-phase1 branch,
cleanly separated from existing code without regressions.

Key additions:
- Pivot calculation pipeline (parse, group, aggregate, calculate,
  materialize) in sheet package
- Pivot editor panel and hook for frontend
- Store interface extended with setPivotDefinition/getPivotDefinition
  and invalidate() methods
- Sheet model pivot protection (block direct edits on pivot sheets)
- Context menu "Insert pivot table" option
- Pivot tab creation in document layout
- Comprehensive pivot tests (7 test files)
- Design doc for pivot table architecture

Co-Authored-By: Claude Opus 4.6 <[email protected]>
@coderabbitai

coderabbitai Bot commented Mar 8, 2026

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

Adds a Phase‑1 Pivot Table feature: design docs, pivot model types and store APIs, pivot calculation pipeline (parse, filter, group, aggregate, materialize), CRDT (Yorkie) persistence and sync, frontend UI/editor and hook, sheet integration (pivot tabs/protection), and extensive unit/e2e tests.

Changes

Cohort / File(s) Summary
Design & Docs
design/README.md, design/pivot-table.md
Added pivot-table design doc and README entry.
Public API & Types
packages/sheet/src/model/types.ts, packages/sheet/src/index.ts
Introduced pivot-related types (PivotTableDefinition, PivotField, AggregateFunction, PivotResult, etc.) and exported pivot utilities (calculatePivot, materialize, parseSourceData, parseRange).
Pivot Engine
packages/sheet/src/model/pivot/...
parse.ts, group.ts, aggregate.ts, calculate.ts, materialize.ts, index.ts
Implemented full pivot pipeline: parseSourceData, applyFilters, buildGroups, aggregateValues, calculatePivot, and materialize to Grid.
Sheet Model & Views
packages/sheet/src/model/sheet.ts, packages/sheet/src/view/worksheet.ts, packages/sheet/src/view/spreadsheet.ts
Added pivotDefinition storage on Sheet, isPivotSheet/get/load helpers, prevented structural edits on pivot sheets, and added invalidateStore hook calls.
Store Interface & Implementations
packages/sheet/src/store/store.ts, packages/sheet/src/store/memory.ts, packages/sheet/src/store/readonly.ts
Extended Store interface with setPivotDefinition/getPivotDefinition/invalidate; MemStore persistence with deep-clone safety; ReadOnlyStore no-op behavior and invalidate implementation.
Frontend Types
packages/frontend/src/types/worksheet.ts
Added SheetKind ("normal"
Frontend Store (Yorkie) Integration
packages/frontend/src/app/spreadsheet/yorkie-store.ts
Added get/set PivotDefinition and invalidate() to YorkieStore to read/write pivot config from CRDT.
Frontend Hook & Editor UI
packages/frontend/src/app/spreadsheet/pivot/use-pivot-table.ts, packages/frontend/src/app/spreadsheet/pivot/pivot-editor-panel.tsx
New usePivotTable hook (load/sync pivot definition, build source grid, mutate fields, refresh/materialize) and PivotEditorPanel UI with Filters/Rows/Columns/Values sections and controls.
SheetView & Context Menu Integration
packages/frontend/src/app/spreadsheet/sheet-view.tsx, packages/frontend/src/components/sheet-context-menu.tsx
Wired addPivotTab prop into SheetView, lazy-load pivot editor, add "Insert pivot table" menu item, handle pivot editor open/refresh flows.
Document-level UI / Tab Management
packages/frontend/src/app/documents/document-detail.tsx
Added addPivotTab creation flow, deleteTabWithDependents cascade deletion for dependent pivot tabs, and confirmation dialog UX for cascading deletes.
Frontend Store Tests & Sheet Tests
packages/sheet/test/... (multiple files)
Added comprehensive tests: parse, grouping, aggregation, calculate, materialize, e2e flows, pivot protection, and MemStore pivot definition tests.

Sequence Diagram(s)

sequenceDiagram
    participant User
    participant Frontend as Frontend (React)
    participant Hook as usePivotTable Hook
    participant CRDT as Yorkie Store
    participant Engine as Sheet Engine

    User->>Frontend: Insert pivot table (selection)
    Frontend->>Frontend: create pivot tab (kind=pivot)
    Frontend->>CRDT: setPivotDefinition(def)
    CRDT-->>Frontend: persist & notify
    Frontend->>Hook: update definition (add/remove fields)
    Hook->>CRDT: sync definition
    Hook->>Engine: calculatePivot(sourceGrid, definition)
    Engine->>Engine: parse → filter → group → aggregate → materialize
    Engine-->>Hook: PivotResult / Grid
    Hook->>CRDT: write materialized cells to pivot tab
    Frontend->>User: display updated pivot sheet
Loading
sequenceDiagram
    participant UI as PivotEditorPanel
    participant Hook as usePivotTable
    participant Store as Yorkie Store
    participant Sheet as Sheet Engine

    UI->>Hook: user modifies fields / refresh
    Hook->>Store: setPivotDefinition(updated)
    Store-->>Hook: change event
    Hook->>Sheet: build source grid & calculatePivot
    Sheet->>Hook: materialized Grid
    Hook->>Store: write cells to pivot tab
    Store-->>UI: data update
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Poem

🐰 I nibbled rows and hopped through sums,

Columns twirled as grouping drums,
Filters whispered, aggregates sang,
Pivot pages sprung with a happy clang,
Hooray — a rabbit's tidy data-thing!

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 54.55% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and accurately summarizes the main change: adding a pivot table feature for Phase 1 of implementation.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch pivot-table

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions

github-actions Bot commented Mar 9, 2026

Copy link
Copy Markdown
Contributor

Verification: verify:self

Result: ✅ PASS in 87.8s

Lane Status Duration
verify:fast ✅ pass 36.2s
frontend:build ✅ pass 13.5s
verify:frontend:chunks ✅ pass 0.3s
verify:frontend:visual ✅ pass 7.9s
backend:build ✅ pass 4.1s
sheet:build ✅ pass 11.8s
verify:entropy ✅ pass 13.5s
verify:browser ✅ pass 0.4s

Verification: verify:integration

Result: ✅ PASS

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🧹 Nitpick comments (9)
packages/sheet/test/store/store.test.ts (1)

259-266: Also verify getPivotDefinition() returns a defensive copy.

This only proves setPivotDefinition() clones on write. If getPivotDefinition() returns the stored object by reference, a caller can still mutate store state and this test stays green. Mutate result before the second read to cover that path too.

As per coding guidelines, "Write tests for critical business logic and edge cases".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/sheet/test/store/store.test.ts` around lines 259 - 266, The test
currently only mutates the original `def` to prove cloning on write; also mutate
the object returned by `getPivotDefinition()` (e.g., `result`) before calling
`getPivotDefinition()` again to assert the store returns a defensive copy on
read. In other words, after `const result = await store.getPivotDefinition();`
change a nested field on `result` (such as `result.rowFields[0].label`) and then
call `await store.getPivotDefinition()` again and assert the stored value
remains unchanged; this verifies both `setPivotDefinition` and
`getPivotDefinition` provide mutation safety.
packages/sheet/src/model/pivot/materialize.ts (1)

22-41: Skip style-only 'empty' cells when materializing.

usePivotTable.refresh() persists every cell returned from this function, so emitting { v: '', s: { b: true } } for 'empty' cells adds document churn without changing what users see. Dropping that case also lets you collapse the duplicated bold-header logic.

♻️ Possible simplification
       switch (pivotCell.type) {
         case 'rowHeader':
         case 'colHeader':
-        case 'empty':
-          cell = { v: pivotCell.value, s: { b: true } };
-          break;
         case 'total':
           cell = { v: pivotCell.value, s: { b: true } };
           break;
+        case 'empty':
+          break;
         case 'value':
           if (pivotCell.value !== '') {
             cell = { v: pivotCell.value };
           }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/sheet/src/model/pivot/materialize.ts` around lines 22 - 41, The
switch in materialize.ts currently creates a style-only cell for type 'empty'
causing unnecessary writes; remove the 'empty' case and merge the bold-header
handling so 'rowHeader', 'colHeader', and 'total' all set cell = { v:
pivotCell.value, s: { b: true } }, while 'value' still only creates a cell when
pivotCell.value !== ''. Ensure grid.set(sref, cell) remains guarded by cell !==
undefined so empty cells are skipped.
packages/sheet/test/sheet/pivot-calculate.test.ts (1)

27-45: Assert the total row/column in the showTotals scenario.

This case turns on both row and column totals, but none of the assertions touch the total cells. A regression in grand-total materialization would still pass.

As per coding guidelines, "Write tests for critical business logic and edge cases".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/sheet/test/sheet/pivot-calculate.test.ts` around lines 27 - 45, The
test enables showTotals for rows and columns but never asserts the grand totals;
update the test that uses calculatePivot and the PivotTableDefinition (in
pivot-calculate.test.ts) to include assertions for the total column and total
row cells (e.g., the bottom-right grand total and the per-row/ per-column total
cells) so the case verifies both row totals and column totals are materialized
correctly when showTotals.rows and showTotals.columns are true.
packages/frontend/src/app/spreadsheet/sheet-view.tsx (1)

152-155: Don't treat optional TabMeta.kind as the only pivot signal.

kind is optional in packages/frontend/src/types/worksheet.ts, but this component uses it as the sole source of truth for pivot UI. A document with root.sheets[tabId]?.pivotTable set and kind missing or stale will render as a normal sheet here while the engine still has pivot state. Either derive this from the stored pivot definition as a fallback or make kind mandatory for sheet tabs.

As per coding guidelines, "Use type annotations in TypeScript to improve code clarity and catch errors early".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/frontend/src/app/spreadsheet/sheet-view.tsx` around lines 152 - 155,
The current pivot detection uses TabMeta.kind alone (const isPivotTab =
root?.tabs[tabId]?.kind === "pivot"), which misses cases where pivot state lives
in root.sheets[tabId]?.pivotTable; update the isPivotTab computation in
sheet-view.tsx to treat a tab as pivot if TabMeta.kind === "pivot" OR if
root.sheets[tabId]?.pivotTable is present (use tabId, root, tabs, pivotTable,
and TabMeta.kind as anchors), and add a TypeScript annotation for the isPivotTab
variable to clarify its boolean type; alternatively, enforce TabMeta.kind as
required across types if you prefer the schema change.
packages/sheet/test/sheet/pivot-protection.test.ts (1)

121-130: Verify deep-clone behavior by mutating the returned definition.

expect(def).not.toBe(pivotDef) only proves the result is not the module-level fixture. A shallow clone of the sheet's cached definition would still pass here. Mutate a nested field on def and assert a second sheet.getPivotDefinition() call is unchanged so this covers the immutability contract callers rely on.

♻️ Suggested test hardening
   const def = sheet.getPivotDefinition();
   expect(def).toEqual(pivotDef);
   // Verify it's a clone, not the same reference
   expect(def).not.toBe(pivotDef);
+  expect(def).toBeDefined();
+  def!.showTotals.rows = false;
+  expect(sheet.getPivotDefinition()?.showTotals.rows).toBe(true);
As per coding guidelines, "Write tests for critical business logic and edge cases".
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/sheet/test/sheet/pivot-protection.test.ts` around lines 121 - 130,
The test currently only checks reference inequality but not deep-clone
immutability; after calling await sheet.loadPivotDefinition() and const def =
sheet.getPivotDefinition(), mutate a nested property on def (e.g., change a
nested field inside def.rows/def.settings or similar nested object) and then
call sheet.getPivotDefinition() again and assert that the newly returned
definition equals the original pivotDef (or at least that the mutated nested
field was not changed), thereby proving getPivotDefinition() returns a deep
clone rather than a shallow copy; use the existing MemStore, Sheet, pivotDef,
loadPivotDefinition, and getPivotDefinition symbols to locate and implement this
check.
packages/sheet/src/model/pivot/calculate.ts (1)

142-146: Consider caching row index sets for performance.

A new Set is created for every row/column leaf intersection (line 144). For large pivot tables with many row and column combinations, this is O(rows × cols × indices) set constructions. Pre-building the row sets outside the inner loop would reduce redundant allocations.

♻️ Optional performance improvement
   // 6. Build data rows.
+  // Pre-build row index sets to avoid repeated Set construction
+  const rowSets = rowLeaves.map((leaf) => new Set(leaf.indices));
+
   for (const rowLeaf of rowLeaves) {
     const dataRow: PivotCell[] = [
       { value: rowLeaf.label, type: 'rowHeader' },
     ];
+    const rowSet = rowSets[rowLeaves.indexOf(rowLeaf)];

     if (hasColumns) {
       for (const colLeaf of colLeaves) {
         // Find intersection: record indices that belong to both row and column.
-        const rowSet = new Set(rowLeaf.indices);
         const intersection = colLeaf.indices.filter((i) => rowSet.has(i));
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/sheet/src/model/pivot/calculate.ts` around lines 142 - 146, The
inner loop in calculate (in packages/sheet/src/model/pivot/calculate.ts)
repeatedly constructs a new Set from rowLeaf.indices for each colLeaf, causing
redundant allocations; fix it by precomputing a Map or array of Sets for all row
leaves (e.g., build rowIndexSets keyed by rowLeaf or index position before
iterating colLeaves) and then reuse rowIndexSets.get(rowLeaf) inside the
colLeaves loop when computing intersection with colLeaf.indices; update
references to rowLeaf.indices -> rowIndexSets.get(rowLeaf) in the intersection
logic to avoid recreating Sets per iteration.
packages/frontend/src/app/spreadsheet/yorkie-store.ts (1)

1205-1235: Consider extracting shared CRDT proxy reading logic.

This method duplicates the proxy-reading logic found in use-pivot-table.ts (readPivotProxy). Both manually extract fields to avoid structuredClone issues with Yorkie proxies. Consider extracting this to a shared utility function.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/frontend/src/app/spreadsheet/yorkie-store.ts` around lines 1205 -
1235, getPivotDefinition duplicates the Yorkie CRDT proxy extraction logic
already implemented as readPivotProxy in use-pivot-table.ts; extract that
proxy-unwrapping logic into a shared utility (e.g., readPivotProxy or
readPivotProxyFields) and replace the inline readField/Array.from(...) blocks
inside getPivotDefinition with calls to the shared function, then update
use-pivot-table.ts to call the same utility so both getPivotDefinition and
readPivotProxy share the single implementation for rowFields, columnFields,
valueFields, filterFields, and showTotals handling.
packages/frontend/src/app/spreadsheet/pivot/pivot-editor-panel.tsx (1)

49-77: Add aria-expanded for screen reader accessibility.

The collapsible section button toggles visibility but doesn't communicate its state to assistive technologies.

♿ Accessibility improvement
       <button
         type="button"
         className="flex w-full cursor-pointer items-center gap-1 text-xs font-semibold uppercase tracking-wide text-muted-foreground hover:text-foreground"
         onClick={() => setOpen(!open)}
+        aria-expanded={open}
       >
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/frontend/src/app/spreadsheet/pivot/pivot-editor-panel.tsx` around
lines 49 - 77, The CollapsibleSection component's toggle button doesn't expose
its state to assistive tech; update the button in CollapsibleSection to set
aria-expanded={open} (and optionally add an aria-controls that points to the
section content's id and ensure the content has that id) so screen readers know
whether the section is open or closed; locate the button element in
CollapsibleSection and add the aria attribute(s) tied to the open state.
packages/frontend/src/app/spreadsheet/pivot/use-pivot-table.ts (1)

108-123: Potential echo loop when remote changes arrive.

When a remote change arrives (Effect 2, line 100-103), it updates definition via setDefinition. This triggers Effect 3 (line 108-123), which syncs the same change back to the Yorkie document. While this is likely a no-op at the CRDT level, it adds unnecessary overhead. Consider adding a flag to distinguish local vs remote updates.

♻️ Optional improvement to skip re-syncing remote changes
+  const isRemoteUpdate = useRef(false);
+
   // Reload definition when remote changes arrive (e.g. another user
   // modifies the pivot configuration).
   useEffect(() => {
     if (!doc) return;
     const unsub = doc.subscribe((event) => {
       if (event.type !== "remote-change") return;
       const root = doc.getRoot();
       const ws = root.sheets?.[tabId];
       if (ws?.pivotTable) {
         loadedTabIdRef.current = tabId;
+        isRemoteUpdate.current = true;
         setDefinition(readPivotProxy(ws.pivotTable));
       }
     });
     return unsub;
   }, [doc, tabId]);

   // Sync definition changes to Yorkie document.
   const definitionRef = useRef(definition);
   useEffect(() => {
     // Skip the initial load (when definitionRef.current transitions from null)
     const isInitialLoad = definitionRef.current === null && definition !== null;
     definitionRef.current = definition;
     if (isInitialLoad || !doc || !definition) return;
+    if (isRemoteUpdate.current) {
+      isRemoteUpdate.current = false;
+      return;
+    }

     // Guard: don't sync if tabId changed but definition hasn't re-loaded yet.
     if (loadedTabIdRef.current !== tabId) return;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/frontend/src/app/spreadsheet/pivot/use-pivot-table.ts` around lines
108 - 123, The sync effect (useEffect with definitionRef, doc.update,
loadedTabIdRef, tabId, definition) can re-send remote-originated updates back
into Yorkie; add a simple flag to skip re-sync for remote changes: introduce a
ref like isRemoteUpdateRef, set it true in the effect that handles remote
arrivals before calling setDefinition (where remote updates are applied), then
in the sync effect (the block using definitionRef and doc.update) early-return
if isRemoteUpdateRef.current is true and reset it to false after skipping so
only local changes trigger doc.update; keep existing guards (loadedTabIdRef,
tabId, definition) intact.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@design/pivot-table.md`:
- Around line 264-275: The Store API docs are missing the new invalidate()
surface; update the Store interface section to include invalidate():
Promise<void> (or the correct signature) alongside setPivotDefinition and
getPivotDefinition, and mention that MemStore, YorkieStore, and ReadOnlyStore
implement it; reference the Store interface, methods
setPivotDefinition/getPivotDefinition, the new invalidate() method, and the
implementations MemStore, YorkieStore, ReadOnlyStore so readers know where the
behavior is added and used in external-write flows.
- Around line 292-307: The pivot-sheet protection section is incomplete: expand
it to document the full protection contract by listing all mutation entrypoints
that must be blocked when TabMeta.kind === "pivot" and/or Sheet.pivotDefinition
is present (e.g., Sheet.setData, any Sheet.applyMutation or cell-edit handlers,
public sheet APIs that modify cells or schema), specify the canonical shared
guard location to enforce (use the Sheet-level pivotDefinition check performed
in Sheet.setData as the model), and explicitly call out the one allowed
exception (internal materialization writes that use doc.update bypass Sheet
methods). Also describe initialization via loadPivotDefinition that sets the
cache and how callers should behave if the pivotDefinition is present
(early-return/error), so future contributors know which paths to change to
preserve protection.

In `@packages/sheet/src/model/pivot/group.ts`:
- Around line 74-79: The group key sort currently calls a localeCompare with an
undefined locale which uses host defaults and yields inconsistent ordering;
update the sort in group.ts where keys are derived (the keys =
[...groups.keys()].sort(...) block) to pass a fixed locale (e.g., 'en' or
'en-US') to localeCompare and keep the { numeric: true } option so ordering is
deterministic across environments, preserving the existing sortDir logic that
negates cmp for 'desc'.

In `@packages/sheet/src/view/worksheet.ts`:
- Line 266: After calling await this.sheet.loadPivotDefinition() in Worksheet,
short-circuit all edit-entry code paths by checking the pivot state: update
primeCellInputForSelection(), showCellInput(), and any paste/cut/edit shortcut
handlers to immediately return/do nothing when this.sheet.isPivotSheet() (or a
new pivotLoaded flag set after loadPivotDefinition resolves) is true; similarly
add the same guard where Worksheet currently primes/opens the inline editor so
pivot sheets never enter edit mode once the pivot definition is loaded.

---

Nitpick comments:
In `@packages/frontend/src/app/spreadsheet/pivot/pivot-editor-panel.tsx`:
- Around line 49-77: The CollapsibleSection component's toggle button doesn't
expose its state to assistive tech; update the button in CollapsibleSection to
set aria-expanded={open} (and optionally add an aria-controls that points to the
section content's id and ensure the content has that id) so screen readers know
whether the section is open or closed; locate the button element in
CollapsibleSection and add the aria attribute(s) tied to the open state.

In `@packages/frontend/src/app/spreadsheet/pivot/use-pivot-table.ts`:
- Around line 108-123: The sync effect (useEffect with definitionRef,
doc.update, loadedTabIdRef, tabId, definition) can re-send remote-originated
updates back into Yorkie; add a simple flag to skip re-sync for remote changes:
introduce a ref like isRemoteUpdateRef, set it true in the effect that handles
remote arrivals before calling setDefinition (where remote updates are applied),
then in the sync effect (the block using definitionRef and doc.update)
early-return if isRemoteUpdateRef.current is true and reset it to false after
skipping so only local changes trigger doc.update; keep existing guards
(loadedTabIdRef, tabId, definition) intact.

In `@packages/frontend/src/app/spreadsheet/sheet-view.tsx`:
- Around line 152-155: The current pivot detection uses TabMeta.kind alone
(const isPivotTab = root?.tabs[tabId]?.kind === "pivot"), which misses cases
where pivot state lives in root.sheets[tabId]?.pivotTable; update the isPivotTab
computation in sheet-view.tsx to treat a tab as pivot if TabMeta.kind ===
"pivot" OR if root.sheets[tabId]?.pivotTable is present (use tabId, root, tabs,
pivotTable, and TabMeta.kind as anchors), and add a TypeScript annotation for
the isPivotTab variable to clarify its boolean type; alternatively, enforce
TabMeta.kind as required across types if you prefer the schema change.

In `@packages/frontend/src/app/spreadsheet/yorkie-store.ts`:
- Around line 1205-1235: getPivotDefinition duplicates the Yorkie CRDT proxy
extraction logic already implemented as readPivotProxy in use-pivot-table.ts;
extract that proxy-unwrapping logic into a shared utility (e.g., readPivotProxy
or readPivotProxyFields) and replace the inline readField/Array.from(...) blocks
inside getPivotDefinition with calls to the shared function, then update
use-pivot-table.ts to call the same utility so both getPivotDefinition and
readPivotProxy share the single implementation for rowFields, columnFields,
valueFields, filterFields, and showTotals handling.

In `@packages/sheet/src/model/pivot/calculate.ts`:
- Around line 142-146: The inner loop in calculate (in
packages/sheet/src/model/pivot/calculate.ts) repeatedly constructs a new Set
from rowLeaf.indices for each colLeaf, causing redundant allocations; fix it by
precomputing a Map or array of Sets for all row leaves (e.g., build rowIndexSets
keyed by rowLeaf or index position before iterating colLeaves) and then reuse
rowIndexSets.get(rowLeaf) inside the colLeaves loop when computing intersection
with colLeaf.indices; update references to rowLeaf.indices ->
rowIndexSets.get(rowLeaf) in the intersection logic to avoid recreating Sets per
iteration.

In `@packages/sheet/src/model/pivot/materialize.ts`:
- Around line 22-41: The switch in materialize.ts currently creates a style-only
cell for type 'empty' causing unnecessary writes; remove the 'empty' case and
merge the bold-header handling so 'rowHeader', 'colHeader', and 'total' all set
cell = { v: pivotCell.value, s: { b: true } }, while 'value' still only creates
a cell when pivotCell.value !== ''. Ensure grid.set(sref, cell) remains guarded
by cell !== undefined so empty cells are skipped.

In `@packages/sheet/test/sheet/pivot-calculate.test.ts`:
- Around line 27-45: The test enables showTotals for rows and columns but never
asserts the grand totals; update the test that uses calculatePivot and the
PivotTableDefinition (in pivot-calculate.test.ts) to include assertions for the
total column and total row cells (e.g., the bottom-right grand total and the
per-row/ per-column total cells) so the case verifies both row totals and column
totals are materialized correctly when showTotals.rows and showTotals.columns
are true.

In `@packages/sheet/test/sheet/pivot-protection.test.ts`:
- Around line 121-130: The test currently only checks reference inequality but
not deep-clone immutability; after calling await sheet.loadPivotDefinition() and
const def = sheet.getPivotDefinition(), mutate a nested property on def (e.g.,
change a nested field inside def.rows/def.settings or similar nested object) and
then call sheet.getPivotDefinition() again and assert that the newly returned
definition equals the original pivotDef (or at least that the mutated nested
field was not changed), thereby proving getPivotDefinition() returns a deep
clone rather than a shallow copy; use the existing MemStore, Sheet, pivotDef,
loadPivotDefinition, and getPivotDefinition symbols to locate and implement this
check.

In `@packages/sheet/test/store/store.test.ts`:
- Around line 259-266: The test currently only mutates the original `def` to
prove cloning on write; also mutate the object returned by
`getPivotDefinition()` (e.g., `result`) before calling `getPivotDefinition()`
again to assert the store returns a defensive copy on read. In other words,
after `const result = await store.getPivotDefinition();` change a nested field
on `result` (such as `result.rowFields[0].label`) and then call `await
store.getPivotDefinition()` again and assert the stored value remains unchanged;
this verifies both `setPivotDefinition` and `getPivotDefinition` provide
mutation safety.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: a8257e8e-d135-4e71-ba12-6f4000549c5c

📥 Commits

Reviewing files that changed from the base of the PR and between e8c308c and 55a9231.

📒 Files selected for processing (31)
  • design/README.md
  • design/pivot-table.md
  • packages/frontend/src/app/documents/document-detail.tsx
  • packages/frontend/src/app/spreadsheet/pivot/pivot-editor-panel.tsx
  • packages/frontend/src/app/spreadsheet/pivot/use-pivot-table.ts
  • packages/frontend/src/app/spreadsheet/sheet-view.tsx
  • packages/frontend/src/app/spreadsheet/yorkie-store.ts
  • packages/frontend/src/components/sheet-context-menu.tsx
  • packages/frontend/src/types/worksheet.ts
  • packages/sheet/src/index.ts
  • packages/sheet/src/model/pivot/aggregate.ts
  • packages/sheet/src/model/pivot/calculate.ts
  • packages/sheet/src/model/pivot/group.ts
  • packages/sheet/src/model/pivot/index.ts
  • packages/sheet/src/model/pivot/materialize.ts
  • packages/sheet/src/model/pivot/parse.ts
  • packages/sheet/src/model/sheet.ts
  • packages/sheet/src/model/types.ts
  • packages/sheet/src/store/memory.ts
  • packages/sheet/src/store/readonly.ts
  • packages/sheet/src/store/store.ts
  • packages/sheet/src/view/spreadsheet.ts
  • packages/sheet/src/view/worksheet.ts
  • packages/sheet/test/sheet/pivot-aggregate.test.ts
  • packages/sheet/test/sheet/pivot-calculate.test.ts
  • packages/sheet/test/sheet/pivot-e2e.test.ts
  • packages/sheet/test/sheet/pivot-group.test.ts
  • packages/sheet/test/sheet/pivot-materialize.test.ts
  • packages/sheet/test/sheet/pivot-parse.test.ts
  • packages/sheet/test/sheet/pivot-protection.test.ts
  • packages/sheet/test/store/store.test.ts

Comment thread design/pivot-table.md
Comment thread design/pivot-table.md
Comment thread packages/frontend/src/app/documents/document-detail.tsx
Comment thread packages/sheet/src/model/pivot/group.ts
- Pin locale to 'en' in pivot group sorting with Intl.Collator for
  deterministic ordering across environments
- Cascade-delete dependent pivot tabs when source sheet is deleted,
  with a confirmation dialog listing affected pivots
- Block edit mode entry on pivot sheets by guarding showCellInput,
  primeCellInputForSelection, and cut shortcut with isPivotSheet()

Co-Authored-By: Claude Opus 4.6 <[email protected]>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

♻️ Duplicate comments (1)
packages/frontend/src/app/documents/document-detail.tsx (1)

421-429: ⚠️ Potential issue | 🟠 Major

Delete the full pivot dependency closure, not just direct children.

SheetView now gets addPivotTab for every sheet tab, and its insert handler uses the current tabId as sourceTabId. This scan only removes pivots whose sourceTabId matches the tab being deleted, so a pivot built from another pivot will survive with a broken source reference after the base tab is removed. Either walk the dependency graph transitively here, or suppress pivot creation on pivot tabs for Phase 1.

Also applies to: 560-560

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/frontend/src/app/documents/document-detail.tsx` around lines 421 -
429, The current deletion only collects direct pivot children by checking
pivotTable.sourceTabId against tabId (using dependentPivotIds, root.tabOrder,
root.sheets, pivotTable, sourceTabId), which leaves pivots-of-pivots orphaned;
change the logic to compute the transitive closure of pivot dependencies
instead: perform a BFS/DFS starting from the initial tabId over the pivot graph
(treat each sheet id as a node and edges from a pivot tab to its sourceTabId) to
collect all reachable pivot tab ids, then delete all those pivot tabs (rather
than only direct children). Update the code that builds dependentPivotIds to use
this traversal so any pivot built from another pivot is also removed.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/frontend/src/app/documents/document-detail.tsx`:
- Around line 457-473: confirmPendingDelete currently recomputes dependents at
click-time and proceeds to delete, which can remove tabs the user didn't confirm
or leave zero tabs; instead, before calling deleteTabWithDependents, re-validate
that the live dependent set equals the snapshot shown in the dialog
(dependentNames stored when opening the modal) and that deleting the snapshot +
pendingDelete.tabId would not remove the last remaining tab. If the live set
differs from the snapshot or the post-delete tab count would be zero, abort (or
show an error/toast) and clear/refresh the modal; only call
deleteTabWithDependents(pendingDelete.tabId, dependentPivotIds) when the
validated sets match and the one-tab-minimum invariant holds. Ensure you
reference confirmPendingDelete, pendingDelete, doc, dependentNames,
deleteTabWithDependents, and setPendingDelete when implementing this check.

In `@packages/sheet/src/view/worksheet.ts`:
- Around line 4478-4479: The current pivot-edit guard (checking this.readOnly ||
!this.sheet || this.sheet.isPivotSheet()) is only used for inline edits; create
a shared method (e.g., canStartEdit or isEditAllowed) that encapsulates these
checks (readOnly, sheet existence, and sheet.isPivotSheet()) and replace the
inline guard in the inline editor path as well as call it from
handleMobileDoubleTap (before invoking mobileEditCallback/showCellInput) and
from finishEditing (the formula-bar edit entry path) so pivot sheets and
readOnly mode cannot start edits via mobile or formula-bar routes.
- Line 266: After calling this.sheet.loadPivotDefinition() you must clear any
active editor and reset edit state when the sheet becomes a pivot: detect when
this.isPivotSheet() returns true after loadPivotDefinition() and then hide/reset
the UI editor state (clear cellInput and any primed/formula session) so
primeCellInputForSelection() and showCellInput() won't leave a stale editor
visible; update the worksheet logic around loadPivotDefinition() to call the
same hide/reset routine used when cancelling edits (or explicitly clear
cellInput and related flags) immediately after loadPivotDefinition() if
isPivotSheet() is true.

---

Duplicate comments:
In `@packages/frontend/src/app/documents/document-detail.tsx`:
- Around line 421-429: The current deletion only collects direct pivot children
by checking pivotTable.sourceTabId against tabId (using dependentPivotIds,
root.tabOrder, root.sheets, pivotTable, sourceTabId), which leaves
pivots-of-pivots orphaned; change the logic to compute the transitive closure of
pivot dependencies instead: perform a BFS/DFS starting from the initial tabId
over the pivot graph (treat each sheet id as a node and edges from a pivot tab
to its sourceTabId) to collect all reachable pivot tab ids, then delete all
those pivot tabs (rather than only direct children). Update the code that builds
dependentPivotIds to use this traversal so any pivot built from another pivot is
also removed.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: b79fe80e-d1bc-46b1-a41d-52a143059891

📥 Commits

Reviewing files that changed from the base of the PR and between 55a9231 and cbe1941.

📒 Files selected for processing (3)
  • packages/frontend/src/app/documents/document-detail.tsx
  • packages/sheet/src/model/pivot/group.ts
  • packages/sheet/src/view/worksheet.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/sheet/src/model/pivot/group.ts

Comment thread packages/frontend/src/app/documents/document-detail.tsx
Comment thread packages/sheet/src/view/worksheet.ts
Comment thread packages/sheet/src/view/worksheet.ts
@hackerwins

Copy link
Copy Markdown
Collaborator Author

@coderabbitai Thanks for the thorough review on the latest commit. Here's our assessment of the 3 new Major comments:

#1 Stale delete confirmation — The race condition requires another client to add/remove tabs during the few seconds the dialog is open. We already re-collect dependents at confirm time, which is a reasonable safeguard for this edge case. We'll revisit if collaboration-heavy usage reveals real issues.

#2 Reset editor on pivot loadloadPivotDefinition() is called during initialize(), before any editing session starts. The scenario of an already-open editor transitioning to pivot state doesn't occur in practice.

#3 Mobile/formula-bar edit bypassshowCellInput() already has the isPivotSheet() guard, so mobile double-tap paths that flow through it are blocked. finishEditing() requires an open editor to reach, but the editor can't be opened on pivot sheets in the first place.

All three are defensive improvements for edge cases that are unlikely in Phase 1. We'll harden these paths as the pivot feature matures.

@coderabbitai

coderabbitai Bot commented Mar 9, 2026

Copy link
Copy Markdown

@hackerwins Thanks for the detailed write-up — the reasoning for all three is sound:

  1. Stale delete confirmation — Re-collecting dependents at confirm time is a solid safeguard for the collaborative race. Agreed it's worth revisiting once real usage patterns emerge.
  2. Reset editor on pivot load — Makes sense; if loadPivotDefinition() is always called prior to any editing session during initialize(), the problematic mid-session transition simply can't arise.
  3. Mobile/formula-bar edit bypass — The layered defense (editor can't open → finishEditing() can't be reached) is a reasonable argument that the guard is already effective in practice.

Happy to close out these comments. If you'd like to track the hardening items for Phase 2/3, I can open follow-up issues for any or all of them. Just let me know!

✅ Actions performed

Comments resolved. Auto-approval is disabled; enable reviews.request_changes_workflow to approve automatically.

@hackerwins
hackerwins merged commit 92fb2a7 into main Mar 9, 2026
2 checks passed
@hackerwins
hackerwins deleted the pivot-table branch March 9, 2026 00:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant