diff --git a/shared/editor/commands/table.ts b/shared/editor/commands/table.ts index 019487f6cf..7ce67901a1 100644 --- a/shared/editor/commands/table.ts +++ b/shared/editor/commands/table.ts @@ -20,6 +20,7 @@ import { } from "prosemirror-tables"; import { ProsemirrorHelper } from "../../utils/ProsemirrorHelper"; import { CSVHelper } from "../../utils/csv"; +import { isCurrency, parseCurrency } from "../../utils/currency"; import { parseDate } from "../../utils/date"; import { chainTransactions } from "../lib/chainTransactions"; import { @@ -336,8 +337,9 @@ export function sortTable({ // column data before sort const columnData = table.map((row) => row[index]?.textContent ?? ""); - // determine sorting type: date, number, or text + // determine sorting type: date, currency, number, or text let compareAsDate = false; + let compareAsCurrency = false; let compareAsNumber = false; const nonEmptyCells = table @@ -346,11 +348,24 @@ export function sortTable({ if (nonEmptyCells.length > 0) { // check if all non-empty cells are valid dates compareAsDate = nonEmptyCells.every((cell) => parseDate(cell) !== null); - // if not dates, check if all non-empty cells are numbers + + // if not dates, check if cells are currency values + // treat as currency if at least 50% of non-empty cells look like currency values if (!compareAsDate) { - compareAsNumber = nonEmptyCells.every( + const currencyCells = nonEmptyCells.filter((cell) => + isCurrency(cell) + ); + const currencyRatio = currencyCells.length / nonEmptyCells.length; + compareAsCurrency = currencyCells.length >= 2 && currencyRatio >= 0.5; + } + + // if not currency, check if cells are numbers (same logic) + if (!compareAsDate && !compareAsCurrency) { + const numberCells = nonEmptyCells.filter( (cell) => !isNaN(parseFloat(cell)) ); + const numberRatio = numberCells.length / nonEmptyCells.length; + compareAsNumber = numberCells.length >= 2 && numberRatio >= 0.5; } } @@ -370,12 +385,36 @@ export function sortTable({ if (compareAsDate) { const aDate = parseDate(aContent); const bDate = parseDate(bContent); - if (aDate && bDate) { - return aDate.getTime() - bDate.getTime(); + // non-date cells go to the end (like empty cells) + if (!aDate) { + return bDate ? 1 : 0; } - return 0; + if (!bDate) { + return -1; + } + return aDate.getTime() - bDate.getTime(); + } else if (compareAsCurrency) { + const aValue = parseCurrency(aContent); + const bValue = parseCurrency(bContent); + // non-currency cells go to the end (like empty cells) + if (aValue === null) { + return bValue !== null ? 1 : 0; + } + if (bValue === null) { + return -1; + } + return aValue - bValue; } else if (compareAsNumber) { - return parseFloat(aContent) - parseFloat(bContent); + const aNum = parseFloat(aContent); + const bNum = parseFloat(bContent); + // non-number cells go to the end (like empty cells) + if (isNaN(aNum)) { + return !isNaN(bNum) ? 1 : 0; + } + if (isNaN(bNum)) { + return -1; + } + return aNum - bNum; } else { return aContent.localeCompare(bContent); } diff --git a/shared/utils/currency.test.ts b/shared/utils/currency.test.ts new file mode 100644 index 0000000000..6ef817c960 --- /dev/null +++ b/shared/utils/currency.test.ts @@ -0,0 +1,114 @@ +import { isCurrency, parseCurrency } from "./currency"; + +describe("isCurrency", () => { + it("recognizes USD format", () => { + expect(isCurrency("$100")).toBe(true); + expect(isCurrency("$400")).toBe(true); + expect(isCurrency("$3000")).toBe(true); + expect(isCurrency("$1,234.56")).toBe(true); + expect(isCurrency("$1234.56")).toBe(true); + }); + + it("recognizes Euro format", () => { + expect(isCurrency("€100")).toBe(true); + expect(isCurrency("100€")).toBe(true); + expect(isCurrency("€1.234,56")).toBe(true); + expect(isCurrency("1.234,56€")).toBe(true); + }); + + it("recognizes other currency symbols", () => { + expect(isCurrency("£500")).toBe(true); + expect(isCurrency("¥1000")).toBe(true); + expect(isCurrency("₹50,000")).toBe(true); + expect(isCurrency("R$1.234,56")).toBe(true); + }); + + it("recognizes negative currency values", () => { + expect(isCurrency("-$100")).toBe(true); + expect(isCurrency("($100)")).toBe(true); + expect(isCurrency("-€50")).toBe(true); + }); + + it("returns false for non-currency values", () => { + expect(isCurrency("100")).toBe(false); + expect(isCurrency("hello")).toBe(false); + expect(isCurrency("")).toBe(false); + expect(isCurrency("1,234.56")).toBe(false); + }); +}); + +describe("parseCurrency", () => { + it("parses USD format", () => { + expect(parseCurrency("$100")).toBe(100); + expect(parseCurrency("$400")).toBe(400); + expect(parseCurrency("$3000")).toBe(3000); + expect(parseCurrency("$1,234.56")).toBe(1234.56); + expect(parseCurrency("$1234.56")).toBe(1234.56); + expect(parseCurrency("$0.99")).toBe(0.99); + }); + + it("parses Euro format (European style)", () => { + expect(parseCurrency("€100")).toBe(100); + expect(parseCurrency("100€")).toBe(100); + expect(parseCurrency("€1.234,56")).toBe(1234.56); + expect(parseCurrency("1.234,56€")).toBe(1234.56); + expect(parseCurrency("€1234,56")).toBe(1234.56); + }); + + it("parses other currencies", () => { + expect(parseCurrency("£500")).toBe(500); + expect(parseCurrency("¥1000")).toBe(1000); + expect(parseCurrency("₹50,000")).toBe(50000); + expect(parseCurrency("R$1.234,56")).toBe(1234.56); + }); + + it("parses negative values", () => { + expect(parseCurrency("-$100")).toBe(-100); + expect(parseCurrency("($100)")).toBe(-100); + expect(parseCurrency("-€50")).toBe(-50); + expect(parseCurrency("($1,234.56)")).toBe(-1234.56); + }); + + it("handles whitespace", () => { + expect(parseCurrency(" $100 ")).toBe(100); + expect(parseCurrency("$ 100")).toBe(100); + expect(parseCurrency("100 €")).toBe(100); + }); + + it("returns null for invalid values", () => { + expect(parseCurrency("")).toBe(null); + expect(parseCurrency("hello")).toBe(null); + expect(parseCurrency("abc$123")).toBe(null); + }); + + it("handles large numbers", () => { + expect(parseCurrency("$1,000,000.00")).toBe(1000000); + expect(parseCurrency("€1.000.000,00")).toBe(1000000); + }); + + it("sorts currency values correctly", () => { + const values = ["$400", "$3000", "$100", "$50"]; + const sorted = values.sort((a, b) => { + const aVal = parseCurrency(a); + const bVal = parseCurrency(b); + if (aVal !== null && bVal !== null) { + return aVal - bVal; + } + return 0; + }); + expect(sorted).toEqual(["$50", "$100", "$400", "$3000"]); + }); + + it("sorts currency values in descending order correctly", () => { + const values = ["$400", "$3000", "$100", "$50"]; + const sorted = values.sort((a, b) => { + const aVal = parseCurrency(a); + const bVal = parseCurrency(b); + if (aVal !== null && bVal !== null) { + return bVal - aVal; // descending + } + return 0; + }); + expect(sorted).toEqual(["$3000", "$400", "$100", "$50"]); + }); +}); diff --git a/shared/utils/currency.ts b/shared/utils/currency.ts new file mode 100644 index 0000000000..d16f1df348 --- /dev/null +++ b/shared/utils/currency.ts @@ -0,0 +1,178 @@ +/** + * Common currency symbols used around the world. + */ +const currencySymbols = [ + "$", // Dollar (USD, CAD, AUD, etc.) + "€", // Euro + "£", // Pound + "¥", // Yen/Yuan + "₹", // Indian Rupee + "₽", // Russian Ruble + "₿", // Bitcoin + "₩", // Korean Won + "₪", // Israeli Shekel + "₺", // Turkish Lira + "₴", // Ukrainian Hryvnia + "₱", // Philippine Peso + "฿", // Thai Baht + "₫", // Vietnamese Dong + "₦", // Nigerian Naira + "₵", // Ghanaian Cedi + "₡", // Costa Rican Colón + "₲", // Paraguayan Guaraní + "₸", // Kazakhstani Tenge + "₼", // Azerbaijani Manat + "₾", // Georgian Lari + "৳", // Bangladeshi Taka + "₠", // European Currency Unit + "R$", // Brazilian Real + "kr", // Scandinavian Krona/Krone + "zł", // Polish Zloty + "Kč", // Czech Koruna + "Ft", // Hungarian Forint + "CHF", // Swiss Franc + "лв", // Bulgarian Lev + "lei", // Romanian Leu + "ден", // Macedonian Denar + "дин", // Serbian Dinar + "ر.س", // Saudi Riyal + "د.إ", // UAE Dirham + "ر.ع", // Omani Rial + "د.ك", // Kuwaiti Dinar + "د.ب", // Bahraini Dinar + "ر.ق", // Qatari Riyal +]; + +/** + * Checks if a string appears to be a currency value. + * Matches formats like: $1,234.56, €50, £1.000,50, -$500, ($500), 1234¥ + * + * @param value - the string to check. + * @returns true if the string appears to be a currency value. + */ +export function isCurrency(value: string): boolean { + if (!value || value.trim().length === 0) { + return false; + } + + const trimmed = value.trim(); + + // Must contain at least one currency symbol + const hasCurrencySymbol = currencySymbols.some((symbol) => + trimmed.includes(symbol) + ); + if (!hasCurrencySymbol) { + return false; + } + + // Must contain at least one digit + if (!/\d/.test(trimmed)) { + return false; + } + + // Remove all valid currency characters and check if anything unexpected remains + let remaining = trimmed; + + // Remove currency symbols (longest first to handle multi-char symbols like R$) + const sortedSymbols = [...currencySymbols].sort( + (a, b) => b.length - a.length + ); + for (const symbol of sortedSymbols) { + remaining = remaining.split(symbol).join(""); + } + + // Remove digits, separators, whitespace, and negative indicators + remaining = remaining.replace(/[\d.,\s()\-]/g, ""); + + // If anything remains, it's not a valid currency + return remaining.length === 0; +} + +/** + * Parses a currency string and returns its numeric value. + * Handles various formats including: + * - US/UK style: $1,234.56 + * - European style: €1.234,56 + * - Negative values: -$500, ($500), -500€ + * - Currency symbol before or after the number + * + * @param value - the currency string to parse. + * @returns the numeric value, or null if parsing fails. + */ +export function parseCurrency(value: string): number | null { + if (!value || value.trim().length === 0) { + return null; + } + + let trimmed = value.trim(); + + // Detect negative values: parentheses indicate negative in accounting + const isNegative = + trimmed.startsWith("(") || + trimmed.startsWith("-") || + trimmed.includes(")-") || + (trimmed.endsWith(")") && trimmed.includes("(")); + + // Remove currency symbols, parentheses, and whitespace + // Sort symbols by length descending so multi-character symbols (like R$) are removed first + let cleaned = trimmed; + const sortedSymbols = [...currencySymbols].sort( + (a, b) => b.length - a.length + ); + for (const symbol of sortedSymbols) { + cleaned = cleaned.split(symbol).join(""); + } + cleaned = cleaned + .replace(/[()]/g, "") + .replace(/\s/g, "") + .replace(/^-|-$/g, ""); + + // Determine the decimal separator by looking at the last separator + // European format uses comma as decimal: 1.234,56 + // US/UK format uses period as decimal: 1,234.56 + const lastComma = cleaned.lastIndexOf(","); + const lastPeriod = cleaned.lastIndexOf("."); + const hasComma = lastComma !== -1; + const hasPeriod = lastPeriod !== -1; + + if (hasComma && hasPeriod) { + // Both separators present - the one that appears last is the decimal + if (lastComma > lastPeriod) { + // European format: comma is the decimal separator + // Remove periods (thousands separator) and replace comma with period + cleaned = cleaned.replace(/\./g, "").replace(",", "."); + } else { + // US/UK format: period is the decimal separator + // Remove commas (thousands separator) + cleaned = cleaned.replace(/,/g, ""); + } + } else if (hasComma) { + // Only commas present - could be thousands separator or decimal + // If there's exactly one comma and 1-2 digits after it, treat as decimal + const parts = cleaned.split(","); + if (parts.length === 2 && parts[1].length <= 2) { + cleaned = cleaned.replace(",", "."); + } else { + // Multiple commas or 3+ digits after comma = thousands separator + cleaned = cleaned.replace(/,/g, ""); + } + } else if (hasPeriod) { + // Only periods present - could be thousands separator or decimal + // If there's exactly one period and 1-2 digits after it, treat as decimal + const parts = cleaned.split("."); + if (parts.length === 2 && parts[1].length <= 2) { + // Already in correct format + } else { + // Multiple periods or 3+ digits after period = thousands separator + cleaned = cleaned.replace(/\./g, ""); + } + } + + const numericValue = parseFloat(cleaned); + + if (isNaN(numericValue)) { + return null; + } + + return isNegative ? -Math.abs(numericValue) : numericValue; +}