Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions AGENTS.md

Large diffs are not rendered by default.

31 changes: 18 additions & 13 deletions README.en.md

Large diffs are not rendered by default.

30 changes: 17 additions & 13 deletions README.md

Large diffs are not rendered by default.

Binary file modified public/screenshots/27-landing-stats.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified public/screenshots/en/27-landing-stats.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 2 additions & 2 deletions public/screenshots/screenshots.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
"27-landing-stats.png": {
"paletteFormulas": 37,
"engineFunctions": 66,
"tests": 2007,
"tests": 2010,
"securityTests": 325
},
"38-landing-problems.png": {
Expand All @@ -21,7 +21,7 @@
"en/27-landing-stats.png": {
"paletteFormulas": 37,
"engineFunctions": 66,
"tests": 2007,
"tests": 2010,
"securityTests": 325
},
"en/38-landing-problems.png": {
Expand Down
Binary file modified public/social-preview.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
40 changes: 40 additions & 0 deletions scripts/check-e2e.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -1600,6 +1600,46 @@ const FLOWS = [
note(/^B5 (เป็นข้อมูลสด|is live data)/.test((await cell(page, 4, 1).getAttribute("data-refused")) ?? ""), "typing on B5:C6 says why under B5");
},
},
{
// #235. A sort over a live block moved its rows and was said as done; the next refresh wrote the
// block back in the source's order while the notes typed beside it stayed sorted, so a note sat
// on another product's row without a word. The seam is a sort meeting a block that rewrites itself.
name: "sorting next to a live block is refused whole and said, and every note stays on its product (#235)",
async run(page) {
await cell(page, 0, 0).click();
await page.getByRole("button", { name: label.liveData }).first().click();
await page.getByRole("button", { name: label.trySales }).click();
const form = page.getByRole("dialog");
await form.getByRole("button", { name: label.test }).click();
await form.getByText(/ได้ข้อมูล 5 แถว|Got 5 rows/).waitFor({ timeout: 15_000 });
await form.getByRole("button", { name: label.saveAndAdd }).click();
const insert = page.getByRole("dialog").getByRole("button", { name: label.insert });
await insert.waitFor({ timeout: 15_000 });
await insert.click();
await page.waitForFunction(() => document.querySelector('td[data-row="1"][data-col="0"]')?.innerText.trim() === "CF-01", null, { timeout: 15_000 });

// A note per product in H, beside the block, as a person keeps them.
const pairs = async () => {
const out = [];
for (let r = 1; r <= 5; r++) out.push([(await cell(page, r, 0).innerText()).trim(), (await cell(page, r, 7).innerText()).trim()]);
return out;
};
for (let r = 1; r <= 5; r++) await typeInCell(page, r, 7, `n-${(await cell(page, r, 0).innerText()).trim()}`);
const before = await pairs();

await cell(page, 1, 3).click();
await page.getByRole("button", { name: label.tools }).click();
await page.getByTitle(label.sortAsc).click();
note(/^A2:G6 (เป็นข้อมูลสด|is live data)/.test((await cell(page, 1, 3).getAttribute("data-refused")) ?? ""), "D2 says under itself why the sort did not happen");
const spoken = await page.evaluate(() => [...document.querySelectorAll('[role="status"][aria-live]')].map((e) => e.textContent).join(" "));
note(!/เรียง|[Ss]orted/.test(spoken), `nothing is said as sorted ("${spoken.trim().slice(0, 80)}")`);
note(JSON.stringify(await pairs()) === JSON.stringify(before), "no row moved");
await page.waitForTimeout(6000); // past the source's 5-second refresh, when the notes used to land on other rows
const after = await pairs();
const wrong = after.filter(([sku, n]) => n !== `n-${sku}`);
note(wrong.length === 0, `after a refresh every note is on its own product (${wrong.length} on another)`);
},
},
{
// #83. The app is used to open a file, fix it and send it back, and a file with pictures or
// Excel's own charts loses them on the way — which the person used to hear from whoever got
Expand Down
2 changes: 1 addition & 1 deletion src/app/opengraph-image.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export default function OgImage() {
const stats = [
["66", "engine functions"],
["37", "palette formulas"],
["2007", "automated tests"],
["2010", "automated tests"],
["325", "of them security"],
["0", "formula libraries"],
];
Expand Down
2 changes: 1 addition & 1 deletion src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -432,7 +432,7 @@ export const en: Messages = {
stats: [
{ value: "37", label: "ready-made formulas" },
{ value: "66", label: "engine functions" },
{ value: "2007", label: "automated tests" },
{ value: "2010", label: "automated tests" },
{ value: "325", label: "security tests" },
{ value: "0", label: "formula libraries used" },
],
Expand Down
2 changes: 1 addition & 1 deletion src/i18n/th.ts
Original file line number Diff line number Diff line change
Expand Up @@ -432,7 +432,7 @@ export const th: Messages = {
stats: [
{ value: "37", label: "สูตรพร้อมใช้" },
{ value: "66", label: "ฟังก์ชันในเอนจิน" },
{ value: "2007", label: "เทสต์อัตโนมัติ" },
{ value: "2010", label: "เทสต์อัตโนมัติ" },
{ value: "325", label: "เทสต์ด้านความปลอดภัย" },
{ value: "0", label: "ไลบรารีคำนวณสูตร" },
],
Expand Down
85 changes: 85 additions & 0 deletions src/store/liveSortRefused.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
// Copyright 2026 Suruch Chakrapeesirisuk
// SPDX-License-Identifier: Apache-2.0

import { beforeEach, describe, expect, it } from "vitest";
import type { TableData } from "@/lib/dataSources/types";
import { useSheetStore } from "./sheetStore";

/**
* A sort over a live block (#235).
*
* The sort moved whole rows, live cells and all, and was said as done. The next refresh wrote the
* block back in the source's order while the notes typed beside it stayed sorted, so a note sat on
* another product's row without a word. A sort whose range reaches a live cell is refused whole.
*/
const state = () => useSheetStore.getState();
const cells = () => state().sheets[0].sheet.cells;
const select = (startRow: number, startCol: number, endRow = startRow, endCol = startCol) =>
state().setSelection({ startRow, startCol, endRow, endCol, anchorRow: startRow, anchorCol: startCol });
const column = (c: number, from = 1, to = 3) => cells().slice(from, to + 1).map((r) => r[c]);

const TABLE: TableData = {
columns: [
{ key: "sku", label: "sku", numeric: false },
{ key: "price", label: "price", numeric: true },
],
rows: [
["CF-01", 50],
["BK-01", 30],
["BK-02", 40],
],
fetchedAt: new Date(0).toISOString(),
};
const refresh = () => state().applyLiveData("sales", TABLE);

beforeEach(() => {
state().startBlank();
state().dismissRefusal();
useSheetStore.temporal.getState().clear();
// The block is A1:B4 (header + three rows); C2:C4 are the person's notes, one per product.
select(0, 0);
state().addLiveBlock({ sourceId: "sales", anchorRow: 0, anchorCol: 0, kind: "table" }, TABLE);
state().setCellRaw(1, 2, "n-CF-01");
state().setCellRaw(2, 2, "n-BK-01");
state().setCellRaw(3, 2, "n-BK-02");
});

describe("a sort that reaches a live block is refused whole and said (#235)", () => {
it("from a live column: nothing moves, the note names the cells, and a refresh leaves every note on its product", () => {
select(1, 1);
state().sortSelection(false);
expect(column(0)).toEqual(["CF-01", "BK-01", "BK-02"]);
expect(column(2)).toEqual(["n-CF-01", "n-BK-01", "n-BK-02"]);
expect(state().announcement?.text).toMatch(/^A2:B4 (เป็นข้อมูลสด|is live data)/);
expect(state().refusal).toMatchObject({ row: 1, col: 1 });
refresh();
expect(column(0)).toEqual(["CF-01", "BK-01", "BK-02"]);
expect(column(2)).toEqual(["n-CF-01", "n-BK-01", "n-BK-02"]);
});

it("from the person's own column beside it, whose rows the sort would take the block with", () => {
select(2, 2);
state().sortSelection(true);
expect(column(2)).toEqual(["n-CF-01", "n-BK-01", "n-BK-02"]);
expect(state().refusal).toMatchObject({ row: 2, col: 2 });
});

it("a range that does not reach the block sorts as before", () => {
state().setCellRaw(6, 0, "3");
state().setCellRaw(7, 0, "1");
state().setCellRaw(8, 0, "2");
// Beside the block's rows too, as long as the selected range stays out of its columns.
select(1, 3, 3, 3);
state().setCellRaw(1, 3, "9");
state().setCellRaw(2, 3, "7");
state().setCellRaw(3, 3, "8");
select(1, 3, 3, 3);
state().sortSelection(true);
expect(column(3)).toEqual(["7", "8", "9"]);
expect(column(0)).toEqual(["CF-01", "BK-01", "BK-02"]);
select(7, 0);
state().sortSelection(true);
expect(column(0, 6, 8)).toEqual(["1", "2", "3"]);
expect(state().refusal).toBeNull();
});
});
7 changes: 7 additions & 0 deletions src/store/sheetStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1670,6 +1670,13 @@ export const useSheetStore = create<SheetState>()(
const selection = activeSelectionOf(s);
const computed = computeTab(sheet, s.sheets);
const range = detectSortRange(sheet, computed, selection, selection.anchorRow);
// A sort that moves live rows is undone by the next refresh, which writes the block back
// in the source's order while the person's own columns beside it stay sorted (#235).
const refused = refusedWrite(s, range.startRow, range.startCol, range.endRow, range.endCol, {
row: selection.anchorRow,
col: selection.anchorCol,
});
if (refused) return { ...refused, sortWarning: null };
// Asked first, not refused: sometimes a running total is meant to be re-run on new
// rows. But a sort that silently gives each row another row's numbers is how #48 cost
// a tester 2,710 baht, so it is never silent.
Expand Down
Loading