Skip to content

Shift chart and pivot table ranges on row/column changes#147

Merged
hackerwins merged 6 commits into
mainfrom
feat/chart-pivot-range-shift
Apr 21, 2026
Merged

Shift chart and pivot table ranges on row/column changes#147
hackerwins merged 6 commits into
mainfrom
feat/chart-pivot-range-shift

Conversation

@hackerwins

@hackerwins hackerwins commented Apr 21, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Add shiftA1Range, moveA1Range, shiftColumnLabel, moveColumnLabel utility functions to packages/sheets for shifting A1-notation range strings and column labels
  • Shift chart sourceRange, xAxisColumn, and seriesColumns when rows/columns are inserted, deleted, or moved
  • Shift pivot table sourceRange when rows/columns are inserted, deleted, or moved
  • Handle cross-tab references: iterate all tabs to update chart/pivot ranges whose sourceTabId matches the modified tab (pivot tables always live on a separate tab from their source data)

Test plan

  • 22 new unit tests for parseColumnLabel, shiftColumnLabel, shiftA1Range, moveColumnLabel, moveA1Range
  • pnpm verify:fast passes (lint + all unit tests)
  • Manual: create chart → insert row within range → verify sourceRange expanded
  • Manual: create pivot table → insert row in source tab → verify pivot sourceRange updated
  • Manual: delete column referenced by chart xAxisColumn → verify column removed from config

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes

    • Charts and pivot tables now maintain accurate data ranges when worksheet columns or rows are shifted or moved across tabs.
  • Tests

    • Added comprehensive tests for column label parsing and range transformation operations.

hackerwins and others added 4 commits April 22, 2026 01:11
Add parseColumnLabel, shiftColumnLabel, shiftA1Range, moveColumnLabel,
and moveA1Range as pure building-block functions for chart and pivot
table range shifting.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
When rows or columns are inserted, deleted, or moved, chart sourceRange,
xAxisColumn, and seriesColumns are now updated to reflect the structural
change. Previously only the chart anchor position was shifted.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
Pivot table sourceRange is now updated when structural changes occur
in both MemStore and Yorkie document layers.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Chart and pivot sourceRange references point to data on other tabs.
The previous implementation only shifted ranges within the same
worksheet, missing pivot tables (always on a separate tab) and
charts referencing other tabs.

Now shiftCells/moveCells iterates all tabs to update chart/pivot
ranges whose sourceTabId matches the modified tab. Also filters
chart range shifts by sourceTabId to avoid incorrect updates.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
@coderabbitai

coderabbitai Bot commented Apr 21, 2026

Copy link
Copy Markdown

Warning

Rate limit exceeded

@hackerwins has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 49 minutes and 18 seconds before requesting another review.

Your organization is not enrolled in usage-based pricing. Contact your admin to enable usage-based pricing to continue reviews beyond the rate limit, or try again in 49 minutes and 18 seconds.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: c2e66663-271e-4842-b06d-66e3a686c0d7

📥 Commits

Reviewing files that changed from the base of the PR and between 5a5fc37 and 50a4dcb.

📒 Files selected for processing (2)
  • packages/sheets/src/model/worksheet/shifting.ts
  • packages/sheets/test/sheet/shifting.test.ts
📝 Walkthrough

Walkthrough

This PR adds cross-tab data range update functionality to the spreadsheet system. When a worksheet is shifted or moved structurally, the YorkieStore now invokes new helper functions that automatically adjust chart and pivot table data ranges if they reference the modified source tab. The sheets library exports new coordinate-parsing and range-transformation utilities to support these updates.

Changes

