add block aggregates count/min/max/product, Ctrl+W close sheet

This commit is contained in:
Horizontal42 2026-07-01 19:58:54 +03:00
parent 55a2b6a492
commit 532c3265ad
7 changed files with 75 additions and 5 deletions

View file

@ -231,6 +231,21 @@ describe("variables and document context", () => {
it("prev", () => { it("prev", () => {
expect(calcDoc("42\nprev + 1")).toEqual(["42", "43"]); expect(calcDoc("42\nprev + 1")).toEqual(["42", "43"]);
}); });
it("count", () => {
expect(calcDoc("10\n20\n30\ncount")).toEqual(["10", "20", "30", "3"]);
expect(calcDoc("10\n20\n\n30\ncount")).toEqual(["10", "20", null, "30", "1"]);
});
it("min / max", () => {
expect(calcDoc("10\n20\n5\nmin")).toEqual(["10", "20", "5", "5"]);
expect(calcDoc("10\n20\n5\nmax")).toEqual(["10", "20", "5", "20"]);
});
it("min / max with units", () => {
expect(calcDoc("10 m\n5 m\n20 m\nmin")).toEqual(["10 m", "5 m", "20 m", "5 m"]);
expect(calcDoc("10 m\n5 m\n20 m\nmax")).toEqual(["10 m", "5 m", "20 m", "20 m"]);
});
it("product", () => {
expect(calcDoc("2\n3\n4\nproduct")).toEqual(["2", "3", "4", "24"]);
});
it("headers split blocks and produce no result", () => { it("headers split blocks and produce no result", () => {
expect(calcDoc("# Food\n10\n20\nsum")).toEqual([null, "10", "20", "30"]); expect(calcDoc("# Food\n10\n20\nsum")).toEqual([null, "10", "20", "30"]);
}); });

View file

@ -395,7 +395,7 @@ function evalDateArith(op: string, l: Value, r: Value): Value {
return { ...date, ms, hasTime }; return { ...date, ms, hasTime };
} }
function evalAgg(name: "sum" | "avg" | "prev", ctx: EvalCtx): Value { function evalAgg(name: "sum" | "avg" | "prev" | "count" | "min" | "max" | "product", ctx: EvalCtx): Value {
const { lineValues, lineKinds, index } = ctx.line; const { lineValues, lineKinds, index } = ctx.line;
if (name === "prev") { if (name === "prev") {
for (let i = index - 1; i >= 0; i--) { for (let i = index - 1; i >= 0; i--) {
@ -416,6 +416,21 @@ function evalAgg(name: "sum" | "avg" | "prev", ctx: EvalCtx): Value {
} }
if (block.length === 0) throw new EvalError("nothing to aggregate"); if (block.length === 0) throw new EvalError("nothing to aggregate");
block.reverse(); block.reverse();
if (name === "count") return qty(new Decimal(block.length));
if (name === "min" || name === "max") {
const refDim = block[0].unit?.dimension ?? null;
const compatible = block.filter((q) => (q.unit?.dimension ?? null) === refDim);
return compatible.reduce((best, q) =>
(name === "min" ? toBase(q).lt(toBase(best)) : toBase(q).gt(toBase(best))) ? q : best
);
}
if (name === "product") {
return qty(block.reduce((acc, q) => acc.mul(q.value), new Decimal(1)));
}
let acc = block[0]; let acc = block[0];
let counted = 1; let counted = 1;
for (let i = 1; i < block.length; i++) { for (let i = 1; i < block.length; i++) {

View file

@ -19,7 +19,7 @@ export type Node =
| { k: "const"; name: "pi" | "e" | "half" | "onehalf" } | { k: "const"; name: "pi" | "e" | "half" | "onehalf" }
| { k: "var"; name: string } | { k: "var"; name: string }
| { k: "date"; word: DateWord } | { k: "date"; word: DateWord }
| { k: "agg"; name: "sum" | "avg" | "prev" } | { k: "agg"; name: "sum" | "avg" | "prev" | "count" | "min" | "max" | "product" }
| { k: "bin"; op: "plus" | "minus" | "mul" | "div" | "mod" | "pow"; l: Node; r: Node } | { k: "bin"; op: "plus" | "minus" | "mul" | "div" | "mod" | "pow"; l: Node; r: Node }
| { k: "bit"; op: BitOp; l: Node; r: Node } | { k: "bit"; op: BitOp; l: Node; r: Node }
| { k: "pctop"; op: PctOp; l: Node; r: Node } | { k: "pctop"; op: PctOp; l: Node; r: Node }

View file

@ -27,7 +27,7 @@ export type Payload =
| { t: "pctop"; op: PctOp } | { t: "pctop"; op: PctOp }
| { t: "percent" } | { t: "percent" }
| { t: "func"; name: string } | { t: "func"; name: string }
| { t: "agg"; name: "sum" | "avg" | "prev" } | { t: "agg"; name: "sum" | "avg" | "prev" | "count" | "min" | "max" | "product" }
| { t: "scale"; mult: Decimal } | { t: "scale"; mult: Decimal }
| { t: "repr"; repr: NumeralRepr } | { t: "repr"; repr: NumeralRepr }
| { t: "special"; name: "unix" | "todate" } | { t: "special"; name: "unix" | "todate" }
@ -170,6 +170,10 @@ export function buildRegistry(): Registry {
for (const v of vocab.variants("Operations", "sum.variants")) reg.addPhrase(v, { t: "agg", name: "sum" }, { caseSensitive: false }); for (const v of vocab.variants("Operations", "sum.variants")) reg.addPhrase(v, { t: "agg", name: "sum" }, { caseSensitive: false });
for (const v of vocab.variants("Operations", "avg.variants")) reg.addPhrase(v, { t: "agg", name: "avg" }, { caseSensitive: false }); for (const v of vocab.variants("Operations", "avg.variants")) reg.addPhrase(v, { t: "agg", name: "avg" }, { caseSensitive: false });
for (const v of vocab.variants("Operations", "prev.variants")) reg.addPhrase(v, { t: "agg", name: "prev" }, { caseSensitive: false }); for (const v of vocab.variants("Operations", "prev.variants")) reg.addPhrase(v, { t: "agg", name: "prev" }, { caseSensitive: false });
for (const v of vocab.variants("Operations", "count.variants")) reg.addPhrase(v, { t: "agg", name: "count" }, { caseSensitive: false });
for (const v of vocab.variants("Operations", "min.variants")) reg.addPhrase(v, { t: "agg", name: "min" }, { caseSensitive: false });
for (const v of vocab.variants("Operations", "max.variants")) reg.addPhrase(v, { t: "agg", name: "max" }, { caseSensitive: false });
for (const v of vocab.variants("Operations", "product.variants")) reg.addPhrase(v, { t: "agg", name: "product" }, { caseSensitive: false });
for (const v of vocab.variants("Operations", "sqrt.variants")) reg.addPhrase(v, { t: "func", name: "sqrt" }, { caseSensitive: false }); for (const v of vocab.variants("Operations", "sqrt.variants")) reg.addPhrase(v, { t: "func", name: "sqrt" }, { caseSensitive: false });
for (const v of vocab.variants("Operations", "cbrt.variants")) reg.addPhrase(v, { t: "func", name: "cbrt" }, { caseSensitive: false }); for (const v of vocab.variants("Operations", "cbrt.variants")) reg.addPhrase(v, { t: "func", name: "cbrt" }, { caseSensitive: false });
for (const v of vocab.variants("Operations", "abs.variants")) reg.addPhrase(v, { t: "func", name: "abs" }, { caseSensitive: false }); for (const v of vocab.variants("Operations", "abs.variants")) reg.addPhrase(v, { t: "func", name: "abs" }, { caseSensitive: false });

View file

@ -427,7 +427,11 @@ export const EN: VocabLocale = {
"ln.variants": "natural log,natural logarithm", "ln.variants": "natural log,natural logarithm",
"log.variants": "logarithm", "log.variants": "logarithm",
"round.variants": "rounded", "round.variants": "rounded",
"sum.variants": "sum,total" "sum.variants": "sum,total",
"count.variants": "count,how many",
"min.variants": "min,minimum,smallest",
"max.variants": "max,maximum,largest",
"product.variants": "product"
}, },
"Percent": { "Percent": {
"as_a_percent_of.variants": "as a % of,as a percent of", "as_a_percent_of.variants": "as a % of,as a percent of",
@ -899,7 +903,11 @@ export const RU: VocabLocale = {
"ln.variants": "натуральный логарифм,нат. логарифм", "ln.variants": "натуральный логарифм,нат. логарифм",
"log.variants": "логарифм", "log.variants": "логарифм",
"round.variants": "округлить,округление", "round.variants": "округлить,округление",
"sum.variants": "всего,сумма,итог,итого" "sum.variants": "всего,сумма,итог,итого",
"count.variants": "количество,кол-во,счёт,число строк",
"min.variants": "минимум,мин,наименьшее",
"max.variants": "максимум,макс,наибольшее",
"product.variants": "произведение,произв"
}, },
"Percent": { "Percent": {
"as_a_percent_of.variants": "как процент от,как процент", "as_a_percent_of.variants": "как процент от,как процент",

View file

@ -55,6 +55,9 @@ const dict: Record<string, Record<string, string>> = {
updateFailed: "Update failed", updateFailed: "Update failed",
searchPlaceholder: "Search all sheets…", searchPlaceholder: "Search all sheets…",
searchEmpty: "No matches", searchEmpty: "No matches",
closeSheet: "Close this sheet?",
close: "Close",
cancel: "Cancel",
}, },
ru: { ru: {
settings: "Настройки", settings: "Настройки",
@ -110,6 +113,9 @@ const dict: Record<string, Record<string, string>> = {
updateFailed: "Не удалось обновить", updateFailed: "Не удалось обновить",
searchPlaceholder: "Поиск по всем листам…", searchPlaceholder: "Поиск по всем листам…",
searchEmpty: "Ничего не найдено", searchEmpty: "Ничего не найдено",
closeSheet: "Закрыть этот лист?",
close: "Закрыть",
cancel: "Отмена",
}, },
}; };

View file

@ -111,6 +111,24 @@ function newDoc(content = ""): void {
switchDoc(id); switchDoc(id);
} }
async function closeActiveDoc(): Promise<void> {
if (data.docs.length <= 1) return;
const doc = data.docs.find((d) => d.id === data.activeId);
if (!doc) return;
const content = data.contents[doc.id] ?? "";
if (content.trim()) {
const ans = await askModal(t("closeSheet"), t("close"), t("cancel"));
if (ans !== "a") return;
}
const idx = data.docs.findIndex((d) => d.id === doc.id);
void backupDeletedSheet(settings.dataDir, doc.title, content);
delete data.contents[doc.id];
data.docs = data.docs.filter((d) => d.id !== doc.id);
const nextId = data.docs[idx] ? data.docs[idx].id : data.docs[idx - 1]?.id ?? data.docs[0].id;
switchDoc(nextId);
saveAppData(data);
}
// ---------- search all sheets // ---------- search all sheets
interface SearchHit { interface SearchHit {
@ -682,6 +700,10 @@ async function boot(): Promise<void> {
e.preventDefault(); e.preventDefault();
newDoc(); newDoc();
} }
if (e.ctrlKey && !e.shiftKey && e.key.toLowerCase() === "w") {
e.preventDefault();
void closeActiveDoc();
}
if (e.ctrlKey && !e.shiftKey && e.key.toLowerCase() === "b") { if (e.ctrlKey && !e.shiftKey && e.key.toLowerCase() === "b") {
e.preventDefault(); e.preventDefault();
$("#toggle-sidebar").click(); $("#toggle-sidebar").click();