From 7126f6cc0cf5e139de0aeb6f38408964e1821c8d Mon Sep 17 00:00:00 2001 From: Yaroslav Ermilov <25526713+ya-erm@users.noreply.github.com> Date: Sat, 18 Nov 2023 04:20:10 +0300 Subject: [PATCH 1/7] Calc expressions in comments --- src/lib/calc/calculator.ts | 223 ++++++++++++++++++ src/lib/calc/types.ts | 159 +++++++++++++ src/lib/utils/calc.ts | 18 ++ .../transactions/TransactionListItem.svelte | 3 +- .../transactions/form/TransactionForm.svelte | 22 +- 5 files changed, 423 insertions(+), 2 deletions(-) create mode 100644 src/lib/calc/calculator.ts create mode 100644 src/lib/calc/types.ts create mode 100644 src/lib/utils/calc.ts diff --git a/src/lib/calc/calculator.ts b/src/lib/calc/calculator.ts new file mode 100644 index 0000000..e8bf9ae --- /dev/null +++ b/src/lib/calc/calculator.ts @@ -0,0 +1,223 @@ +// Original source: +// https://github.com/ya-erm/calculator-react/blob/dev/src/model/Calculator.ts + +import { CalculationToken } from './types'; + +class CalculatorError extends Error { + message: string; + position?: number; + constructor(message: string, position?: number) { + super(); + this.message = message; + this.position = position; + } +} + +const parse: (expression: string) => CalculationToken[] = (expression) => { + const tokens: CalculationToken[] = []; + let previous: CalculationToken | null = null; + let numberChars: string[] = []; + let bracketsCount = 0; + let unary = false; + + const checkToken = (token: CalculationToken, position: number) => { + if (!previous) { + if (!token.canBeFirst()) { + throw new CalculatorError(`Token ${token} can't be first`, position); + } + } else if (!token.canBeAfter(previous)) { + throw new CalculatorError(`Token ${token} can't be after ${previous.type}`, position); + } + if (position === expression.length - 1 && !token.canBeLast()) { + throw new CalculatorError(`Token ${token} can't be last`, position); + } + }; + + const processNumber = (position: number) => { + let numberText = numberChars.join(''); + if (previous?.type === 'minus' && unary) { + numberText = `-${numberText}`; + tokens.pop(); + previous = null; + unary = false; + } + const numberToken = CalculationToken.parse(numberText); + if (!numberToken) { + throw new CalculatorError(`Failed to initialize number token from "${numberText}"`, position); + } + checkToken(numberToken, position); + tokens.push(numberToken); + previous = numberToken; + numberChars = []; + }; + + for (let i = 0; i < expression.length; i++) { + const symbol = expression[i]; + if (symbol === ' ') { + continue; + } + if (symbol.match(/\d/) || symbol === '.') { + numberChars.push(symbol); + continue; + } + if (numberChars.length > 0) { + processNumber(i); + } + const token = CalculationToken.parse(symbol); + if (!token) { + throw new CalculatorError(`Unsupported symbol "${symbol}"`, i); + } + switch (token.type) { + case 'minus': + switch (previous?.type) { + case 'leftBracket': + case 'pow': + case null: + case undefined: + unary = true; + break; + default: + unary = false; + break; + } + break; + case 'leftBracket': + bracketsCount += 1; + break; + case 'rightBracket': + bracketsCount -= 1; + break; + default: + break; + } + if (bracketsCount < 0) { + throw new CalculatorError('Closed brackets more than opened', i); + } + checkToken(token, i); + tokens.push(token); + previous = token; + } + if (numberChars.length > 0) { + processNumber(expression.length - 1); + } + if (bracketsCount > 0) { + throw new CalculatorError('Opened brackets more than closed', expression.length - 1); + } + + return tokens; +}; + +const performOperation = (operationToken: CalculationToken, left: number, right: number) => { + switch (operationToken.type) { + case 'plus': + return left + right; + case 'minus': + return left - right; + case 'multiply': + return left * right; + case 'divide': + return left / right; + case 'mod': + return left % right; + case 'pow': + return Math.pow(left, right); + case 'leftBracket': + case 'rightBracket': + case 'number': + throw Error(`${operationToken.type} is not operationToken`); + } +}; + +export const calculate = (expression: string, previous: number | null) => { + let leftStack: CalculationToken[] = []; + const rightStack = parse(expression); + let accumulator: number | null = null; + + if (previous) { + rightStack[0] = new CalculationToken('number', previous.toString()); + } + + const scroll = () => { + const accumulatedValue = accumulator; + if (accumulatedValue !== null) { + const newToken = new CalculationToken('number', accumulatedValue.toString()); + leftStack = [newToken, ...leftStack]; + accumulator = null; + } + do { + const token = rightStack.shift(); + if (!token) { + return; + } + switch (token.type) { + case 'number': { + accumulator = parseFloat(token.value!); + return; + } + default: { + leftStack = [token, ...leftStack]; + break; + } + } + } while (rightStack.length > 0); + }; + + scroll(); + + // eslint-disable-next-line no-constant-condition + while (true) { + let leftToken = leftStack[0]; + const rightToken = rightStack[0]; + let delta = (leftToken?.leftPriority() ?? 0) - (rightToken?.rightPriority() ?? 0); + if (leftToken && delta > 0) { + const operation = leftStack.shift(); + const leftOperand = leftStack.shift(); + if (!operation || !leftOperand) { + return; + } + switch (leftOperand.type) { + case 'number': { + const value = parseFloat(leftOperand.value!); + const result = performOperation(operation, value, accumulator ?? 0); + accumulator = result; + leftToken = leftStack[0]; + delta = (leftToken?.leftPriority() ?? 0) - (rightToken?.rightPriority() ?? 0); + break; + } + default: { + throw new Error(`Left operand ${leftOperand.type} is not number`); + } + } + } + if (rightToken && rightToken?.type !== 'number' && delta < 0) { + scroll(); + } + if (leftToken?.type === 'leftBracket' && rightToken?.type === 'rightBracket') { + leftStack.shift(); + rightStack.shift(); + } + if (leftStack.length === 0 && rightStack.length === 0) { + break; + } + } + + return accumulator; +}; + +export function roundTo(n: number, digits?: number) { + let negative = false; + if (digits === undefined) { + digits = 0; + } + if (n < 0) { + negative = true; + n = -1 * n; + } + const m = Math.pow(10, digits); + let r = parseFloat((n * m).toFixed(11)); + r = Number((Math.round(r) / m).toFixed(digits)); + if (negative) { + r = -1 * r; + } + return r; +} diff --git a/src/lib/calc/types.ts b/src/lib/calc/types.ts new file mode 100644 index 0000000..7974a51 --- /dev/null +++ b/src/lib/calc/types.ts @@ -0,0 +1,159 @@ +// Original source: +// https://github.com/ya-erm/calculator-react/blob/dev/src/model/CalculationToken.ts + +type ICalculationTokenType = + | 'plus' + | 'minus' + | 'multiply' + | 'divide' + | 'mod' + | 'pow' + | 'leftBracket' + | 'rightBracket' + | 'number'; + +export class CalculationToken { + type: ICalculationTokenType; + value?: string; + + constructor(type: ICalculationTokenType, value?: string) { + this.type = type; + this.value = value; + } + + static parse(text: string) { + switch (text) { + case '+': + return new this('plus', text); + case '-': + return new this('minus', text); + case '*': + return new this('multiply', text); + case '/': + return new this('divide', text); + case '%': + return new this('mod', text); + case '^': + return new this('pow', text); + case '(': + return new this('leftBracket', text); + case ')': + return new this('rightBracket', text); + + default: + if (isNaN(parseFloat(text))) { + throw new Error(`Can't parse "${text}" as number token`); + } + return new this('number', text); + } + } + + canBeAfter = (previous?: CalculationToken) => { + if (previous == null) { + return this.canBeFirst(); + } + switch (this.type) { + case 'leftBracket': + case 'number': + switch (previous.type) { + case 'rightBracket': + case 'number': + return false; + default: + return true; + } + case 'multiply': + case 'divide': + case 'mod': + case 'pow': + case 'rightBracket': + switch (previous.type) { + case 'rightBracket': + case 'number': + return true; + default: + return false; + } + case 'plus': + case 'minus': + switch (previous.type) { + case 'leftBracket': + case 'rightBracket': + case 'number': + return true; + default: + return false; + } + } + }; + + canBeFirst = () => { + switch (this.type) { + case 'plus': + case 'minus': + case 'leftBracket': + case 'number': + return true; + case 'multiply': + case 'divide': + case 'mod': + case 'pow': + case 'rightBracket': + return false; + } + }; + + canBeLast = () => { + switch (this.type) { + case 'rightBracket': + case 'number': + return true; + default: + return false; + } + }; + + canBeUnary = () => { + switch (this.type) { + case 'minus': + case 'plus': + return true; + default: + return false; + } + }; + + isNumber = () => this.type === 'number'; + + leftPriority = () => { + switch (this.type) { + case 'plus': + case 'minus': + return 2; + case 'multiply': + case 'divide': + case 'mod': + return 4; + case 'pow': + return 5; + default: + return 0; + } + }; + + rightPriority = () => { + switch (this.type) { + case 'plus': + case 'minus': + return 1; + case 'multiply': + case 'divide': + case 'mod': + return 3; + case 'pow': + return 6; + default: + return 0; + } + }; +} diff --git a/src/lib/utils/calc.ts b/src/lib/utils/calc.ts new file mode 100644 index 0000000..6285925 --- /dev/null +++ b/src/lib/utils/calc.ts @@ -0,0 +1,18 @@ +import { calculate } from '$lib/calc/calculator'; + +import { Logger } from './logger'; + +const logger = new Logger('Calc'); + +export function replaceCalcExpressions(text: string) { + return text.replaceAll(/\$\{[^}]+\}/g, (substring) => { + const expression = substring.slice(2, -1); + try { + const result = calculate(expression.replaceAll(',', '.'), null); + return result?.toFixed(2) ?? substring; + } catch (e) { + logger.warn('Failed to calculate expression', { text, expression }, e); + return substring; + } + }); +} diff --git a/src/routes/transactions/TransactionListItem.svelte b/src/routes/transactions/TransactionListItem.svelte index bfbb52e..4c64a66 100644 --- a/src/routes/transactions/TransactionListItem.svelte +++ b/src/routes/transactions/TransactionListItem.svelte @@ -2,6 +2,7 @@ import type { CurrencyRate, TransactionViewModel } from '$lib/data/interfaces'; import { translate, type Messages } from '$lib/translate'; import Icon from '$lib/ui/Icon.svelte'; + import { replaceCalcExpressions } from '$lib/utils/calc'; import { formatMoney } from '$lib/utils/formatMoney'; export let transaction: TransactionViewModel; @@ -63,7 +64,7 @@ {#if transaction.comment}
- {transaction.comment} + {replaceCalcExpressions(transaction.comment)}
{/if} {#if transaction.tags?.length} diff --git a/src/routes/transactions/form/TransactionForm.svelte b/src/routes/transactions/form/TransactionForm.svelte index 14cfb03..78479fd 100644 --- a/src/routes/transactions/form/TransactionForm.svelte +++ b/src/routes/transactions/form/TransactionForm.svelte @@ -11,6 +11,7 @@ import InputLabel from '$lib/ui/InputLabel.svelte'; import { showErrorToast } from '$lib/ui/toasts'; import { formatMoney, getSearchParam } from '$lib/utils'; + import { replaceCalcExpressions } from '$lib/utils/calc'; import { checkNumberFormParameter, checkStringFormParameter, @@ -65,6 +66,8 @@ let selectedTags = transaction?.tags.map((t) => `${t.id}`) ?? []; + let comment = transaction?.comment ?? ''; + const handleSubmit = async (e: Event) => { const formData = new FormData(e.target as HTMLFormElement); if (!formData.get('accountId')) { @@ -193,7 +196,18 @@ {/if} - + (comment = value)} + optional + /> + {#if comment && replaceCalcExpressions(comment) !== comment} +
+ {replaceCalcExpressions(comment)} +
+ {/if}
From a327d1899e39c70d5484840e316a34eab8adaaa2 Mon Sep 17 00:00:00 2001 From: Yaroslav Ermilov <25526713+ya-erm@users.noreply.github.com> Date: Sat, 18 Nov 2023 04:20:30 +0300 Subject: [PATCH 2/7] Show error toast when filed to fetch updates --- src/lib/data/journal.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/lib/data/journal.ts b/src/lib/data/journal.ts index 5c7d352..154e039 100644 --- a/src/lib/data/journal.ts +++ b/src/lib/data/journal.ts @@ -5,6 +5,7 @@ import type { } from '$lib/server/api/v2/journal'; import type { GetJournalRequest } from '$lib/server/api/v2/journal/getJournal'; import { store } from '$lib/store'; +import { showErrorToast } from '$lib/ui/toasts'; import { unexpectedCase } from '$lib/utils'; import { Logger } from '$lib/utils/logger'; import { useFetch } from '$lib/utils/useFetch'; @@ -147,6 +148,11 @@ export class JournalService implements Initialisable { await this.fetchUpdates(); } catch (e) { logger.error('Failed to fetch updates', e); + if (e instanceof Error) { + showErrorToast(`Failed to fetch updates: ${e.message}`); + } else { + showErrorToast('Failed to fetch updates'); + } } finally { this._state.set('idle'); } From e448e0f17e9048227b28ff9985c64727c7fba64c Mon Sep 17 00:00:00 2001 From: Yaroslav Ermilov <25526713+ya-erm@users.noreply.github.com> Date: Sat, 18 Nov 2023 04:22:22 +0300 Subject: [PATCH 3/7] Version 2.6.0 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 7bdc494..fdd6314 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "client", - "version": "2.5.2", + "version": "2.6.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "client", - "version": "2.5.2", + "version": "2.6.0", "dependencies": { "@prisma/client": "^4.14.1", "@vercel/analytics": "^1.0.1", diff --git a/package.json b/package.json index 40f5bbd..80483c3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "client", - "version": "2.5.2", + "version": "2.6.0", "private": true, "scripts": { "dev": "vite dev", From 89eef045817d45bf154419f4281ec90b841416ca Mon Sep 17 00:00:00 2001 From: Yaroslav Ermilov <25526713+ya-erm@users.noreply.github.com> Date: Sat, 18 Nov 2023 05:28:46 +0300 Subject: [PATCH 4/7] Add another currency option for transaction --- src/lib/data/interfaces.ts | 2 + src/lib/translate/en.ts | 2 + src/lib/translate/messages.ts | 2 + src/lib/translate/ru.ts | 5 +- src/lib/utils/calc.ts | 6 ++ src/lib/utils/index.ts | 1 + src/lib/utils/spreadIf.ts | 21 +++++++ .../transactions/TransactionListItem.svelte | 5 ++ .../transactions/form/TransactionForm.svelte | 61 ++++++++++++++++--- 9 files changed, 96 insertions(+), 9 deletions(-) create mode 100644 src/lib/utils/spreadIf.ts diff --git a/src/lib/data/interfaces.ts b/src/lib/data/interfaces.ts index a370d8d..e92a1bb 100644 --- a/src/lib/data/interfaces.ts +++ b/src/lib/data/interfaces.ts @@ -100,6 +100,8 @@ export type Transaction = { comment?: string | null; description?: string | null; linkedTransactionId?: string | null; + anotherCurrency?: string | null; + anotherCurrencyAmount?: number | null; tagIds?: string[]; deleted?: boolean; }; diff --git a/src/lib/translate/en.ts b/src/lib/translate/en.ts index 8c138a6..daa9b6a 100644 --- a/src/lib/translate/en.ts +++ b/src/lib/translate/en.ts @@ -179,6 +179,8 @@ export const enDict: Dictionary = { 'transactions.delete_transaction_success': 'Operation was deleted', 'transactions.delete_transaction_failure': 'Failed to delete operation', 'transactions.feature_operations': 'Feature operations', + 'transactions.another_currency': 'Another currency', + 'transactions.same_currency': 'Same currency', // Transactions import 'transactions.import': 'Import', 'transactions.import.title': 'Import operations', diff --git a/src/lib/translate/messages.ts b/src/lib/translate/messages.ts index fedd983..b836604 100644 --- a/src/lib/translate/messages.ts +++ b/src/lib/translate/messages.ts @@ -173,6 +173,8 @@ export type Messages = | 'transactions.delete_transaction_success' | 'transactions.delete_transaction_failure' | 'transactions.feature_operations' + | 'transactions.another_currency' + | 'transactions.same_currency' // Transactions import | 'transactions.import' | 'transactions.import.title' diff --git a/src/lib/translate/ru.ts b/src/lib/translate/ru.ts index 2b32ac0..8ca1c15 100644 --- a/src/lib/translate/ru.ts +++ b/src/lib/translate/ru.ts @@ -178,6 +178,10 @@ export const ruDict: Dictionary = { 'transactions.delete_transaction': 'Удалить операцию', 'transactions.delete_transaction_success': 'Операция удалена', 'transactions.delete_transaction_failure': 'Не удалось удалить операцию', + 'transactions.feature_operations': 'Будущие операции', + 'transactions.another_currency': 'Другая валюта', + 'transactions.same_currency': 'Та же валюта', + // Transactions import 'transactions.import': 'Импорт', 'transactions.import.title': 'Импорт операций', 'transactions.import.invalid_expression': 'Введите поисковый запрос, чтобы выбрать операции одной категории', @@ -209,7 +213,6 @@ export const ruDict: Dictionary = { 'transactions.import.rules.delete': 'Удалить правило', 'transactions.import.rules.delete_success': 'Правило удалено', 'transactions.import.rules.delete_failure': 'Не удалось удалить правило', - 'transactions.feature_operations': 'Будущие операции', // System 'system.category.transfer_in': 'Перевод c другого счёта', 'system.category.transfer_out': 'Перевод на другой счёт', diff --git a/src/lib/utils/calc.ts b/src/lib/utils/calc.ts index 6285925..28ef3b8 100644 --- a/src/lib/utils/calc.ts +++ b/src/lib/utils/calc.ts @@ -4,6 +4,12 @@ import { Logger } from './logger'; const logger = new Logger('Calc'); +/** + * Replaces calculation expressions in the given text with their evaluated results. + * + * @param text The text containing calculation expressions in the format "${expression}". + * @returns The text with replaced calculation expressions. + */ export function replaceCalcExpressions(text: string) { return text.replaceAll(/\$\{[^}]+\}/g, (substring) => { const expression = substring.slice(2, -1); diff --git a/src/lib/utils/index.ts b/src/lib/utils/index.ts index 1faabcd..dd71b16 100644 --- a/src/lib/utils/index.ts +++ b/src/lib/utils/index.ts @@ -21,6 +21,7 @@ export { join } from './join'; export { keyTransactions } from './keyTransactions'; export { longPress } from './longPress'; export { serialize } from './serialize'; +export { spreadIf } from './spreadIf'; export { unexpectedCase } from './unexpectedCase'; export { useFetch } from './useFetch'; export { useSmartLoading } from './useSmartLoading'; diff --git a/src/lib/utils/spreadIf.ts b/src/lib/utils/spreadIf.ts new file mode 100644 index 0000000..39e8d85 --- /dev/null +++ b/src/lib/utils/spreadIf.ts @@ -0,0 +1,21 @@ +/** + * Returns the `values` object if `condition` is true, otherwise returns an empty object. + * + * @template T The type of the `values` object. + * @param {boolean} condition - The condition to check. + * @param {T} values The object to return if `condition` is true. + * @returns The `values` object if `condition` is true, otherwise an empty object. + * + * @example + * const someObj = { + * someProps: 'someProps', + * // will add value and description to the object if value is not null + * ...spreadIf(value !== null, { + * value, + * description: 'something', + * }), + * } + */ +export function spreadIf>(condition: boolean, values: T) { + return condition ? values : {}; +} diff --git a/src/routes/transactions/TransactionListItem.svelte b/src/routes/transactions/TransactionListItem.svelte index 4c64a66..5f993ae 100644 --- a/src/routes/transactions/TransactionListItem.svelte +++ b/src/routes/transactions/TransactionListItem.svelte @@ -74,6 +74,11 @@ {/if}
+ {#if !!transaction.anotherCurrencyAmount} + + {formatMoney(transaction.anotherCurrencyAmount, { currency: transaction.anotherCurrency ?? undefined })} + + {/if} {incoming ? '+' : outgoing ? '-' : ''}{formatMoney(transaction.amount)} {transaction.account.currency} diff --git a/src/routes/transactions/form/TransactionForm.svelte b/src/routes/transactions/form/TransactionForm.svelte index 78479fd..7f92416 100644 --- a/src/routes/transactions/form/TransactionForm.svelte +++ b/src/routes/transactions/form/TransactionForm.svelte @@ -7,10 +7,12 @@ import { SYSTEM_CATEGORY_TRANSFER_IN, SYSTEM_CATEGORY_TRANSFER_OUT } from '$lib/data/categories'; import type { AccountViewModel, Category, Tag, Transaction, TransactionViewModel } from '$lib/data/interfaces'; import { translate } from '$lib/translate'; + import Button from '$lib/ui/Button.svelte'; import Input from '$lib/ui/Input.svelte'; import InputLabel from '$lib/ui/InputLabel.svelte'; + import Modal from '$lib/ui/Modal.svelte'; import { showErrorToast } from '$lib/ui/toasts'; - import { formatMoney, getSearchParam } from '$lib/utils'; + import { formatMoney, getSearchParam, spreadIf } from '$lib/utils'; import { replaceCalcExpressions } from '$lib/utils/calc'; import { checkNumberFormParameter, @@ -58,7 +60,7 @@ $: destinationAccountCurrency = accounts.find(({ id }) => id === destinationAccountId)?.currency; let _value1 = (isTransfer ? sourceTransaction?.amount : transaction?.amount)?.toString() ?? ''; - let _value2 = destinationTransaction?.amount?.toString() ?? ''; + let _value2 = destinationTransaction?.amount?.toString() ?? transaction?.anotherCurrencyAmount?.toString() ?? ''; $: _rate = Number(_value1) / Number(_value2); let selectingAccount = false; @@ -68,6 +70,9 @@ let comment = transaction?.comment ?? ''; + let anotherCurrencyModalOpened = false; + let anotherCurrency: string | null = transaction?.anotherCurrency ?? null; + const handleSubmit = async (e: Event) => { const formData = new FormData(e.target as HTMLFormElement); if (!formData.get('accountId')) { @@ -102,6 +107,10 @@ amount: checkNumberFormParameter(formData, 'amount'), comment: checkStringOptionalFormParameter(formData, 'comment'), tagIds: selectedTags, + ...spreadIf(!!anotherCurrency, { + anotherCurrency, + anotherCurrencyAmount: checkNumberFormParameter(formData, 'destinationAmount'), + }), }); if (type === 'TRANSFER') { @@ -166,7 +175,20 @@
- +
+ + {#if type !== 'TRANSFER'} + {#if !anotherCurrency} + + {:else} + + {/if} + {/if} +
- {#if type === 'TRANSFER'} + {#if type === 'TRANSFER' || !!anotherCurrency} {/if}
- {#if type === 'TRANSFER' && Number(_value1) && Number(_value2)} + {#if (type === 'TRANSFER' || !!anotherCurrency) && Number(_value1) && Number(_value2)}
{`1 ${accountCurrency} = ${formatMoney(1 / _rate, { maxPrecision: 4, - currency: destinationAccountCurrency, + currency: destinationAccountCurrency || (anotherCurrency ?? undefined), })}`} - {`(1 ${destinationAccountCurrency} = ${formatMoney(_rate, { maxPrecision: 4, currency: accountCurrency })})`} + {`(1 ${destinationAccountCurrency || anotherCurrency} = ${formatMoney(_rate, { + maxPrecision: 4, + currency: accountCurrency, + })})`}
{/if}
@@ -224,6 +249,26 @@ + +
{ + anotherCurrency = new FormData(e.currentTarget).get('another-currency')?.toString() ?? null; + anotherCurrencyModalOpened = false; + }} + > + +
+
+
+
+