Cohort / File(s) Summary
YorkieStore Integration
packages/frontend/src/app/spreadsheet/yorkie-store.ts
Added imports and integrated calls to shiftCrossTabDataRanges and moveCrossTabDataRanges within shift/move transaction callbacks.
Cross-Tab Data Range Logic
packages/frontend/src/app/spreadsheet/yorkie-worksheet-structure.ts
Implemented shiftCrossTabDataRanges and moveCrossTabDataRanges functions to update chart sourceRange, xAxisColumn, seriesColumns, and pivot table sourceRange when their sourceTabId matches the modified tab. Uses conditional range/label transformations based on axis type.
Sheets Library Exports
packages/sheets/src/index.ts
Expanded public API to re-export new range and label transformation utilities: parseColumnLabel, shiftColumnLabel, shiftA1Range, moveColumnLabel, moveA1Range.
Coordinate and Shifting Utilities
packages/sheets/src/model/core/coordinates.ts, packages/sheets/src/model/worksheet/shifting.ts
Added parseColumnLabel to convert column labels to numeric indices. Implemented shiftColumnLabel, shiftA1Range, moveColumnLabel, moveA1Range functions to handle range and label transformations during sheet operations, with special handling for deletions and boundary clamping.
Documentation Comments
packages/sheets/src/store/memory.ts
Added clarifying comments in MemStore explaining that cross-tab range updates are intentionally delegated to the document-level store layer.
Comprehensive Test Coverage
packages/sheets/test/sheet/shifting.test.ts
Added test suites validating parseColumnLabel, shiftColumnLabel, shiftA1Range, moveColumnLabel, and moveA1Range across various scenarios: single/multi-letter parsing, deletions, range expansion/shrinking, endpoint clamping, and move operations.

Sequence Diagram

sequenceDiagram
    participant Client
    participant YorkieStore
    participant Worksheet Structure
    participant Charts/Pivots

    Client->>YorkieStore: shiftCells() / moveCells()
    YorkieStore->>YorkieStore: applyYorkieWorksheetShift() / applyYorkieWorksheetMove()
    YorkieStore->>Worksheet Structure: shiftCrossTabDataRanges() / moveCrossTabDataRanges()
    
    Worksheet Structure->>Worksheet Structure: Iterate through all sheets
    
    alt sourceTabId matches modified tab
        Worksheet Structure->>Worksheet Structure: Transform chart sourceRange via shiftA1Range/moveA1Range
        Worksheet Structure->>Worksheet Structure: Update xAxisColumn via shiftColumnLabel/moveColumnLabel
        Worksheet Structure->>Worksheet Structure: Update seriesColumns entries
        Worksheet Structure->>Charts/Pivots: Apply updated ranges
    else sourceTabId does not match
        Worksheet Structure->>Charts/Pivots: Leave ranges unchanged
    end
    
    Worksheet Structure-->>YorkieStore: Complete
    YorkieStore-->>Client: Operation finished
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related PRs

Poem

🐰 Ranges hop and labels shift,
Charts and pivots get a lift,
Cross-tab dances, tabs align,
Data ranges redesign! 📊✨

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: shifting chart and pivot table data ranges when rows or columns are inserted, deleted, or moved.
Docstring Coverage ✅ Passed Docstring coverage is 93.33% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

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

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/chart-pivot-range-shift

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 Apr 21, 2026

Copy link
Copy Markdown
Contributor

Verification: verify:self

Result: ✅ PASS in 123.1s

Lane Status Duration
sheets:build ✅ pass 13.9s
docs:build ✅ pass 8.2s
verify:fast ✅ pass 60.6s
frontend:build ✅ pass 16.7s
verify:frontend:chunks ✅ pass 0.3s
backend:build ✅ pass 4.9s
cli:build ✅ pass 1.9s
verify:entropy ✅ pass 16.7s

Verification: verify:integration

Result: ✅ PASS

@codecov

codecov Bot commented Apr 21, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.29630% with 3 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
packages/sheets/src/model/worksheet/shifting.ts 95.89% 2 Missing and 1 partial ⚠️

📢 Thoughts on this report? Let us know!

shiftA1Range previously returned null when either range endpoint
fell in the deleted zone. For chart/pivot data ranges, this is too
aggressive — deleting the first few rows of a chart range should
shrink it, not lose it entirely.

Now clamps deleted endpoints to the deletion boundary so ranges
survive partial deletions. Also removes incorrect pivot shift from
MemStore (single-tab store cannot do cross-tab sourceTabId checks).

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
@hackerwins

Copy link
Copy Markdown
Collaborator Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Apr 21, 2026

Copy link
Copy Markdown
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@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

🧹 Nitpick comments (2)
packages/sheets/src/model/worksheet/shifting.ts (1)

499-500: Silent passthrough for non-range strings may mask bugs.

if (parts.length !== 2) return range; silently returns the input unchanged for single-cell refs (e.g. "A1"), comma-joined multi-ranges (e.g. "A1:B2,C1:D2"), or an empty string. A log/throw or at least a doc comment on the expected input format would make misuse easier to detect. Not critical since current callers always pass start:end strings, but worth documenting.

packages/sheets/test/sheet/shifting.test.ts (1)

519-530: Consider adding a test for moves that split a range.

The current moveA1Range tests only exercise ranges that lie entirely inside the moved block or entirely outside it. Adding a case where the move covers only one endpoint of the range (e.g. moveA1Range('C1:D10', 'column', 3, 1, 10)) would lock down the behavior for the inversion edge case called out in shifting.ts.

it('should keep range valid when move splits the range', () => {
  // Moving col C out while D stays — current output "I1:C10" is inverted.
  const result = moveA1Range('C1:D10', 'column', 3, 1, 10);
  // Define the expected semantics; at minimum, start <= end.
  const [start, end] = result.split(':');
  // e.g. expect(parseRef(start).c).toBeLessThanOrEqual(parseRef(end).c);
});
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/sheets/test/sheet/shifting.test.ts` around lines 519 - 530, Add a
unit test for the split-move edge case to ensure moveA1Range preserves valid
(non-inverted) ranges when the move covers only one endpoint; specifically call
moveA1Range('C1:D10', 'column', 3, 1, 10) and assert the returned "start:end"
has start <= end by parsing both endpoints (use parseRef or the existing A1
parser used elsewhere in tests) and checking the column (and/or row) of the
start is <= the end, plus any additional semantic expectations you want to lock
down.
🤖 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/spreadsheet/yorkie-worksheet-structure.ts`:
- Around line 393-398: The pivotTable.sourceRange is left unchanged when
shiftA1Range(ws.pivotTable.sourceRange, axis, index, count) returns null,
leaving the pivot pointing at deleted data; update the block handling
ws.pivotTable (use ws.pivotTable, shiftA1Range, pivotTable.sourceRange) so that
if shifted is null you either set ws.pivotTable.sourceRange = undefined/null or
remove ws.pivotTable entirely (choose the same behavior used for charts
elsewhere), and ensure any dependent state/notifications are updated accordingly
so the worksheet no longer references a stale range.
- Around line 118-150: The shiftOneChartRange function leaves chart.sourceRange
and chart.xAxisColumn stale when shiftA1Range or shiftColumnLabel return null;
update shiftOneChartRange so that when shiftA1Range(chart.sourceRange, ...)
returns null you clear/unset chart.sourceRange (or mark the chart for deletion),
and similarly when shiftColumnLabel returns null you remove/unset
chart.xAxisColumn (not just skip), while continuing to filter nulls out of
chart.seriesColumns; apply the same consistent behavior for
pivotTable.sourceRange (use shiftA1Range and clear/unset pivotTable.sourceRange
or drop the pivotTable when it becomes invalid) so
sourceRange/xAxisColumn/seriesColumns handling is consistent across
shiftOneChartRange and pivotTable updates.

In `@packages/sheets/src/model/worksheet/shifting.ts`:
- Around line 551-564: moveA1Range can produce inverted ranges when moveRef
remaps endpoints independently; after computing start =
moveRef(parseRef(parts[0]), ...) and end = moveRef(parseRef(parts[1]), ...),
detect if the moved axis coordinate of start is greater than end and swap that
axis' values so the returned "start:end" is normalized; use the same
parsed/returned ref structures (from parseRef/moveRef/toSref) to swap either
column indices for axis === 'column' or row indices for axis === 'row' before
calling toSref, preserving other coordinates.

---

Nitpick comments:
In `@packages/sheets/test/sheet/shifting.test.ts`:
- Around line 519-530: Add a unit test for the split-move edge case to ensure
moveA1Range preserves valid (non-inverted) ranges when the move covers only one
endpoint; specifically call moveA1Range('C1:D10', 'column', 3, 1, 10) and assert
the returned "start:end" has start <= end by parsing both endpoints (use
parseRef or the existing A1 parser used elsewhere in tests) and checking the
column (and/or row) of the start is <= the end, plus any additional semantic
expectations you want to lock down.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 8afa84e3-f63d-4585-a802-f582851c9670

📥 Commits

Reviewing files that changed from the base of the PR and between 8f6c8af and 5a5fc37.

📒 Files selected for processing (7)
  • packages/frontend/src/app/spreadsheet/yorkie-store.ts
  • packages/frontend/src/app/spreadsheet/yorkie-worksheet-structure.ts
  • packages/sheets/src/index.ts
  • packages/sheets/src/model/core/coordinates.ts
  • packages/sheets/src/model/worksheet/shifting.ts
  • packages/sheets/src/store/memory.ts
  • packages/sheets/test/sheet/shifting.test.ts

Comment on lines +118 to +150
function shiftOneChartRange(
chart: SheetChart,
axis: Axis,
index: number,
count: number,
): void {
if (chart.sourceRange) {
const shifted = shiftA1Range(chart.sourceRange, axis, index, count);
if (shifted) {
chart.sourceRange = shifted;
}
}

if (axis === 'column') {
if (chart.xAxisColumn) {
const shifted = shiftColumnLabel(chart.xAxisColumn, index, count);
if (shifted) {
chart.xAxisColumn = shifted;
}
}

if (chart.seriesColumns) {
const result: string[] = [];
for (const col of chart.seriesColumns) {
const shifted = shiftColumnLabel(col, index, count);
if (shifted) {
result.push(shifted);
}
}
chart.seriesColumns = result;
}
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Stale chart config when a referenced range/column is fully deleted.

shiftA1Range and shiftColumnLabel return null when the entire range / the referenced column is deleted, but this branch is simply skipped:

  • chart.sourceRange keeps pointing at data rows/columns that no longer exist.
  • chart.xAxisColumn keeps the old label even when that column was deleted — while seriesColumns correctly filters null entries, so behavior is inconsistent between the two fields.

Consider clearing (or unsetting) the field, or dropping the chart entirely, when the source becomes invalid — the same question applies to pivotTable.sourceRange below. Whatever the decision, it should at least be consistent across sourceRange / xAxisColumn / seriesColumns.

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

In `@packages/frontend/src/app/spreadsheet/yorkie-worksheet-structure.ts` around
lines 118 - 150, The shiftOneChartRange function leaves chart.sourceRange and
chart.xAxisColumn stale when shiftA1Range or shiftColumnLabel return null;
update shiftOneChartRange so that when shiftA1Range(chart.sourceRange, ...)
returns null you clear/unset chart.sourceRange (or mark the chart for deletion),
and similarly when shiftColumnLabel returns null you remove/unset
chart.xAxisColumn (not just skip), while continuing to filter nulls out of
chart.seriesColumns; apply the same consistent behavior for
pivotTable.sourceRange (use shiftA1Range and clear/unset pivotTable.sourceRange
or drop the pivotTable when it becomes invalid) so
sourceRange/xAxisColumn/seriesColumns handling is consistent across
shiftOneChartRange and pivotTable updates.

Comment on lines +393 to +398
if (ws.pivotTable?.sourceTabId === sourceTabId && ws.pivotTable.sourceRange) {
const shifted = shiftA1Range(ws.pivotTable.sourceRange, axis, index, count);
if (shifted) {
ws.pivotTable.sourceRange = shifted;
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Same stale-range concern for pivotTable.sourceRange.

When shiftA1Range(...) returns null (the pivot's source range was fully deleted), the pivot keeps its original sourceRange, which will now point at non-existent data. Recommend either clearing sourceRange or removing pivotTable altogether on full deletion — same question as for charts above.

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

In `@packages/frontend/src/app/spreadsheet/yorkie-worksheet-structure.ts` around
lines 393 - 398, The pivotTable.sourceRange is left unchanged when
shiftA1Range(ws.pivotTable.sourceRange, axis, index, count) returns null,
leaving the pivot pointing at deleted data; update the block handling
ws.pivotTable (use ws.pivotTable, shiftA1Range, pivotTable.sourceRange) so that
if shifted is null you either set ws.pivotTable.sourceRange = undefined/null or
remove ws.pivotTable entirely (choose the same behavior used for charts
elsewhere), and ensure any dependent state/notifications are updated accordingly
so the worksheet no longer references a stale range.

Comment on lines +551 to +564
export function moveA1Range(
range: string,
axis: Axis,
src: number,
count: number,
dst: number,
): string {
const parts = range.split(':');
if (parts.length !== 2) return range;

const start = moveRef(parseRef(parts[0]), axis, src, count, dst);
const end = moveRef(parseRef(parts[1]), axis, src, count, dst);
return toSref(start) + ':' + toSref(end);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

moveA1Range can produce inverted ranges when a move splits the range.

When the move block covers only part of a range's span, the two endpoints are remapped independently and can cross over, producing an invalid start:end string.

Example: moveA1Range('C1:D10', 'column', 3, 1, 10) yields "I1:C10" because:

  • start c=3remapIndex(3,3,1,10) = 9 ("I")
  • end c=4remapIndex(4,3,1,10) = 3 ("C")

Downstream parsers that call parseRange on this will happily accept it but produce a negative-sized range. At minimum the result should be normalized so start <= end on the moved axis; ideally the semantics of a split move should be defined (e.g., clamp to the surviving contiguous block, or preserve the bounding span).

🔧 Suggested minimum fix — normalize endpoints so the range is never inverted
 export function moveA1Range(
   range: string,
   axis: Axis,
   src: number,
   count: number,
   dst: number,
 ): string {
   const parts = range.split(':');
   if (parts.length !== 2) return range;

   const start = moveRef(parseRef(parts[0]), axis, src, count, dst);
   const end = moveRef(parseRef(parts[1]), axis, src, count, dst);
-  return toSref(start) + ':' + toSref(end);
+  const normStart = {
+    r: Math.min(start.r, end.r),
+    c: Math.min(start.c, end.c),
+  };
+  const normEnd = {
+    r: Math.max(start.r, end.r),
+    c: Math.max(start.c, end.c),
+  };
+  return toSref(normStart) + ':' + toSref(normEnd);
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/sheets/src/model/worksheet/shifting.ts` around lines 551 - 564,
moveA1Range can produce inverted ranges when moveRef remaps endpoints
independently; after computing start = moveRef(parseRef(parts[0]), ...) and end
= moveRef(parseRef(parts[1]), ...), detect if the moved axis coordinate of start
is greater than end and swap that axis' values so the returned "start:end" is
normalized; use the same parsed/returned ref structures (from
parseRef/moveRef/toSref) to swap either column indices for axis === 'column' or
row indices for axis === 'row' before calling toSref, preserving other
coordinates.

When a move splits a range (e.g. moving one endpoint's column far
away), the two endpoints can cross over producing an invalid
inverted range like "I1:C10". Now normalizes start/end so the
result is always a valid range.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
@hackerwins
hackerwins merged commit a3134dd into main Apr 21, 2026
4 checks passed
@hackerwins
hackerwins deleted the feat/chart-pivot-range-shift branch April 21, 2026 22:14
hackerwins added a commit that referenced this pull request Apr 22, 2026
chart-pivot-range-shift (#147), table-row-splitting (#145),
and table-copy-paste (#140) are all merged.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
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