├── .editorconfig ├── .env.development ├── .env.production ├── .github └── FUNDING.yml ├── .gitignore ├── .npmrc ├── .prettierignore ├── .sentryclirc ├── Dockerfile ├── LICENSE ├── README.md ├── index.html ├── package.json ├── pnpm-lock.yaml ├── public ├── icons │ ├── 192px-maskable.png │ ├── 192px.png │ ├── 512px.png │ ├── accounts-192px-maskable.png │ ├── accounts-192px.png │ ├── apple-touch-icon.png │ ├── favicon.ico │ ├── favicon.svg │ ├── transactions-192px-maskable.png │ └── transactions-192px.png ├── manifest.json ├── robots.txt └── social-preview.png ├── src ├── 1-app │ ├── App.tsx │ ├── GlobalErrorBoundary.tsx │ ├── GlobalWidgets.tsx │ ├── Providers.tsx │ ├── SWPrompt.tsx │ └── index.tsx ├── 2-pages │ ├── About │ │ ├── Components.tsx │ │ ├── Head.tsx │ │ ├── index.scss │ │ ├── index.tsx │ │ └── pages │ │ │ ├── AboutEn.mdx │ │ │ ├── AboutRu.mdx │ │ │ ├── MethodEn.mdx │ │ │ ├── MethodRu.mdx │ │ │ ├── QuickStarRu.mdx │ │ │ ├── QuickStartEn.mdx │ │ │ └── whole-screenshot.png │ ├── Accounts.tsx │ ├── Auth │ │ └── index.tsx │ ├── Budgets │ │ ├── BalanceWidget.tsx │ │ ├── BudgetPopover │ │ │ ├── BudgetPopover.tsx │ │ │ ├── Context.tsx │ │ │ ├── index.tsx │ │ │ └── useQuickActions.ts │ │ ├── DnD │ │ │ ├── DnDContext.tsx │ │ │ ├── Highlight.tsx │ │ │ ├── dragTypes.tsx │ │ │ └── index.tsx │ │ ├── EnvelopeEditDialog │ │ │ ├── CurrencyCodeSelect.tsx │ │ │ ├── EnvelopeEditDialog.tsx │ │ │ ├── VisidilitySelect.tsx │ │ │ └── index.tsx │ │ ├── EnvelopePreview │ │ │ ├── ActivityWidget.tsx │ │ │ ├── BurndownWidget.tsx │ │ │ ├── CommentWidget.tsx │ │ │ ├── EnvelopeInfo.tsx │ │ │ ├── StatisticWidget.tsx │ │ │ ├── index.tsx │ │ │ └── shared.tsx │ │ ├── EnvelopeTable │ │ │ ├── EnvelopeTable.tsx │ │ │ ├── Footer │ │ │ │ ├── Footer.tsx │ │ │ │ └── index.tsx │ │ │ ├── Group.tsx │ │ │ ├── Header │ │ │ │ ├── Header.tsx │ │ │ │ ├── MonthSelect.tsx │ │ │ │ ├── TableMenu.tsx │ │ │ │ ├── ToBeBudgeted.tsx │ │ │ │ └── index.tsx │ │ │ ├── NewGroup.tsx │ │ │ ├── Parent.tsx │ │ │ ├── Row │ │ │ │ ├── ActivityCell.tsx │ │ │ │ ├── AvailableCell.tsx │ │ │ │ ├── Btn.tsx │ │ │ │ ├── BudgetCell.tsx │ │ │ │ ├── NameCell.tsx │ │ │ │ ├── Row.tsx │ │ │ │ └── index.tsx │ │ │ ├── index.tsx │ │ │ ├── models │ │ │ │ ├── envRenderInfo.ts │ │ │ │ ├── useExpandEnvelopes.ts │ │ │ │ └── useMetric.tsx │ │ │ └── shared │ │ │ │ └── shared.tsx │ │ ├── GoalPopover │ │ │ ├── Context.tsx │ │ │ ├── GoalPopover.tsx │ │ │ └── index.ts │ │ ├── MonthInfo │ │ │ ├── ActivityStats │ │ │ │ ├── ActivityStats.tsx │ │ │ │ └── index.tsx │ │ │ ├── FxRates.tsx │ │ │ └── index.tsx │ │ ├── MonthProvider.tsx │ │ ├── SideContent.tsx │ │ └── index.tsx │ ├── Donation.tsx │ ├── Review │ │ ├── cards │ │ │ ├── IncomeCard │ │ │ │ ├── IncomeCard.tsx │ │ │ │ ├── NotFunFact.tsx │ │ │ │ └── index.tsx │ │ │ ├── NoCategoryCard.tsx │ │ │ ├── NotFunCard │ │ │ │ ├── Chart.tsx │ │ │ │ ├── NotFunCard.tsx │ │ │ │ ├── TagSelect.tsx │ │ │ │ ├── getTaxesByIncome.tsx │ │ │ │ └── index.tsx │ │ │ ├── OutcomeCard.tsx │ │ │ ├── OutcomeStatCard.tsx │ │ │ ├── PayeeByFrequencyCard.tsx │ │ │ ├── PayeeByOutcomeCard.tsx │ │ │ ├── QRCard.tsx │ │ │ └── SavingsCard.tsx │ │ ├── index.scss │ │ ├── index.tsx │ │ └── shared │ │ │ ├── Card.tsx │ │ │ ├── getFacts.ts │ │ │ └── useTrToDisplay.tsx │ ├── Stats │ │ ├── WidgetAccHistory │ │ │ ├── WidgetAccHistory.tsx │ │ │ ├── index.ts │ │ │ └── model.ts │ │ ├── WidgetCashflow │ │ │ ├── WidgetCashflow.tsx │ │ │ └── index.ts │ │ ├── WidgetNetWorth │ │ │ ├── WidgetNetWorth.tsx │ │ │ ├── index.ts │ │ │ ├── useAverageExpenses.ts │ │ │ └── useNetWorth.ts │ │ ├── index.tsx │ │ └── shared │ │ │ ├── calcCashflow.ts │ │ │ ├── cashflow.ts │ │ │ └── period.ts │ ├── Token.tsx │ └── Transactions │ │ └── index.tsx ├── 3-widgets │ ├── Amount.tsx │ ├── DataLine.tsx │ ├── DebtorList │ │ ├── components.tsx │ │ └── index.tsx │ ├── ErrorBoundary │ │ ├── ErrorMessage.tsx │ │ └── index.tsx │ ├── Navigation │ │ ├── MenuButton.tsx │ │ ├── MobileNavigation.tsx │ │ ├── NavDrawer.tsx │ │ ├── SettingsMenu.tsx │ │ └── index.tsx │ ├── RefreshButton.tsx │ ├── RegularSyncHandler.tsx │ ├── account │ │ └── AccountList │ │ │ ├── components.tsx │ │ │ └── index.tsx │ ├── global │ │ ├── AccountContextMenu.tsx │ │ ├── EnvTransactionsDrawer.tsx │ │ ├── TrContextMenu.tsx │ │ ├── TransactionListDrawer.tsx │ │ ├── TransactionPreviewDrawer.tsx │ │ └── shared │ │ │ └── helpers.ts │ └── transaction │ │ ├── TransactionList │ │ ├── GrouppedList.tsx │ │ ├── TopBar │ │ │ ├── Actions.tsx │ │ │ ├── BulkEditModal.tsx │ │ │ ├── Filter.tsx │ │ │ ├── FilterDrawer.tsx │ │ │ └── transitions.css │ │ ├── Transaction │ │ │ ├── Transaction.Components.tsx │ │ │ ├── Transaction.tsx │ │ │ └── index.tsx │ │ └── index.tsx │ │ └── TransactionPreview │ │ ├── Map.tsx │ │ ├── Reciept.tsx │ │ └── index.tsx ├── 4-features │ ├── authorization.ts │ ├── budget │ │ ├── convertZmBudgetsToZerro.ts │ │ └── setTotalBudget.ts │ ├── bulkActions │ │ ├── copyPrevMonth │ │ │ └── index.ts │ │ ├── fillGoals │ │ │ ├── index.ts │ │ │ ├── model │ │ │ │ ├── fillGoals.ts │ │ │ │ └── index.ts │ │ │ └── ui │ │ │ │ └── GoalsProgress.tsx │ │ ├── fixOverspend │ │ │ ├── index.ts │ │ │ ├── model │ │ │ │ ├── fixOverspends.ts │ │ │ │ └── index.ts │ │ │ └── ui │ │ │ │ └── OverspendNotice.tsx │ │ └── startFresh.ts │ ├── envelope │ │ ├── arrayMove.ts │ │ ├── assignNewGroup.ts │ │ ├── createEnvelope.ts │ │ ├── moveEnvelope.ts │ │ ├── moveGroup.ts │ │ └── renameGroup.ts │ ├── export │ │ ├── exportCSV │ │ │ ├── exportCSV.ts │ │ │ ├── index.ts │ │ │ └── populateTransaction.ts │ │ └── exportJSON.ts │ ├── localData.ts │ ├── mergeAccounts.ts │ ├── moveMoney │ │ ├── MoveMoneyModal.tsx │ │ ├── index.ts │ │ └── moveMoney.ts │ ├── shared │ │ └── getDataToSave.ts │ └── sync.ts ├── 5-entities │ ├── accBalances │ │ ├── getBalances.ts │ │ ├── getBalancesByDate.ts │ │ ├── getConverterToChange.ts │ │ ├── index.ts │ │ ├── shared │ │ │ ├── convertBalancesToDisplay.ts │ │ │ └── types.ts │ │ └── useBalances.ts │ ├── account │ │ ├── index.ts │ │ ├── selectors.ts │ │ ├── shared │ │ │ ├── makeAccount.ts │ │ │ └── populate.ts │ │ └── thunks.ts │ ├── budget │ │ ├── envBudget │ │ │ ├── budgetStore.ts │ │ │ ├── index.ts │ │ │ └── setEnvBudget.ts │ │ ├── getBudgets.ts │ │ ├── index.ts │ │ ├── setBudget.ts │ │ └── tagBudget │ │ │ ├── getBudgetId.ts │ │ │ ├── index.ts │ │ │ ├── makeTagBudget.ts │ │ │ ├── selectors.ts │ │ │ └── setTagBudget.ts │ ├── currency │ │ ├── displayCurrency │ │ │ ├── DisplayAmount.tsx │ │ │ ├── index.ts │ │ │ └── model.ts │ │ ├── fxRate │ │ │ ├── converter.ts │ │ │ ├── fxRateStore.ts │ │ │ ├── getFxRates.ts │ │ │ ├── getFxRatesGetter.ts │ │ │ ├── index.ts │ │ │ └── patchRates.ts │ │ └── instrument │ │ │ ├── index.ts │ │ │ └── model.ts │ ├── debtors │ │ ├── debtorGetter.ts │ │ ├── getDebtors.ts │ │ └── index.ts │ ├── envBalances │ │ ├── 1 - currentFunds.ts │ │ ├── 1 - monthList.ts │ │ ├── 1 - rawActivity.ts │ │ ├── 2 - activity.ts │ │ ├── 2 - sortedActivity.ts │ │ ├── 3 - envMetrics.ts │ │ ├── 4 - monthTotals.ts │ │ ├── dataflow.dot │ │ └── index.ts │ ├── envelope │ │ ├── applyStructure.ts │ │ ├── getEnvelopes.ts │ │ ├── index.ts │ │ ├── patchEnvelope.ts │ │ └── shared │ │ │ ├── compareEnvelopes.ts │ │ │ ├── envelopeId.ts │ │ │ ├── makeEnvelope.ts │ │ │ ├── metaData.ts │ │ │ └── structure.ts │ ├── goal │ │ ├── getGoals.ts │ │ ├── getTotals.ts │ │ ├── goalStore.ts │ │ ├── index.ts │ │ ├── setGoal.ts │ │ └── shared │ │ │ ├── calcGoals.test.ts │ │ │ ├── calcGoals.ts │ │ │ ├── helpers.ts │ │ │ └── types.ts │ ├── merchant │ │ ├── index.ts │ │ ├── model.ts │ │ └── patchMerchant.ts │ ├── old-hiddenData │ │ ├── accTagMap │ │ │ └── index.ts │ │ ├── constants.ts │ │ ├── goals │ │ │ ├── helpers.ts │ │ │ └── index.ts │ │ ├── helpers.ts │ │ ├── selectors.ts │ │ ├── tagMeta │ │ │ └── index.ts │ │ └── thunks.ts │ ├── reminder │ │ ├── index.ts │ │ ├── makeReminder.ts │ │ ├── model.ts │ │ └── setReminder.ts │ ├── shared │ │ ├── cleanPayee.ts │ │ └── hidden-store │ │ │ ├── dataAccount.ts │ │ │ ├── helpers.ts │ │ │ ├── index.ts │ │ │ ├── monthlyStoreFactory.ts │ │ │ ├── simpleStoreFactory.ts │ │ │ └── types.ts │ ├── tag │ │ ├── index.tsx │ │ ├── model │ │ │ ├── index.ts │ │ │ ├── makeTag.ts │ │ │ ├── model.ts │ │ │ ├── populateTags.ts │ │ │ └── thunks.ts │ │ └── ui │ │ │ ├── TagChip.tsx │ │ │ ├── TagList.tsx │ │ │ ├── TagSelect.tsx │ │ │ └── TagSelect2.tsx │ ├── transaction │ │ ├── basicFiltering.ts │ │ ├── filtering.ts │ │ ├── helpers.ts │ │ ├── index.ts │ │ ├── makeTransaction.ts │ │ ├── model.ts │ │ └── thunks.ts │ ├── user │ │ ├── index.ts │ │ └── model.ts │ └── userSettings │ │ ├── index.ts │ │ └── userSettings.ts ├── 6-shared │ ├── api │ │ ├── fxRates.ts │ │ ├── storage.ts │ │ ├── tokenStorage.ts │ │ ├── zenmoney │ │ │ ├── auth.ts │ │ │ ├── endpoints.ts │ │ │ ├── errorExamples.ts │ │ │ ├── fetchDiff.ts │ │ │ └── index.ts │ │ ├── zm-adapter │ │ │ ├── converters.ts │ │ │ ├── index.ts │ │ │ └── toBudgetId.ts │ │ └── zmPreferenceStorage.ts │ ├── config.ts │ ├── helpers │ │ ├── color │ │ │ ├── APCAcontrast.ts │ │ │ ├── index.ts │ │ │ └── makeColorArray.ts │ │ ├── date │ │ │ ├── date.test.ts │ │ │ ├── formatDate.ts │ │ │ ├── index.ts │ │ │ ├── makeDateArray.ts │ │ │ └── utils.ts │ │ ├── keys.ts │ │ ├── money │ │ │ ├── currencyHelpers.ts │ │ │ ├── currencySymbols.json │ │ │ ├── format.ts │ │ │ └── index.ts │ │ ├── performance.ts │ │ ├── pluralize.ts │ │ ├── random.ts │ │ ├── receipt.ts │ │ ├── storybookDecorator.tsx │ │ └── tracking.ts │ ├── historyPopovers │ │ ├── PopoverManager.tsx │ │ ├── index.tsx │ │ └── popoverStack.ts │ ├── hooks │ │ ├── useCachedValue.tsx │ │ ├── useContextMenu.ts │ │ ├── useDebounce.ts │ │ ├── useDebouncedCallback.ts │ │ ├── useHomeBar.ts │ │ ├── useSearchParam.ts │ │ └── useToggle.ts │ ├── localization │ │ ├── LangSwitcher.tsx │ │ ├── dateLocalization.tsx │ │ ├── i18n.ts │ │ ├── index.ts │ │ └── translations │ │ │ ├── en.ts │ │ │ └── ru.json │ ├── tagIcons.json │ ├── types │ │ ├── data-entities.ts │ │ ├── index.ts │ │ ├── ts-utils.ts │ │ └── types.ts │ └── ui │ │ ├── AdaptivePopover.tsx │ │ ├── Amount.tsx │ │ ├── AmountInput.tsx │ │ ├── ColorPickerPopover │ │ ├── colors.ts │ │ ├── index.tsx │ │ └── styles.scss │ │ ├── EmojiIcon.tsx │ │ ├── FloatingInput.tsx │ │ ├── Icons.tsx │ │ ├── Logo.tsx │ │ ├── MonthSelectPopover.tsx │ │ ├── PercentBar │ │ └── index.tsx │ │ ├── RadialProgress.tsx │ │ ├── SmartConfirm.tsx │ │ ├── SmartDialog.tsx │ │ ├── SmartSelect.tsx │ │ ├── SnackbarProvider.tsx │ │ ├── Tooltip.tsx │ │ ├── Total.tsx │ │ └── theme │ │ ├── AppThemeProvider.tsx │ │ ├── createTheme.ts │ │ ├── hooks.tsx │ │ ├── index.tsx │ │ └── styles.scss ├── index.tsx ├── store │ ├── data │ │ ├── index.ts │ │ ├── selectors.ts │ │ ├── shared │ │ │ ├── applyDiff.ts │ │ │ ├── getItemsCount.ts │ │ │ ├── getLastDiffChange.ts │ │ │ └── mergeDiffs.ts │ │ └── slice.ts │ ├── displayCurrency.ts │ ├── index.ts │ ├── isPending.ts │ ├── lastSync.ts │ └── token.ts ├── stories │ ├── AccountList.stories.tsx │ ├── AmountInput.stories.tsx │ ├── EmojiIcon.stories.tsx │ ├── GoalProgress.stories.tsx │ ├── Map.stories.tsx │ ├── Reciept.stories.tsx │ ├── TagSelect.stories.tsx │ └── shared │ │ ├── DemoProviders.tsx │ │ ├── context.tsx │ │ └── data │ │ ├── companies.json │ │ ├── countries.json │ │ ├── index.ts │ │ └── instruments.json └── worker │ ├── index.ts │ └── worker.ts ├── tsconfig.json ├── tsconfig.node.json ├── typings ├── i18n.d.ts ├── theme.d.ts └── vite-env.d.ts ├── vercel.json └── vite.config.ts /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | end_of_line = lf 7 | indent_size = 2 8 | indent_style = space 9 | insert_final_newline = true 10 | max_line_length = 80 11 | trim_trailing_whitespace = true 12 | 13 | [*.md] 14 | max_line_length = 0 15 | trim_trailing_whitespace = false -------------------------------------------------------------------------------- /.env.development: -------------------------------------------------------------------------------- 1 | REACT_APP_VERSION=$npm_package_version 2 | 3 | REACT_APP_REDIRECT_URI=http://localhost:3000 4 | REACT_APP_CLIENT_ID=g61164be3dd7521a6511ce97adc6bb 5 | REACT_APP_CLIENT_SECRET=b2828c65b7 6 | -------------------------------------------------------------------------------- /.env.production: -------------------------------------------------------------------------------- 1 | REACT_APP_VERSION=$npm_package_version 2 | 3 | # Variables for tracking. 4 | # Uncomment and fill these variables to track errors and events 5 | # REACT_APP_SENTRY_DSN= 6 | # REACT_APP_YMID= 7 | # REACT_APP_GAID= 8 | 9 | # Fill this fields to deploy app 10 | # Consumer key and secret you can get on http://developers.zenmoney.ru 11 | REACT_APP_REDIRECT_URI= 12 | REACT_APP_CLIENT_ID= 13 | REACT_APP_CLIENT_SECRET= 14 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: ardov 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | custom: ['https://www.tinkoff.ru/sl/3zbRWFqgcT1'] 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | /dist 14 | 15 | # misc 16 | .idea 17 | .vscode 18 | .DS_Store 19 | .env.local 20 | .env.development.local 21 | .env.test.local 22 | .env.production.local 23 | 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | .now 28 | 29 | # cache 30 | .eslintcache 31 | .cache 32 | 33 | .vercel 34 | /.yarn 35 | 36 | *storybook.log 37 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | strict-peer-dependencies=false 2 | auto-install-peers=true 3 | resolve-peers-from-workspace-root=true 4 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # Build outputs 2 | /dist 3 | /build 4 | 5 | # Dependencies 6 | /node_modules 7 | /.yarn 8 | 9 | # Cache 10 | .cache 11 | .eslintcache 12 | 13 | # Misc 14 | .DS_Store 15 | .env* 16 | *.log 17 | 18 | # Generated files 19 | package-lock.json 20 | yarn.lock 21 | -------------------------------------------------------------------------------- /.sentryclirc: -------------------------------------------------------------------------------- 1 | [defaults] 2 | project=zerro 3 | org=zerro 4 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:20-alpine 2 | 3 | # set working directory 4 | WORKDIR /app 5 | 6 | # add `/app/node_modules/.bin` to $PATH 7 | ENV PATH /app/node_modules/.bin:$PATH 8 | 9 | # install pnpm 10 | RUN npm install -g pnpm 11 | 12 | # install app dependencies 13 | COPY package.json pnpm-lock.yaml ./ 14 | RUN pnpm install 15 | 16 | # add app 17 | COPY . ./ 18 | 19 | # start app 20 | CMD ["pnpm", "run", "dev", "--", "--host"] 21 | -------------------------------------------------------------------------------- /public/icons/192px-maskable.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ardov/zerro/921f5b97768be3a049d1370eba1313cbcc3bc719/public/icons/192px-maskable.png -------------------------------------------------------------------------------- /public/icons/192px.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ardov/zerro/921f5b97768be3a049d1370eba1313cbcc3bc719/public/icons/192px.png -------------------------------------------------------------------------------- /public/icons/512px.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ardov/zerro/921f5b97768be3a049d1370eba1313cbcc3bc719/public/icons/512px.png -------------------------------------------------------------------------------- /public/icons/accounts-192px-maskable.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ardov/zerro/921f5b97768be3a049d1370eba1313cbcc3bc719/public/icons/accounts-192px-maskable.png -------------------------------------------------------------------------------- /public/icons/accounts-192px.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ardov/zerro/921f5b97768be3a049d1370eba1313cbcc3bc719/public/icons/accounts-192px.png -------------------------------------------------------------------------------- /public/icons/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ardov/zerro/921f5b97768be3a049d1370eba1313cbcc3bc719/public/icons/apple-touch-icon.png -------------------------------------------------------------------------------- /public/icons/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ardov/zerro/921f5b97768be3a049d1370eba1313cbcc3bc719/public/icons/favicon.ico -------------------------------------------------------------------------------- /public/icons/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 13 | 16 | -------------------------------------------------------------------------------- /public/icons/transactions-192px-maskable.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ardov/zerro/921f5b97768be3a049d1370eba1313cbcc3bc719/public/icons/transactions-192px-maskable.png -------------------------------------------------------------------------------- /public/icons/transactions-192px.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ardov/zerro/921f5b97768be3a049d1370eba1313cbcc3bc719/public/icons/transactions-192px.png -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Allow: / 3 | -------------------------------------------------------------------------------- /public/social-preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ardov/zerro/921f5b97768be3a049d1370eba1313cbcc3bc719/public/social-preview.png -------------------------------------------------------------------------------- /src/1-app/GlobalErrorBoundary.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { captureError, sendEvent } from '6-shared/helpers/tracking' 3 | import { clearStorage } from 'worker' 4 | import { tokenStorage } from '6-shared/api/tokenStorage' 5 | import { useTranslation } from 'react-i18next' 6 | 7 | export default class GlobalErrorBoundary extends React.Component<{ 8 | children: React.ReactNode 9 | }> { 10 | state = { hasError: false } 11 | 12 | static getDerivedStateFromError = (error: any) => ({ hasError: true }) 13 | 14 | componentDidCatch = (error: Error, errorInfo: React.ErrorInfo) => { 15 | sendEvent(`GlobalError: ${error.message}`) 16 | captureError(error, errorInfo) 17 | } 18 | 19 | render() { 20 | return this.state.hasError ? : this.props.children 21 | } 22 | } 23 | 24 | function ErrorFallback() { 25 | const { t } = useTranslation('errorGlobal') 26 | const fullRefresh = () => { 27 | clearStorage() 28 | localStorage.clear() 29 | tokenStorage.clear() 30 | window.location.reload() 31 | } 32 | 33 | return ( 34 |
35 |

{t('message')}

36 | 42 |
43 | ) 44 | } 45 | -------------------------------------------------------------------------------- /src/1-app/GlobalWidgets.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { SmartConfirm } from '6-shared/ui/SmartConfirm' 3 | import { SmartTransactionListDrawer } from '3-widgets/global/TransactionListDrawer' 4 | import { SmartTransactionPreview } from '3-widgets/global/TransactionPreviewDrawer' 5 | import { TrContextMenu } from '3-widgets/global/TrContextMenu' 6 | import { AccountContextMenu } from '3-widgets/global/AccountContextMenu' 7 | import { SmartEnvTransactionsDrawer } from '3-widgets/global/EnvTransactionsDrawer' 8 | 9 | export const GlobalWidgets = () => { 10 | return ( 11 | <> 12 | {/* Global confirm */} 13 | 14 | 15 | {/* Global widgets */} 16 | 17 | 18 | 19 | 20 | 21 | 22 | ) 23 | } 24 | -------------------------------------------------------------------------------- /src/1-app/Providers.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Provider } from 'react-redux' 3 | import { StyledEngineProvider } from '@mui/material/styles' 4 | import { store } from 'store' 5 | import { AppThemeProvider } from '6-shared/ui/theme' 6 | import { SnackbarProvider } from '6-shared/ui/SnackbarProvider' 7 | import { LocalizationProvider } from '6-shared/localization' 8 | 9 | export function Providers(props: { 10 | children: React.ReactNode 11 | store?: typeof store 12 | }) { 13 | return ( 14 | 15 | 16 | 17 | 18 | {props.children} 19 | 20 | 21 | 22 | 23 | ) 24 | } 25 | -------------------------------------------------------------------------------- /src/1-app/SWPrompt.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react' 2 | import { Box } from '@mui/material' 3 | import { useRegisterSW } from 'virtual:pwa-register/react' 4 | 5 | const pageOpened = Date.now() 6 | 7 | // TODO: use prompt to update service worker (now it's just draft) 8 | export const SWPrompt = () => { 9 | const { 10 | updateServiceWorker, 11 | needRefresh: [needRefresh, setNeedRefresh], 12 | offlineReady: [offlineReady, setOfflineReady], 13 | } = useRegisterSW({ 14 | immediate: true, 15 | onRegisteredSW(swUrl, r) { 16 | console.log(`Service Worker at: ${swUrl}`) 17 | 18 | // r && 19 | // setInterval(() => { 20 | // console.log('Checking for sw update') 21 | // r.update() 22 | // }, 10000 /* 20s for testing purposes */) 23 | }, 24 | onRegisterError(error) { 25 | console.log('SW registration error', error) 26 | }, 27 | }) 28 | 29 | useEffect(() => { 30 | if (needRefresh && Date.now() - pageOpened < 2000) { 31 | alert('updating!') 32 | updateServiceWorker(true) 33 | } else if (needRefresh) { 34 | console.log('too late') 35 | } 36 | }, [needRefresh, updateServiceWorker]) 37 | 38 | return ( 39 | 52 | Hello! 53 | {needRefresh && needRefresh} 54 | {offlineReady && offlineReady} 55 | 56 | 63 | 64 | ) 65 | } 66 | -------------------------------------------------------------------------------- /src/1-app/index.tsx: -------------------------------------------------------------------------------- 1 | import type { TDiff } from '6-shared/types' 2 | 3 | import React from 'react' 4 | import { initSentry } from '6-shared/helpers/tracking' 5 | import { store } from 'store' 6 | import { bindWorkerToStore } from 'worker' 7 | import { applyClientPatch, resetData } from 'store/data' 8 | import GlobalErrorBoundary from './GlobalErrorBoundary' 9 | import App from './App' 10 | import { Providers } from './Providers' 11 | import { registerSW } from 'virtual:pwa-register' 12 | 13 | registerSW({ immediate: true }) 14 | initSentry() 15 | bindWorkerToStore(store.dispatch) 16 | createZerroInstance(store) 17 | 18 | export const MainApp = () => ( 19 | 20 | 21 | 22 | 23 | 24 | ) 25 | 26 | /** `zerro` can be used in console to access state and modify data */ 27 | function createZerroInstance(s: typeof store) { 28 | let logsShow = localStorage.getItem('showLogs') === 'true' 29 | 30 | // @ts-ignore 31 | window.zerro = { 32 | get state() { 33 | return s.getState() 34 | }, 35 | // @ts-ignore 36 | env: import.meta.env, 37 | get logsShow() { 38 | return logsShow 39 | }, 40 | toggleLogs: () => { 41 | logsShow = !logsShow 42 | localStorage.setItem('showLogs', String(logsShow)) 43 | return logsShow 44 | }, 45 | logs: {}, 46 | resetData: () => s.dispatch(resetData()), 47 | applyClientPatch: (patch: TDiff) => s.dispatch(applyClientPatch(patch)), 48 | showEl: (id: string) => { 49 | let data = s.getState().data.current 50 | return ( 51 | Object.values(data) 52 | // @ts-ignore 53 | .map(c => c[id]) 54 | .filter(Boolean)[0] 55 | ) 56 | }, 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/2-pages/About/Head.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react' 2 | import { Helmet } from 'react-helmet' 3 | 4 | /* Need this component because MDX changes Helmet children and raw Helmet doesn't work */ 5 | export const Head: FC<{ 6 | title: string 7 | description?: string 8 | canonical?: string 9 | }> = props => { 10 | return ( 11 | 12 | {props.title} 13 | {props.description && ( 14 | 15 | )} 16 | {props.canonical && } 17 | 18 | ) 19 | } 20 | -------------------------------------------------------------------------------- /src/2-pages/About/pages/whole-screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ardov/zerro/921f5b97768be3a049d1370eba1313cbcc3bc719/src/2-pages/About/pages/whole-screenshot.png -------------------------------------------------------------------------------- /src/2-pages/Accounts.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Box } from '@mui/material' 3 | import AccountList from '3-widgets/account/AccountList' 4 | import { Helmet } from 'react-helmet' 5 | import { DebtorList } from '3-widgets/DebtorList' 6 | import { useTranslation } from 'react-i18next' 7 | 8 | export default function Accounts() { 9 | const { t } = useTranslation('accounts') 10 | return ( 11 | <> 12 | 13 | {t('pageTitle')} | Zerro 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | ) 23 | } 24 | -------------------------------------------------------------------------------- /src/2-pages/Budgets/BudgetPopover/Context.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, useCallback } from 'react' 2 | 3 | import { TEnvelopeId } from '5-entities/envelope' 4 | import { useMonth } from '../MonthProvider' 5 | import { BudgetPopover } from './BudgetPopover' 6 | import { registerPopover } from '6-shared/historyPopovers' 7 | import { TISOMonth } from '6-shared/types' 8 | import { PopoverProps } from '@mui/material' 9 | 10 | const budgetPopover = registerPopover< 11 | { id?: TEnvelopeId; month?: TISOMonth }, 12 | PopoverProps 13 | >('budgetPopover', {}) 14 | 15 | export const useBudgetPopover = () => { 16 | const [month] = useMonth() 17 | const { open } = budgetPopover.useMethods() 18 | const openPopover = useCallback( 19 | (id: TEnvelopeId, anchorEl?: Element) => 20 | open({ id, month }, { anchorEl, key: Date.now() }), 21 | [month, open] 22 | ) 23 | return openPopover 24 | } 25 | 26 | export const SmartBudgetPopover: FC = () => { 27 | const popover = budgetPopover.useProps() 28 | const { month, id } = popover.extraProps 29 | if (!month || !id) return null 30 | return 31 | } 32 | -------------------------------------------------------------------------------- /src/2-pages/Budgets/BudgetPopover/index.tsx: -------------------------------------------------------------------------------- 1 | export { useBudgetPopover, SmartBudgetPopover } from './Context' 2 | -------------------------------------------------------------------------------- /src/2-pages/Budgets/DnD/dragTypes.tsx: -------------------------------------------------------------------------------- 1 | import { TEnvelopeId } from '5-entities/envelope' 2 | 3 | export enum DragTypes { 4 | newGroup = 'newGroup', 5 | amount = 'amount', 6 | envelope = 'envelope', 7 | } 8 | 9 | export type TDragData = { 10 | type: DragTypes 11 | id: TEnvelopeId 12 | isExpanded?: boolean 13 | isLastVisibleChild?: boolean 14 | } 15 | -------------------------------------------------------------------------------- /src/2-pages/Budgets/DnD/index.tsx: -------------------------------------------------------------------------------- 1 | export { DnDContext } from './DnDContext' 2 | export { DragTypes } from './dragTypes' 3 | -------------------------------------------------------------------------------- /src/2-pages/Budgets/EnvelopeEditDialog/CurrencyCodeSelect.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react' 2 | import { MenuItem, SelectProps, ListItemText } from '@mui/material' 3 | import { instrumentModel } from '5-entities/currency/instrument' 4 | import { accountModel } from '5-entities/account' 5 | import { TFxCode, TInstrument } from '6-shared/types' 6 | import { getCurrencySymbol } from '6-shared/helpers/money' 7 | import { userModel } from '5-entities/user' 8 | import { SmartSelect } from '6-shared/ui/SmartSelect' 9 | 10 | export const CurrencyCodeSelect: FC> = props => { 11 | const instrumentsByCode = instrumentModel.useInstrumentsByCode() 12 | const userCurrency = userModel.useUserCurrency() 13 | const accs = accountModel.useInBudgetAccounts() 14 | const value = props.value 15 | 16 | const fxSet = new Set(accs.map(a => a.fxCode)) 17 | fxSet.add(userCurrency) 18 | if (value) fxSet.add(value) 19 | const instruments = [...fxSet].map(code => instrumentsByCode[code]) 20 | 21 | return ( 22 | v} elKey="CurrencyCodeSelect"> 23 | {instruments.map(instr => ( 24 | 25 | 29 | 30 | ))} 31 | 32 | ) 33 | } 34 | 35 | function describe(i: TInstrument) { 36 | const symbol = getCurrencySymbol(i.shortTitle) 37 | if (symbol !== i.shortTitle) return `${i.title} (${symbol})` 38 | return i.title 39 | } 40 | -------------------------------------------------------------------------------- /src/2-pages/Budgets/EnvelopeEditDialog/VisidilitySelect.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react' 2 | import { MenuItem, SelectProps } from '@mui/material' 3 | import { useTranslation } from 'react-i18next' 4 | import { SmartSelect } from '6-shared/ui/SmartSelect' 5 | import { envelopeVisibility } from '5-entities/envelope' 6 | 7 | export const VisibilitySelect: FC> = props => { 8 | const { t } = useTranslation('envelopeEditDialog') 9 | return ( 10 | 11 | 12 | {t('visibility.auto')} 13 | 14 | 15 | {t('visibility.visible')} 16 | 17 | 18 | {t('visibility.hidden')} 19 | 20 | 21 | ) 22 | } 23 | -------------------------------------------------------------------------------- /src/2-pages/Budgets/EnvelopeEditDialog/index.tsx: -------------------------------------------------------------------------------- 1 | export { EnvelopeEditDialog, useEditDialog } from './EnvelopeEditDialog' 2 | -------------------------------------------------------------------------------- /src/2-pages/Budgets/EnvelopePreview/CommentWidget.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, useEffect, useState } from 'react' 2 | import { InputBase, InputAdornment } from '@mui/material' 3 | import { NotesIcon } from '6-shared/ui/Icons' 4 | import { TISOMonth } from '6-shared/types' 5 | import { useAppDispatch, useAppSelector } from 'store' 6 | import { cardStyle } from './shared' 7 | import { envelopeModel, TEnvelopeId } from '5-entities/envelope' 8 | import { useDebouncedCallback } from '6-shared/hooks/useDebouncedCallback' 9 | import { useTranslation } from 'react-i18next' 10 | 11 | export const CommentWidget: FC<{ month: TISOMonth; id: TEnvelopeId }> = ({ 12 | month, 13 | id, 14 | }) => { 15 | const { t } = useTranslation('common') 16 | const dispatch = useAppDispatch() 17 | const comment = useAppSelector(s => envelopeModel.getEnvelopes(s)[id].comment) 18 | const [value, setValue] = useState(comment) 19 | 20 | const applyChanges = useDebouncedCallback( 21 | value => { 22 | if (comment !== value) { 23 | dispatch(envelopeModel.patchEnvelope({ id, comment: value })) 24 | } 25 | }, 26 | [id, dispatch], 27 | 300 28 | ) 29 | 30 | useEffect(() => { 31 | setValue(comment) 32 | }, [comment]) 33 | 34 | return ( 35 | { 40 | setValue(e.target.value) 41 | applyChanges(e.target.value) 42 | }} 43 | multiline 44 | startAdornment={ 45 | 46 | 47 | 48 | } 49 | /> 50 | ) 51 | } 52 | -------------------------------------------------------------------------------- /src/2-pages/Budgets/EnvelopePreview/shared.tsx: -------------------------------------------------------------------------------- 1 | import type { TISOMonth } from '6-shared/types' 2 | 3 | export const cardStyle = { 4 | borderRadius: 1, 5 | py: 1, 6 | px: 2, 7 | bgcolor: 'background.default', 8 | width: '100%', 9 | } 10 | 11 | export function getDateRange( 12 | dates: TISOMonth[], 13 | range: number, 14 | targetMonth: TISOMonth 15 | ) { 16 | let idx = dates.findIndex(d => d === targetMonth) 17 | if (idx === -1) idx = dates.length - 1 18 | return trimMonths(dates, range, idx) 19 | } 20 | 21 | /** Cuts out a range with target index in center */ 22 | export function trimMonths( 23 | arr: Array, 24 | windowSize: number, 25 | targetIdx: number 26 | ): Array { 27 | // In this case the last month is always empty 28 | // so we can throw it away if it's not the target 29 | const isLast = targetIdx === arr.length - 1 30 | const cleanArr = isLast ? arr : arr.slice(0, arr.length - 1) 31 | 32 | if (cleanArr.length <= windowSize) return cleanArr 33 | 34 | // Calculate the range with the target in the center 35 | let padLeft = Math.floor((windowSize - 1) / 2) 36 | let padRight = windowSize - 1 - padLeft 37 | let rangeStart = targetIdx - padLeft 38 | let rangeEnd = targetIdx + padRight 39 | 40 | if (rangeEnd >= cleanArr.length) return cleanArr.slice(-windowSize) 41 | if (rangeStart <= 0) return cleanArr.slice(0, windowSize) 42 | return cleanArr.slice(rangeStart, rangeEnd + 1) 43 | } 44 | -------------------------------------------------------------------------------- /src/2-pages/Budgets/EnvelopeTable/Footer/Footer.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react' 2 | import { Typography } from '@mui/material' 3 | import { TableRow } from '../shared/shared' 4 | 5 | import { Metric } from '../models/useMetric' 6 | import { TFxAmount, TISOMonth } from '6-shared/types' 7 | import { balances } from '5-entities/envBalances' 8 | import { DisplayAmount } from '5-entities/currency/displayCurrency' 9 | import { useTranslation } from 'react-i18next' 10 | 11 | type FooterProps = { 12 | month: TISOMonth 13 | metric: Metric 14 | } 15 | 16 | export const Footer: FC = props => { 17 | const { month } = props 18 | const totals = balances.useTotals()[month] 19 | const { t } = useTranslation('common') 20 | 21 | const Sum: FC<{ value: TFxAmount }> = ({ value }) => ( 22 | 30 | 31 | 32 | ) 33 | 34 | return ( 35 | 38 | 45 | {t('total')} 46 | 47 | 48 | } 49 | budgeted={} 50 | outcome={} 51 | available={} 52 | goal={null} 53 | /> 54 | ) 55 | } 56 | -------------------------------------------------------------------------------- /src/2-pages/Budgets/EnvelopeTable/Footer/index.tsx: -------------------------------------------------------------------------------- 1 | export { Footer } from './Footer' 2 | -------------------------------------------------------------------------------- /src/2-pages/Budgets/EnvelopeTable/Header/TableMenu.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback } from 'react' 2 | import { Menu, MenuItem, PopoverProps } from '@mui/material' 3 | import { useTranslation } from 'react-i18next' 4 | import { registerPopover } from '6-shared/historyPopovers' 5 | 6 | type TableMenuProps = { 7 | isAllShown: boolean 8 | isReordering: boolean 9 | onShowAllToggle: () => void 10 | onReorderModeToggle: () => void 11 | } 12 | 13 | const tableMenu = registerPopover('tableMenu', { 14 | isAllShown: false, 15 | isReordering: false, 16 | onShowAllToggle: () => {}, 17 | onReorderModeToggle: () => {}, 18 | }) 19 | 20 | export const useTableMenu = (props: TableMenuProps) => { 21 | const { open } = tableMenu.useMethods() 22 | return useCallback( 23 | (e: React.MouseEvent) => open({ ...props }, { anchorEl: e.currentTarget }), 24 | [open, props] 25 | ) 26 | } 27 | 28 | export function TableMenu() { 29 | const { t } = useTranslation('envelopeTableMenu') 30 | const popover = tableMenu.useProps() 31 | const { onShowAllToggle, onReorderModeToggle, isReordering, isAllShown } = 32 | popover.extraProps 33 | 34 | return ( 35 | 36 | { 38 | popover.close() 39 | onShowAllToggle() 40 | }} 41 | > 42 | {t(isAllShown ? 'showPrtiallyEnvelopes' : 'showAllEnvelopes')} 43 | 44 | { 46 | popover.close() 47 | onReorderModeToggle() 48 | }} 49 | > 50 | {t(isReordering ? 'leaveEditMode' : 'goToEditMode')} 51 | 52 | 53 | ) 54 | } 55 | -------------------------------------------------------------------------------- /src/2-pages/Budgets/EnvelopeTable/Header/index.tsx: -------------------------------------------------------------------------------- 1 | export { Header } from './Header' 2 | -------------------------------------------------------------------------------- /src/2-pages/Budgets/EnvelopeTable/NewGroup.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react' 2 | import { Box, Collapse } from '@mui/material' 3 | import { useDroppable } from '@dnd-kit/core' 4 | import { DragTypes } from '../DnD' 5 | import { useTranslation } from 'react-i18next' 6 | 7 | export function NewGroup(props: { visible: boolean }) { 8 | const { t } = useTranslation('creatingNewGroup') 9 | const { visible } = props 10 | const [clicked, setClicked] = useState(false) 11 | const { setNodeRef, active, isOver } = useDroppable({ 12 | id: 'newGroup', 13 | disabled: !visible, 14 | data: { type: DragTypes.newGroup, id: DragTypes.newGroup }, 15 | }) 16 | const canDrop = active?.data?.current?.type === DragTypes.envelope 17 | 18 | const text = isOver 19 | ? canDrop 20 | ? t('okDropIt') 21 | : t('categoryNeeded') 22 | : clicked 23 | ? t('dropCategoryHere') 24 | : t('newGroup') 25 | return ( 26 | 27 | 34 | setClicked(true)} 44 | > 45 | {text} 46 | 47 | 48 | 49 | ) 50 | } 51 | -------------------------------------------------------------------------------- /src/2-pages/Budgets/EnvelopeTable/Row/ActivityCell.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react' 2 | import { Typography, Box } from '@mui/material' 3 | import { Amount } from '6-shared/ui/Amount' 4 | import { Btn } from './Btn' 5 | 6 | type ActivityCellProps = { 7 | value: number 8 | onClick: React.MouseEventHandler 9 | } 10 | 11 | export const ActivityCell: FC = props => { 12 | const { value: displayActivity, onClick } = props 13 | return ( 14 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | ) 28 | } 29 | -------------------------------------------------------------------------------- /src/2-pages/Budgets/EnvelopeTable/Row/Btn.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react' 2 | import { ButtonBase, ButtonBaseProps } from '@mui/material' 3 | 4 | const style = { 5 | py: 1, 6 | px: 1.5, 7 | my: -1, 8 | mx: -1.5, 9 | borderRadius: 1, 10 | minWidth: 0, 11 | transition: '0.1s', 12 | textAlign: 'right', 13 | typography: 'body1', 14 | '&:hover': { bgcolor: 'action.hover' }, 15 | '&:focus': { bgcolor: 'action.focus' }, 16 | } 17 | 18 | export const Btn: FC = props => ( 19 | 20 | ) 21 | -------------------------------------------------------------------------------- /src/2-pages/Budgets/EnvelopeTable/Row/BudgetCell.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react' 2 | import { Box } from '@mui/material' 3 | import { Amount } from '6-shared/ui/Amount' 4 | 5 | import { Btn } from './Btn' 6 | 7 | type BudgetCellProps = { 8 | value: number 9 | isSelf?: boolean 10 | onBudgetClick: React.MouseEventHandler 11 | } 12 | 13 | export const BudgetCell: FC = props => { 14 | const { value, onBudgetClick, isSelf } = props 15 | return ( 16 | 27 | 28 | 29 | 30 | 31 | ) 32 | } 33 | -------------------------------------------------------------------------------- /src/2-pages/Budgets/EnvelopeTable/Row/index.tsx: -------------------------------------------------------------------------------- 1 | export { Row } from './Row' 2 | -------------------------------------------------------------------------------- /src/2-pages/Budgets/EnvelopeTable/index.tsx: -------------------------------------------------------------------------------- 1 | export { EnvelopeTable } from './EnvelopeTable' 2 | -------------------------------------------------------------------------------- /src/2-pages/Budgets/EnvelopeTable/models/useExpandEnvelopes.ts: -------------------------------------------------------------------------------- 1 | import { useState } from 'react' 2 | import { toISOMonth } from '6-shared/helpers/date' 3 | import { TISOMonth } from '6-shared/types' 4 | import { useEnvRenderInfo } from './envRenderInfo' 5 | import { TEnvelopeId } from '5-entities/envelope' 6 | 7 | export function useExpandEnvelopes(month: TISOMonth = toISOMonth(new Date())): { 8 | expanded: TEnvelopeId[] 9 | toggle: (id: TEnvelopeId) => void 10 | expandAll: () => void 11 | collapseAll: () => void 12 | } { 13 | const renderInfo = useEnvRenderInfo(month) 14 | const defaultExpanded = Object.values(renderInfo) 15 | .filter(e => e.isDefaultExpanded) 16 | .map(e => e.id) 17 | 18 | const [expanded, setExpanded] = useState(defaultExpanded) 19 | return { 20 | expanded, 21 | toggle: (id: TEnvelopeId) => { 22 | expanded.includes(id) 23 | ? setExpanded(expanded => expanded.filter(e => e !== id)) 24 | : setExpanded([...expanded, id]) 25 | }, 26 | expandAll: () => { 27 | let expandedList = Object.values(renderInfo) 28 | .filter(e => e.hasChildren) 29 | .map(e => e.id) 30 | setExpanded(expandedList) 31 | }, 32 | collapseAll: () => setExpanded([]), 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/2-pages/Budgets/EnvelopeTable/models/useMetric.tsx: -------------------------------------------------------------------------------- 1 | import { Theme, useMediaQuery } from '@mui/material' 2 | import React, { FC, ReactNode, useContext, useState } from 'react' 3 | 4 | export enum Metric { 5 | budgeted = 'budgeted', 6 | outcome = 'outcome', 7 | available = 'available', 8 | } 9 | 10 | const allColumns: Metric[] = [Metric.available, Metric.budgeted, Metric.outcome] 11 | 12 | export function useMetric() { 13 | const [metric, setMetric] = useState(allColumns[0]) 14 | const toggleMetric = () => { 15 | const currIdx = allColumns.indexOf(metric) 16 | const nextIdx = (currIdx + 1) % allColumns.length 17 | setMetric(allColumns[nextIdx]) 18 | } 19 | return { metric, toggleMetric } 20 | } 21 | 22 | const RenderColumnContext = React.createContext< 23 | [Metric[], (metrics: Metric[]) => void] 24 | >([[], () => {}]) 25 | 26 | export const RenderColumnsProvider: FC<{ children: ReactNode }> = props => { 27 | const isMobile = useMediaQuery(theme => theme.breakpoints.down('sm')) 28 | const [mobColumns, setMobColumns] = useState([Metric.available]) 29 | const renderColumns = isMobile ? mobColumns : allColumns 30 | return ( 31 | 32 | {props.children} 33 | 34 | ) 35 | } 36 | 37 | export function useColumns() { 38 | const [columns, setMobColumns] = useContext(RenderColumnContext) 39 | const nextColumn = () => { 40 | const currIdx = allColumns.indexOf(columns[0]) 41 | const nextIdx = (currIdx + 1) % allColumns.length 42 | setMobColumns([allColumns[nextIdx]]) 43 | } 44 | return { columns, nextColumn } 45 | } 46 | -------------------------------------------------------------------------------- /src/2-pages/Budgets/EnvelopeTable/shared/shared.tsx: -------------------------------------------------------------------------------- 1 | import { Theme, useMediaQuery } from '@mui/material' 2 | import { Box, BoxProps, SxProps } from '@mui/system' 3 | import { FC, ReactNode } from 'react' 4 | import { Metric, useColumns } from '../models/useMetric' 5 | 6 | export function useIsSmall() { 7 | return useMediaQuery(theme => theme.breakpoints.down('sm')) 8 | } 9 | 10 | const rowStyle: SxProps = { 11 | display: 'grid', 12 | gridTemplateColumns: { 13 | xs: 'auto 90px 16px', 14 | sm: 'auto 90px 90px 90px 16px', 15 | }, 16 | width: '100%', 17 | px: 2, 18 | alignItems: 'center', 19 | justifyContent: 'initial', 20 | gridColumnGap: '12px', 21 | } 22 | 23 | export const TableRow: FC< 24 | BoxProps & { 25 | name: ReactNode 26 | available: ReactNode 27 | budgeted: ReactNode 28 | outcome: ReactNode 29 | goal: ReactNode 30 | } 31 | > = props => { 32 | const { name, available, budgeted, outcome, goal, sx, ...rest } = props 33 | const { columns } = useColumns() 34 | return ( 35 | 36 | {name} 37 | {columns.includes(Metric.budgeted) && budgeted} 38 | {columns.includes(Metric.outcome) && outcome} 39 | {columns.includes(Metric.available) && available} 40 | {goal} 41 | 42 | ) 43 | } 44 | -------------------------------------------------------------------------------- /src/2-pages/Budgets/GoalPopover/Context.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, useCallback } from 'react' 2 | import { useMonth } from '../MonthProvider' 3 | 4 | import { TEnvelopeId } from '5-entities/envelope' 5 | import { GoalPopover } from './GoalPopover' 6 | import { registerPopover } from '6-shared/historyPopovers' 7 | import { TISOMonth } from '6-shared/types' 8 | import { PopoverProps } from '@mui/material' 9 | 10 | const goalPopover = registerPopover< 11 | { id?: TEnvelopeId; month?: TISOMonth }, 12 | PopoverProps 13 | >('goalPopover', {}) 14 | 15 | export const useGoalPopover = () => { 16 | const [month] = useMonth() 17 | const { open } = goalPopover.useMethods() 18 | const openPopover = useCallback( 19 | (id: TEnvelopeId, anchorEl?: Element) => 20 | open({ id, month }, { anchorEl, key: Date.now() }), 21 | [month, open] 22 | ) 23 | return openPopover 24 | } 25 | 26 | export const SmartGoalPopover: FC = () => { 27 | const popover = goalPopover.useProps() 28 | const { month, id } = popover.extraProps 29 | if (!month || !id) return null 30 | return 31 | } 32 | -------------------------------------------------------------------------------- /src/2-pages/Budgets/GoalPopover/index.ts: -------------------------------------------------------------------------------- 1 | export { useGoalPopover, SmartGoalPopover } from './Context' 2 | -------------------------------------------------------------------------------- /src/2-pages/Budgets/MonthInfo/ActivityStats/index.tsx: -------------------------------------------------------------------------------- 1 | export { ActivityStats } from './ActivityStats' 2 | -------------------------------------------------------------------------------- /src/2-pages/Budgets/MonthProvider.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, ReactNode, useCallback, useState } from 'react' 2 | import { TDateDraft, TISOMonth } from '6-shared/types' 3 | import { isISOMonth, toISOMonth } from '6-shared/helpers/date' 4 | import { balances } from '5-entities/envBalances' 5 | 6 | type TMonthState = [TISOMonth, (date: TDateDraft) => void] 7 | 8 | const MonthContext = React.createContext([ 9 | toISOMonth(new Date()), 10 | () => {}, 11 | ]) 12 | 13 | export const useMonth = () => React.useContext(MonthContext) 14 | 15 | export const MonthProvider: FC<{ children: ReactNode }> = props => { 16 | const currentMonth = toISOMonth(new Date()) 17 | const monthList = balances.useMonthList() 18 | const firstMonth = monthList[0] || currentMonth 19 | const lastMonth = monthList[monthList.length - 1] || currentMonth 20 | const [selected, setSelected] = useState(currentMonth) 21 | 22 | const setNewMonth = useCallback( 23 | (date: TDateDraft) => 24 | setSelected(prev => { 25 | if (!date) return prev 26 | const next = toISOMonth(date) 27 | if (!isISOMonth(next)) return prev 28 | return getValidMonth(next, firstMonth, lastMonth) 29 | }), 30 | [firstMonth, lastMonth] 31 | ) 32 | 33 | const month = getValidMonth(selected, firstMonth, lastMonth) 34 | 35 | return ( 36 | 37 | {props.children} 38 | 39 | ) 40 | } 41 | 42 | /** Returns a month within given range */ 43 | function getValidMonth(selected: TISOMonth, min: TISOMonth, max: TISOMonth) { 44 | if (selected < min) return min 45 | if (selected > max) return max 46 | return selected 47 | } 48 | -------------------------------------------------------------------------------- /src/2-pages/Budgets/SideContent.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, memo, useCallback } from 'react' 2 | import { Box, Drawer } from '@mui/material' 3 | import { TEnvelopeId } from '5-entities/envelope' 4 | import { MonthInfo } from './MonthInfo' 5 | import { EnvelopePreview } from './EnvelopePreview' 6 | import { registerPopover } from '6-shared/historyPopovers' 7 | 8 | type TDrawerId = TEnvelopeId | 'overview' 9 | 10 | const sideDrawer = registerPopover<{ id: TDrawerId }>('sideContent', { 11 | id: 'overview', 12 | }) 13 | 14 | export const useSideContent = () => { 15 | const { open } = sideDrawer.useMethods() 16 | return useCallback((id: TDrawerId) => open({ id }), [open]) 17 | } 18 | 19 | export const SideContent: FC<{ docked?: boolean; width: number }> = props => { 20 | const drawer = sideDrawer.useProps() 21 | return ( 22 | 28 | ) 29 | } 30 | 31 | type TSideContentProps = { 32 | open: boolean 33 | onClose: () => void 34 | id?: TDrawerId 35 | docked?: boolean 36 | width: number 37 | } 38 | const MemoSideDrawer = memo(props => { 39 | const { open, onClose, id, docked, width } = props 40 | 41 | const drawerContent = 42 | !id || id === 'overview' ? ( 43 | 44 | ) : ( 45 | 46 | ) 47 | 48 | if (docked) { 49 | return open ? drawerContent : 50 | } 51 | 52 | return ( 53 | 54 | {drawerContent} 55 | 56 | ) 57 | }) 58 | -------------------------------------------------------------------------------- /src/2-pages/Review/cards/IncomeCard/index.tsx: -------------------------------------------------------------------------------- 1 | export { IncomeCard } from './IncomeCard' 2 | -------------------------------------------------------------------------------- /src/2-pages/Review/cards/NoCategoryCard.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { ButtonBase, Stack, Typography } from '@mui/material' 3 | import { Card, TCardProps } from '../shared/Card' 4 | import { useStats } from '../shared/getFacts' 5 | import { useTranslation } from 'react-i18next' 6 | 7 | export function NoCategoryCard(props: TCardProps) { 8 | const yearStats = useStats(props.year) 9 | const { t } = useTranslation('yearReview', { keyPrefix: 'noCategory' }) 10 | const noTag = [ 11 | ...yearStats.total.incomeTransactions.filter(tr => !tr.tag), 12 | ...yearStats.total.outcomeTransactions.filter(tr => !tr.tag), 13 | ] 14 | const count = noTag.length 15 | return ( 16 | 17 | 18 | {count ? ( 19 | <> 20 | props.onShowTransactions(noTag)} 23 | > 24 | 25 | {t('title', { count })} 26 | 27 | 28 | 29 | {t('withoutCategory', { count })} 30 | 31 | 32 | ) : ( 33 | <> 34 | 35 | 👍 36 | 37 | 38 | {t('allTransactionsHaveCategories')} 39 | 40 | 41 | )} 42 | 43 | 44 | ) 45 | } 46 | -------------------------------------------------------------------------------- /src/2-pages/Review/cards/NotFunCard/TagSelect.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { useTranslation } from 'react-i18next' 3 | import { 4 | Checkbox, 5 | FormControl, 6 | InputLabel, 7 | ListItemText, 8 | MenuItem, 9 | OutlinedInput, 10 | Select, 11 | } from '@mui/material' 12 | import { formatMoney } from '6-shared/helpers/money' 13 | import { TTagId } from '6-shared/types' 14 | 15 | type TagSelectProps = { 16 | options: { id: TTagId; name: string; amount: number }[] 17 | onChange: (opts: TTagId[]) => void 18 | selected: TTagId[] 19 | label: string 20 | } 21 | 22 | export function TagSelect(props: TagSelectProps) { 23 | const { t } = useTranslation('common') 24 | let { options, onChange, selected, label } = props 25 | let renderText = (selected: TagSelectProps['selected']) => { 26 | if (selected.length === 1) { 27 | let opt = options.find(opt => opt.id === selected[0]) 28 | if (opt?.name) return opt.name 29 | } 30 | return t('tagSelected', { count: selected.length }) 31 | } 32 | 33 | return ( 34 | 35 | {label} 36 | 57 | 58 | ) 59 | } 60 | -------------------------------------------------------------------------------- /src/2-pages/Review/cards/NotFunCard/index.tsx: -------------------------------------------------------------------------------- 1 | export { NotFunCard } from './NotFunCard' 2 | -------------------------------------------------------------------------------- /src/2-pages/Review/cards/QRCard.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { useTranslation } from 'react-i18next' 3 | import { ButtonBase, Stack, Typography } from '@mui/material' 4 | import { Card, TCardProps } from '../shared/Card' 5 | import { useStats } from '../shared/getFacts' 6 | 7 | export function QRCard(props: TCardProps) { 8 | const { t } = useTranslation('yearReview', { keyPrefix: 'qrCard' }) 9 | const yearStats = useStats(props.year) 10 | const hasReceipt = yearStats.total.outcomeTransactions.filter(tr => tr.qrCode) 11 | const value = hasReceipt.length 12 | 13 | if (!value) return null 14 | return ( 15 | 16 | props.onShowTransactions(hasReceipt)} 19 | > 20 | 25 | 26 | {t('youAttached')} 27 | 28 | 29 | {t('receipt', { count: value })} 30 | 31 | 32 | 33 | 34 | ) 35 | } 36 | -------------------------------------------------------------------------------- /src/2-pages/Review/shared/Card.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Box, BoxProps } from '@mui/material' 3 | import { TTransaction } from '6-shared/types' 4 | 5 | export type TCardProps = { 6 | year: string | number 7 | onShowTransactions: (t: TTransaction[]) => void 8 | } 9 | 10 | export const Card = (props: BoxProps) => ( 11 | 29 | ) 30 | -------------------------------------------------------------------------------- /src/2-pages/Review/shared/useTrToDisplay.tsx: -------------------------------------------------------------------------------- 1 | import { displayCurrency } from '5-entities/currency/displayCurrency' 2 | import { instrumentModel } from '5-entities/currency/instrument' 3 | import { TTransaction } from '6-shared/types' 4 | 5 | export function useTrToDisplay() { 6 | const toDisplay = displayCurrency.useToDisplay('current') 7 | const instCodeMap = instrumentModel.useInstCodeMap() 8 | 9 | return (tr: TTransaction) => { 10 | return { 11 | income: toDisplay({ [instCodeMap[tr.incomeInstrument]]: tr.income }), 12 | outcome: toDisplay({ [instCodeMap[tr.outcomeInstrument]]: tr.outcome }), 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/2-pages/Stats/WidgetAccHistory/index.ts: -------------------------------------------------------------------------------- 1 | export { WidgetAccHistory } from './WidgetAccHistory' 2 | -------------------------------------------------------------------------------- /src/2-pages/Stats/WidgetAccHistory/model.ts: -------------------------------------------------------------------------------- 1 | import { GroupBy } from '6-shared/helpers/date' 2 | import { TAccountId, TISODate } from '6-shared/types' 3 | import { accBalanceModel } from '5-entities/accBalances' 4 | import { getStart, Period } from '../shared/period' 5 | import { accountModel } from '5-entities/account' 6 | import { useAppSelector } from 'store/index' 7 | import { useMemo } from 'react' 8 | 9 | export type TPoint = { 10 | date: TISODate 11 | balance: number 12 | } 13 | 14 | export function useAccountHistory(id: TAccountId, period: Period): TPoint[] { 15 | let { fxCode } = accountModel.usePopulatedAccounts()[id] 16 | 17 | let allBalances = useAppSelector(accBalanceModel.getBalancesByDate) 18 | 19 | return useMemo(() => { 20 | const firstDate = getStart(period, GroupBy.Day) 21 | const filtered = firstDate 22 | ? allBalances.filter(({ date }) => date >= firstDate!) 23 | : allBalances 24 | 25 | return filtered.map(({ date, balances }) => ({ 26 | date, 27 | balance: balances.accounts?.[id]?.[fxCode] || 0, 28 | })) 29 | }, [allBalances, period, fxCode, id]) 30 | } 31 | -------------------------------------------------------------------------------- /src/2-pages/Stats/WidgetCashflow/index.ts: -------------------------------------------------------------------------------- 1 | export { WidgetCashflow } from './WidgetCashflow' 2 | -------------------------------------------------------------------------------- /src/2-pages/Stats/WidgetNetWorth/index.ts: -------------------------------------------------------------------------------- 1 | export { WidgetNetWorth } from './WidgetNetWorth' 2 | -------------------------------------------------------------------------------- /src/2-pages/Stats/WidgetNetWorth/useAverageExpenses.ts: -------------------------------------------------------------------------------- 1 | import { useMemo } from 'react' 2 | import { GroupBy } from '6-shared/helpers/date' 3 | import { useCashFlow, summarizeCashflow } from '../shared/cashflow' 4 | import { Period } from '../shared/period' 5 | 6 | /** Hook to get average monthly expenses */ 7 | export function useAverageExpenses(): number { 8 | const points = useCashFlow(Period.LastYear, GroupBy.Month) 9 | 10 | return useMemo(() => { 11 | const length = points.length 12 | if (!length) return 0 13 | const summary = summarizeCashflow(points) 14 | const avgMonthlyExpenses = summary.outcome / length 15 | return avgMonthlyExpenses 16 | }, [points]) 17 | } 18 | -------------------------------------------------------------------------------- /src/2-pages/Stats/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useCallback } from 'react' 2 | import { Stack } from '@mui/system' 3 | 4 | import { WidgetNetWorth } from './WidgetNetWorth' 5 | import { WidgetCashflow } from './WidgetCashflow' 6 | import { WidgetAccHistory } from './WidgetAccHistory' 7 | import { nextPeriod, Period } from './shared/period' 8 | 9 | export default function Stats() { 10 | const [period, setPeriod] = useState(Period.LastYear) 11 | const togglePeriod = useCallback( 12 | () => setPeriod(prevPeriod => nextPeriod(prevPeriod)), 13 | [] 14 | ) 15 | 16 | return ( 17 | 24 | 25 | 26 | 27 | 28 | ) 29 | } 30 | -------------------------------------------------------------------------------- /src/2-pages/Stats/shared/period.ts: -------------------------------------------------------------------------------- 1 | import { TISODate } from '6-shared/types' 2 | import { GroupBy, toGroup, toISODate } from '6-shared/helpers/date' 3 | import { useTranslation } from 'react-i18next' 4 | 5 | export enum Period { 6 | LastYear = 'LastYear', 7 | ThreeYears = 'ThreeYears', 8 | All = 'All', 9 | } 10 | 11 | export function getStart( 12 | period: Period, 13 | aggregation: GroupBy 14 | ): TISODate | undefined { 15 | if (period === Period.All) return undefined //'2000-01-01' as TISODate 16 | if (period === Period.LastYear) { 17 | const date = new Date() 18 | date.setFullYear(date.getFullYear() - 1) 19 | date.setDate(date.getDate() + 1) // Exclude the first day 20 | return toGroup(toISODate(date), aggregation) 21 | } 22 | if (period === Period.ThreeYears) { 23 | const date = new Date() 24 | date.setFullYear(date.getFullYear() - 3) 25 | date.setDate(date.getDate() + 1) // Exclude the first day 26 | return toGroup(toISODate(date), aggregation) 27 | } 28 | throw new Error(`Unknown period: ${period}`) 29 | } 30 | 31 | const order = [Period.All, Period.LastYear, Period.ThreeYears] 32 | export const nextPeriod = (current: Period) => { 33 | const currIdx = order.findIndex(p => p === current) 34 | const nextIdx = (currIdx + 1) % order.length 35 | return order[nextIdx] 36 | } 37 | 38 | /** 39 | * Returns the localized title of the period 40 | */ 41 | export const PeriodTitle = (props: { period: Period }) => { 42 | const { t } = useTranslation('analytics') 43 | const { period } = props 44 | if (period === Period.All) return t('period_all') 45 | if (period === Period.LastYear) return t('period_year') 46 | if (period === Period.ThreeYears) return t('period_3years') 47 | console.error(`Unknown period: ${period}`) 48 | return null 49 | } 50 | -------------------------------------------------------------------------------- /src/2-pages/Token.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Box, Button, Typography } from '@mui/material' 3 | import { Helmet } from 'react-helmet' 4 | import { useTranslation } from 'react-i18next' 5 | import { getToken } from 'store/token' 6 | import { useAppSelector } from 'store' 7 | 8 | export default function Token() { 9 | const { t } = useTranslation('token') 10 | const token = useAppSelector(getToken) 11 | const [tokenIsVisible, setTokenVisibility] = React.useState(false) 12 | 13 | return ( 14 | <> 15 | 16 | {t('pageTitle')} | Zerro 17 | 18 | 19 | 20 | 28 | 29 | 30 | {t('heading')} 31 | 32 | 33 | 34 | {t('body')} 35 | 36 | 37 | 38 | 45 | 46 | 47 | {tokenIsVisible ?

{token}

: null} 48 |
49 |
50 | 51 | ) 52 | } 53 | -------------------------------------------------------------------------------- /src/3-widgets/Amount.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react' 2 | import { OptionalExceptFor, TInstrumentId } from '6-shared/types' 3 | import { AmountProps, Amount } from '6-shared/ui/Amount' 4 | 5 | import { instrumentModel } from '5-entities/currency/instrument' 6 | import { userModel } from '5-entities/user' 7 | 8 | export type TSmartAmountProps = AmountProps & { 9 | instrument?: TInstrumentId | 'user' 10 | } 11 | 12 | export const SmartAmount: FC = props => { 13 | if (props.instrument !== undefined) 14 | return 15 | else return 16 | } 17 | 18 | type ConnectedAmountProps = OptionalExceptFor< 19 | Required, 20 | 'value' | 'instrument' 21 | > 22 | function ConnectedAmount(props: ConnectedAmountProps) { 23 | const userInstrumentId = userModel.useUserInstrumentId() 24 | const instruments = instrumentModel.useInstruments() 25 | const id = props.instrument === 'user' ? userInstrumentId : props.instrument 26 | const currency = id ? instruments?.[id]?.shortTitle : undefined 27 | return 28 | } 29 | -------------------------------------------------------------------------------- /src/3-widgets/ErrorBoundary/ErrorMessage.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react' 2 | import { Box, Typography, Button } from '@mui/material' 3 | import { SyncIcon } from '6-shared/ui/Icons' 4 | import { useTranslation } from 'react-i18next' 5 | 6 | interface ErrorMessageProps { 7 | onLogOut: () => void 8 | message: string 9 | } 10 | 11 | export const ErrorMessage: FC = ({ onLogOut, message }) => { 12 | const { t } = useTranslation('errorBoudary') 13 | return ( 14 | 22 | 29 | 30 | {t('title')} 31 | 32 | 33 | 34 | {t('description')} 35 | 36 | 37 | 38 | 46 | 47 | 50 | 51 | {!!message && ( 52 | 56 | {t('errorMsg', { message })} 57 | 58 | )} 59 | 60 | 61 | 62 | ) 63 | } 64 | -------------------------------------------------------------------------------- /src/3-widgets/ErrorBoundary/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactNode } from 'react' 2 | import { connect } from 'react-redux' 3 | import { logOut } from '4-features/authorization' 4 | import { captureError, sendEvent } from '6-shared/helpers/tracking' 5 | import { ErrorMessage } from './ErrorMessage' 6 | import { AppDispatch } from 'store' 7 | 8 | interface ErrorBoundaryProps { 9 | logOut: () => void 10 | children: ReactNode 11 | } 12 | interface ErrorBoundaryState { 13 | hasError: boolean 14 | } 15 | 16 | class ErrorBoundary extends React.Component< 17 | ErrorBoundaryProps, 18 | ErrorBoundaryState 19 | > { 20 | state = { hasError: false, message: '' } 21 | static getDerivedStateFromError = (error: Error) => ({ 22 | hasError: true, 23 | message: error.message, 24 | }) 25 | componentDidCatch = (error: Error, errorInfo: React.ErrorInfo) => { 26 | sendEvent(`Error: ${error.message}`) 27 | captureError(error, errorInfo) 28 | } 29 | render() { 30 | return this.state.hasError ? ( 31 | 32 | ) : ( 33 | this.props.children 34 | ) 35 | } 36 | } 37 | 38 | const mapDispatchToProps = (dispatch: AppDispatch) => ({ 39 | logOut: () => dispatch(logOut()), 40 | }) 41 | 42 | export default connect(null, mapDispatchToProps)(ErrorBoundary) 43 | -------------------------------------------------------------------------------- /src/3-widgets/Navigation/MenuButton.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react' 2 | import { useTranslation } from 'react-i18next' 3 | import { IconButton, IconButtonProps } from '@mui/material' 4 | import { SettingsIcon } from '6-shared/ui/Icons' 5 | import { Tooltip } from '6-shared/ui/Tooltip' 6 | 7 | import { SettingsMenu, useSettingsMenu } from './SettingsMenu' 8 | 9 | interface MenuButtonProps extends IconButtonProps { 10 | showLinks?: boolean 11 | } 12 | 13 | export const MenuButton: FC = ({ showLinks, ...rest }) => { 14 | const { t } = useTranslation('navigation') 15 | const openSettings = useSettingsMenu() 16 | return ( 17 | <> 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | ) 26 | } 27 | -------------------------------------------------------------------------------- /src/3-widgets/Navigation/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import NavDrawer from './NavDrawer' 3 | import { MobileNavigation } from './MobileNavigation' 4 | 5 | const drawerWidth = 280 6 | const contentSx = { 7 | width: drawerWidth, 8 | [`& .MuiDrawer-paper`]: { 9 | width: drawerWidth, 10 | scrollbarWidth: 'none', 11 | overflow: '-moz-scrollbars-none', 12 | '&::-webkit-scrollbar': { display: 'none' }, 13 | }, 14 | } 15 | 16 | export default function Header() { 17 | return ( 18 | 19 | ) 20 | } 21 | 22 | export { MobileNavigation } 23 | -------------------------------------------------------------------------------- /src/3-widgets/global/TransactionPreviewDrawer.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Drawer, Box } from '@mui/material' 3 | import { registerPopover } from '6-shared/historyPopovers' 4 | import { TTransactionId } from '6-shared/types' 5 | import { 6 | TransactionPreview, 7 | TransactionPreviewProps, 8 | } from '../transaction/TransactionPreview' 9 | 10 | export type TransactionPreviewDrawerProps = { 11 | id: TTransactionId 12 | onOpenOther?: TransactionPreviewProps['onOpenOther'] 13 | onSelectSimilar?: TransactionPreviewProps['onSelectSimilar'] 14 | } 15 | 16 | const trDrawerHooks = registerPopover('transaction-preview-drawer', { 17 | id: '', 18 | } as TransactionPreviewDrawerProps) 19 | 20 | export const useTransactionPreview = trDrawerHooks.useMethods 21 | 22 | export const SmartTransactionPreview = () => { 23 | const drawer = trDrawerHooks.useProps() 24 | const { id, onOpenOther = () => {}, onSelectSimilar } = drawer.extraProps 25 | const { onClose, open } = drawer.displayProps 26 | return ( 27 | 34 | 42 | 48 | 49 | 50 | ) 51 | } 52 | 53 | const drawerWidth = { xs: '100vw', sm: 360 } 54 | const contentSx = { 55 | width: drawerWidth, 56 | [`& .MuiDrawer-paper`]: { width: drawerWidth }, 57 | } 58 | -------------------------------------------------------------------------------- /src/3-widgets/global/shared/helpers.ts: -------------------------------------------------------------------------------- 1 | import { MenuProps } from '@mui/material' 2 | 3 | export function getMenuPosition(anchorPosition?: { 4 | left: number 5 | top: number 6 | }): Partial { 7 | return anchorPosition 8 | ? { 9 | anchorReference: 'anchorPosition', 10 | anchorPosition, 11 | } 12 | : { 13 | anchorReference: 'anchorPosition', 14 | anchorPosition: { 15 | // Center of the screen 16 | left: window.innerWidth / 2, 17 | top: window.innerHeight / 2, 18 | }, 19 | transformOrigin: { horizontal: 'center', vertical: 'center' }, 20 | } 21 | } 22 | 23 | export function getEventPosition(event: React.MouseEvent | React.TouchEvent) { 24 | if ('touches' in event) { 25 | const touch = event.touches[0] 26 | return { left: touch.clientX, top: touch.clientY } 27 | } 28 | return { left: event.clientX, top: event.clientY } 29 | } 30 | -------------------------------------------------------------------------------- /src/3-widgets/transaction/TransactionList/TopBar/transitions.css: -------------------------------------------------------------------------------- 1 | .actions-transition-enter { 2 | opacity: 0; 3 | transform: translateY(16px); 4 | } 5 | .actions-transition-enter-active { 6 | opacity: 1; 7 | transform: translateY(0px); 8 | transition: 200ms ease-out; 9 | transition-property: opacity, transform; 10 | } 11 | .actions-transition-exit { 12 | opacity: 1; 13 | transform: translateY(0px); 14 | } 15 | .actions-transition-exit-active { 16 | opacity: 0; 17 | transform: translateY(16px); 18 | transition: 200ms ease-out; 19 | transition-property: opacity, transform; 20 | } 21 | .actions-transition-exit-done { 22 | display: none; 23 | } 24 | -------------------------------------------------------------------------------- /src/3-widgets/transaction/TransactionList/Transaction/index.tsx: -------------------------------------------------------------------------------- 1 | export type { TTransactionProps } from './Transaction' 2 | export { Transaction } from './Transaction' 3 | -------------------------------------------------------------------------------- /src/3-widgets/transaction/TransactionPreview/Map.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react' 2 | import { BoxProps, Paper } from '@mui/material' 3 | import styled from '@emotion/styled' 4 | 5 | interface MapProps { 6 | longitude?: number | null 7 | latitude?: number | null 8 | sx?: BoxProps['sx'] 9 | } 10 | 11 | export const Map: FC = ({ longitude, latitude, sx }) => { 12 | if (!(longitude && latitude)) return null 13 | 14 | return ( 15 | 16 | 25 | 26 | ) 27 | } 28 | 29 | const StyledIframe = styled.iframe` 30 | width: 100%; 31 | margin-bottom: -4px; 32 | ` 33 | -------------------------------------------------------------------------------- /src/4-features/budget/convertZmBudgetsToZerro.ts: -------------------------------------------------------------------------------- 1 | import { toISOMonth } from '6-shared/helpers/date' 2 | import { ById, TBudget, globalBudgetTagId } from '6-shared/types' 3 | import { getTagBudgets, setEnvBudget, TBudgetUpdate } from '5-entities/budget' 4 | import { envelopeModel, EnvType } from '5-entities/envelope' 5 | import { AppThunk } from 'store' 6 | 7 | export function convertZmBudgetsToZerro(): AppThunk { 8 | return (dispatch, getState) => { 9 | const tagBudgets = getTagBudgets(getState()) 10 | const updates = convertTagBudgetsToUpdates(tagBudgets) 11 | dispatch(setEnvBudget(updates)) 12 | return updates 13 | } 14 | } 15 | 16 | function convertTagBudgetsToUpdates(tagBudgets: ById) { 17 | let updates = [] as TBudgetUpdate[] 18 | 19 | Object.values(tagBudgets).forEach(budget => { 20 | if (!budget.outcome) return 21 | if (budget.tag === globalBudgetTagId) return 22 | updates.push({ 23 | id: envelopeModel.makeId(EnvType.Tag, String(budget.tag)), 24 | month: toISOMonth(budget.date), 25 | value: budget.outcome, 26 | }) 27 | }) 28 | 29 | return updates 30 | } 31 | -------------------------------------------------------------------------------- /src/4-features/budget/setTotalBudget.ts: -------------------------------------------------------------------------------- 1 | import { round } from '6-shared/helpers/money' 2 | import { AppThunk } from 'store' 3 | 4 | import { balances } from '5-entities/envBalances' 5 | import { budgetModel, TBudgetUpdate } from '5-entities/budget' 6 | import { fxRateModel } from '5-entities/currency/fxRate' 7 | 8 | export function setTotalBudget(upd: TBudgetUpdate | TBudgetUpdate[]): AppThunk { 9 | return (dispatch, getState) => { 10 | const state = getState() 11 | const envMetrics = balances.envData(state) 12 | const updates = Array.isArray(upd) ? upd : [upd] 13 | const convertFx = fxRateModel.converter(state) 14 | 15 | const adjusted = updates.map(adjustValue) 16 | dispatch(budgetModel.set(adjusted)) 17 | 18 | /** Adjusts budget depending on children budgets */ 19 | function adjustValue(u: TBudgetUpdate): TBudgetUpdate { 20 | const { childrenBudgeted, currency } = envMetrics[u.month][u.id] 21 | const childrenValue = convertFx(childrenBudgeted, currency, u.month) 22 | return { ...u, value: round(u.value - childrenValue) } 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/4-features/bulkActions/copyPrevMonth/index.ts: -------------------------------------------------------------------------------- 1 | import { sendEvent } from '6-shared/helpers/tracking' 2 | import { AppThunk } from 'store' 3 | import { TISOMonth } from '6-shared/types' 4 | import { prevMonth, toISOMonth } from '6-shared/helpers/date' 5 | import { balances } from '5-entities/envBalances' 6 | import { TBudgetUpdate } from '5-entities/budget' 7 | import { setBudget } from '5-entities/budget/setBudget' 8 | 9 | export const copyPreviousBudget = 10 | (month: TISOMonth): AppThunk => 11 | (dispatch, getState) => { 12 | sendEvent('Budgets: copy previous') 13 | const envData = balances.envData(getState()) 14 | const curr = envData[month] 15 | const prev = envData[toISOMonth(prevMonth(month))] 16 | 17 | if (!curr || !prev) return 18 | 19 | const updates: TBudgetUpdate[] = [] 20 | Object.values(prev).forEach(({ id, currency, selfBudgeted }) => { 21 | let prevVal = selfBudgeted[currency] 22 | let currVal = curr[id].selfBudgeted[currency] 23 | console.assert( 24 | typeof prevVal === 'number' && typeof currVal === 'number', 25 | 'Value is not number' 26 | ) 27 | if (prevVal === currVal) return 28 | updates.push({ id, value: prevVal, month }) 29 | }) 30 | dispatch(setBudget(updates)) 31 | } 32 | -------------------------------------------------------------------------------- /src/4-features/bulkActions/fillGoals/index.ts: -------------------------------------------------------------------------------- 1 | export { totalGoalsModel } from './model' 2 | export { GoalsProgress } from './ui/GoalsProgress' 3 | -------------------------------------------------------------------------------- /src/4-features/bulkActions/fillGoals/model/fillGoals.ts: -------------------------------------------------------------------------------- 1 | import { sendEvent } from '6-shared/helpers/tracking' 2 | import { TISOMonth } from '6-shared/types' 3 | import { AppThunk } from 'store' 4 | import { goalModel, goalType } from '5-entities/goal' 5 | import { TBudgetUpdate } from '5-entities/budget' 6 | import { setTotalBudget } from '4-features/budget/setTotalBudget' 7 | 8 | export const fillGoals = 9 | (month: TISOMonth): AppThunk => 10 | (dispatch, getState) => { 11 | sendEvent('Budgets: fill goals') 12 | const goals = goalModel.get(getState())[month] 13 | const updates: TBudgetUpdate[] = [] 14 | 15 | Object.values(goals).forEach(goalInfo => { 16 | let { id, goal, needNow, targetBudget } = goalInfo 17 | // Ignore filled goals 18 | if (!needNow) return 19 | // Ignore endless goals with target balance 20 | if (goal.type === goalType.TARGET_BALANCE && !goal.end) return 21 | updates.push({ id, month, value: targetBudget }) 22 | }) 23 | 24 | dispatch(setTotalBudget(updates)) 25 | } 26 | -------------------------------------------------------------------------------- /src/4-features/bulkActions/fillGoals/model/index.ts: -------------------------------------------------------------------------------- 1 | import { fillGoals } from './fillGoals' 2 | 3 | export const totalGoalsModel = { fillAll: fillGoals } 4 | -------------------------------------------------------------------------------- /src/4-features/bulkActions/fixOverspend/index.ts: -------------------------------------------------------------------------------- 1 | export { OverspendNotice } from './ui/OverspendNotice' 2 | export { overspendModel } from './model' 3 | -------------------------------------------------------------------------------- /src/4-features/bulkActions/fixOverspend/model/index.ts: -------------------------------------------------------------------------------- 1 | import { fixOverspends } from './fixOverspends' 2 | 3 | export const overspendModel = { fixAll: fixOverspends } 4 | -------------------------------------------------------------------------------- /src/4-features/envelope/arrayMove.ts: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | 0 group 4 | 1 - parent 5 | 2 - - child 1 6 | 3 - - child 2 7 | 4 - - child 3 8 | 5 - parent 9 | 6 - - child 1 10 | 7 group 11 | 8 - parent 12 | 9 - - child 1 13 | 10 - - child 2 14 | 11 - - child 3 15 | 12 - parent 16 | 13 - - child 1 17 | 14 - - child 2 18 | 15 - - child 3 19 | 20 | */ 21 | export function arrayMove( 22 | arr: Array, 23 | fromIndex: number, 24 | toIndex: number 25 | ) { 26 | var element = arr[fromIndex] 27 | arr.splice(fromIndex, 1) 28 | arr.splice(toIndex, 0, element) 29 | } 30 | -------------------------------------------------------------------------------- /src/4-features/envelope/assignNewGroup.ts: -------------------------------------------------------------------------------- 1 | import { t } from 'i18next' 2 | import { 3 | envelopeModel, 4 | TEnvelopeDraft, 5 | TEnvelopeId, 6 | TGroupNode, 7 | } from '5-entities/envelope' 8 | import { AppThunk } from 'store/index' 9 | 10 | export function assignNewGroup(id: TEnvelopeId): AppThunk { 11 | return (dispatch, getState) => { 12 | const envelopes = envelopeModel.getEnvelopes(getState()) 13 | const structure = envelopeModel.getEnvelopeStructure(getState()) 14 | const groupName = getNewGroupName(structure) 15 | 16 | const patches: TEnvelopeDraft[] = Object.values(envelopes).map(e => { 17 | if (e.id === id) { 18 | return { id, group: groupName, indexRaw: 0 } 19 | } 20 | return { id: e.id, indexRaw: e.index + 1 } 21 | }) 22 | 23 | dispatch(envelopeModel.patchEnvelope(patches)) 24 | } 25 | } 26 | 27 | function getNewGroupName(structure: TGroupNode[]) { 28 | const baseName = t('groupNew', { ns: 'common' }) 29 | let names = structure 30 | .map(group => group.id) 31 | .filter(name => name.startsWith(baseName)) 32 | if (names.length === 0) return baseName 33 | 34 | let i = 2 35 | while (names.indexOf(`${baseName} ${i}`) >= 0) { 36 | i++ 37 | } 38 | return `${baseName} ${i}` 39 | } 40 | -------------------------------------------------------------------------------- /src/4-features/envelope/createEnvelope.ts: -------------------------------------------------------------------------------- 1 | import { AppThunk } from 'store' 2 | import { tagModel } from '5-entities/tag' 3 | import { envelopeModel, EnvType, TEnvelope } from '5-entities/envelope' 4 | import { t } from 'i18next' 5 | 6 | export const createEnvelope = 7 | (draft: Partial): AppThunk => 8 | (dispatch, getState) => { 9 | const newTag = dispatch( 10 | tagModel.createTag({ 11 | title: draft.name || t('tagNew', { ns: 'common' }), 12 | showOutcome: true, 13 | }) 14 | )[0].id 15 | const id = envelopeModel.makeId(EnvType.Tag, newTag) 16 | dispatch(envelopeModel.patchEnvelope({ ...draft, id })) 17 | return id 18 | } 19 | 20 | export const createEnvelopeInGroup = 21 | (group: string): AppThunk => 22 | (dispatch, getState) => { 23 | // In order for group not jump to the top of the list 24 | // we need to find the first envelope in the group 25 | // and create a new envelope right before it 26 | const envelopes = envelopeModel.getEnvelopes(getState()) 27 | const structure = envelopeModel.getEnvelopeStructure(getState()) 28 | const groupNode = structure.find(gr => gr.id === group) 29 | if (!groupNode) return 30 | const firstEnvId = groupNode.children[0]?.id 31 | const firstEnvIdx = envelopes[firstEnvId].indexRaw || 0 32 | return dispatch(createEnvelope({ group, indexRaw: firstEnvIdx - 1 })) 33 | } 34 | -------------------------------------------------------------------------------- /src/4-features/envelope/moveGroup.ts: -------------------------------------------------------------------------------- 1 | import { 2 | envelopeModel, 3 | TEnvelopeDraft, 4 | TEnvelopeId, 5 | TEnvNode, 6 | TGroupNode, 7 | } from '5-entities/envelope' 8 | import { entries } from '6-shared/helpers/keys' 9 | import { AppThunk } from 'store/index' 10 | import { arrayMove } from './arrayMove' 11 | 12 | export function moveGroup(fromIdx: number, toIdx: number): AppThunk { 13 | return (dispatch, getState) => { 14 | const structure = envelopeModel.getEnvelopeStructure(getState()) 15 | if (fromIdx === toIdx) return 16 | if (!structure[fromIdx] || !structure[toIdx]) return 17 | 18 | const updatedStructure = [...structure] 19 | arrayMove(updatedStructure, fromIdx, toIdx) 20 | const indices = getIndices(updatedStructure) 21 | 22 | const patches: TEnvelopeDraft[] = entries(indices).map( 23 | ([id, indexRaw]) => ({ id, indexRaw }) 24 | ) 25 | 26 | dispatch(envelopeModel.patchEnvelope(patches)) 27 | } 28 | } 29 | 30 | function getIndices(structure: TGroupNode[]) { 31 | let indices: Record = {} 32 | let idx = 0 33 | structure.forEach(group => group.children.forEach(addIndex)) 34 | return indices 35 | 36 | function addIndex(el: TEnvNode) { 37 | indices[el.id] = idx 38 | idx++ 39 | el.children.forEach(addIndex) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/4-features/envelope/renameGroup.ts: -------------------------------------------------------------------------------- 1 | import { envelopeModel, TEnvelopeDraft } from '5-entities/envelope' 2 | import { AppThunk } from 'store/index' 3 | 4 | export function renameGroup(prevName: string, nextName: string): AppThunk { 5 | return (dispatch, getState) => { 6 | let trimmedNext = nextName.trim() 7 | if (prevName === nextName || prevName === trimmedNext) return 8 | if (!prevName || !trimmedNext) return 9 | const envelopes = envelopeModel.getEnvelopes(getState()) 10 | const patches: TEnvelopeDraft[] = [] 11 | 12 | Object.values(envelopes).forEach(e => { 13 | if (e.group !== prevName) return 14 | patches.push({ id: e.id, group: trimmedNext }) 15 | }) 16 | 17 | if (patches.length) dispatch(envelopeModel.patchEnvelope(patches)) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/4-features/export/exportCSV/index.ts: -------------------------------------------------------------------------------- 1 | export { exportCSV } from './exportCSV' 2 | -------------------------------------------------------------------------------- /src/4-features/export/exportCSV/populateTransaction.ts: -------------------------------------------------------------------------------- 1 | import { TrType } from '5-entities/transaction' 2 | import { getType } from '5-entities/transaction/helpers' 3 | import { 4 | ByIdOld, 5 | Modify, 6 | TAccount, 7 | TInstrument, 8 | TTag, 9 | TTagId, 10 | TTransaction, 11 | } from '6-shared/types' 12 | 13 | interface DataSources { 14 | instruments: { [id: number]: TInstrument } 15 | accounts: { [id: string]: TAccount } 16 | tags: { [id: string]: TTag } 17 | } 18 | 19 | export type PopulatedTransaction = Modify< 20 | TTransaction, 21 | { 22 | incomeInstrument: TInstrument 23 | incomeAccount: TAccount 24 | opIncomeInstrument: TInstrument 25 | outcomeInstrument: TInstrument 26 | outcomeAccount: TAccount 27 | opOutcomeInstrument: TInstrument 28 | tag: TTag[] | null 29 | type: TrType 30 | } 31 | > 32 | 33 | export const populateTransaction = ( 34 | { instruments, accounts, tags }: DataSources, 35 | raw: TTransaction 36 | ) => ({ 37 | ...raw, 38 | incomeInstrument: instruments[raw.incomeInstrument], 39 | incomeAccount: accounts[raw.incomeAccount], 40 | opIncomeInstrument: instruments[Number(raw.opIncomeInstrument)], 41 | outcomeInstrument: instruments[raw.outcomeInstrument], 42 | outcomeAccount: accounts[raw.outcomeAccount], 43 | opOutcomeInstrument: instruments[Number(raw.opOutcomeInstrument)], 44 | tag: mapTags(raw.tag, tags), 45 | //COMPUTED PROPERTIES 46 | type: getType(raw), 47 | }) 48 | 49 | function mapTags(ids: TTagId[] | null, tags: ByIdOld) { 50 | // TODO: Надо что-то придумать с null тегом 🤔 ⤵ 51 | return ids && ids.length ? ids.map(id => tags[id + '']) : null 52 | } 53 | -------------------------------------------------------------------------------- /src/4-features/export/exportJSON.ts: -------------------------------------------------------------------------------- 1 | import { formatDate } from '6-shared/helpers/date' 2 | import { AppThunk } from 'store' 3 | import { getDataToSave } from '../shared/getDataToSave' 4 | 5 | export const exportJSON: AppThunk = (_, getState) => { 6 | const state = getState() 7 | const data = getDataToSave(state) 8 | const content = JSON.stringify(data, null, 2) 9 | const blob = new Blob([content], { type: 'text/json' }) 10 | const href = window.URL.createObjectURL(blob) 11 | const fileName = `zm-backup-${formatDate(Date.now(), 'yyyyMMdd-HHmm')}.json` 12 | 13 | var link = document.createElement('a') 14 | link.setAttribute('href', href) 15 | link.setAttribute('download', fileName) 16 | document.body.appendChild(link) // Required for FF 17 | 18 | link.click() 19 | } 20 | -------------------------------------------------------------------------------- /src/4-features/localData.ts: -------------------------------------------------------------------------------- 1 | import { AppThunk } from 'store' 2 | import { applyServerPatch } from 'store/data' 3 | import { getDataToSave } from '4-features/shared/getDataToSave' 4 | import { TLocalData } from '6-shared/types' 5 | import { getLocalData, clearStorage, saveLocalData } from 'worker' 6 | 7 | type LocalKey = keyof TLocalData 8 | 9 | const LOCAL_KEYS = [ 10 | 'serverTimestamp', 11 | 'instrument', 12 | 'user', 13 | 'merchant', 14 | 'country', 15 | 'company', 16 | 'reminder', 17 | 'reminderMarker', 18 | 'account', 19 | 'tag', 20 | 'budget', 21 | 'transaction', 22 | ] as LocalKey[] 23 | 24 | export const saveDataLocally = 25 | (changedDomains = LOCAL_KEYS): AppThunk => 26 | (dispatch, getState) => { 27 | const state = getState() 28 | const data = getDataToSave(state) 29 | const changed = Object.assign( 30 | {}, 31 | ...changedDomains.map(key => ({ [key]: data[key] })) 32 | ) 33 | 34 | saveLocalData(changed) 35 | } 36 | 37 | export const loadLocalData = (): AppThunk => async dispatch => { 38 | const data = await getLocalData() 39 | dispatch(applyServerPatch(data)) 40 | return data 41 | } 42 | 43 | export const clearLocalData = (): AppThunk => () => clearStorage() 44 | -------------------------------------------------------------------------------- /src/4-features/moveMoney/index.ts: -------------------------------------------------------------------------------- 1 | export type { MoveMoneyModalProps } from './MoveMoneyModal' 2 | 3 | export { moveMoney } from './moveMoney' 4 | export { MoveMoneyModal } from './MoveMoneyModal' 5 | -------------------------------------------------------------------------------- /src/4-features/shared/getDataToSave.ts: -------------------------------------------------------------------------------- 1 | import { keys } from '6-shared/helpers/keys' 2 | import { RootState } from 'store' 3 | import { TDiff, TLocalData } from '6-shared/types' 4 | import { convertDiff } from '6-shared/api/zm-adapter' 5 | 6 | export const getDataToSave = (state: RootState): TLocalData => { 7 | const data = state.data.server 8 | if (!data) return { serverTimestamp: 0 } 9 | let result: TDiff = { serverTimestamp: 0 } 10 | keys(data).forEach(key => { 11 | if (key === 'serverTimestamp') { 12 | result[key] = data[key] 13 | } else { 14 | result[key] = Object.values(data[key]) 15 | } 16 | }) 17 | return convertDiff.toServer(result) 18 | } 19 | -------------------------------------------------------------------------------- /src/5-entities/accBalances/getBalancesByDate.ts: -------------------------------------------------------------------------------- 1 | import { createSelector } from '@reduxjs/toolkit' 2 | import { GroupBy, makeDateArray } from '6-shared/helpers/date' 3 | 4 | import { TSelector } from 'store/index' 5 | import { displayCurrency } from '5-entities/currency/displayCurrency' 6 | import { trModel } from '5-entities/transaction' 7 | import { balancesToDisplay } from './shared/convertBalancesToDisplay' 8 | import { TBalanceNode } from './shared/types' 9 | import { getBalances } from './getBalances' 10 | 11 | /** 12 | * This selector builds full history from first reasonable transaction. 13 | * Zenmoney may create transactions with date 1970-01-01. 14 | */ 15 | export const getBalancesByDate: TSelector = createSelector( 16 | [getBalances, trModel.getHistoryStart], 17 | ({ byDay, startingBalances }, historyStart) => { 18 | let dates = makeDateArray(historyStart, Date.now(), GroupBy.Day) 19 | let lastUsedBalance = startingBalances 20 | return dates.map(date => { 21 | let balances = byDay[date] || lastUsedBalance 22 | lastUsedBalance = balances 23 | return { date, balances } as TBalanceNode 24 | }) 25 | } 26 | ) 27 | 28 | export const getDisplayBalancesByDate: TSelector[]> = 29 | createSelector( 30 | [getBalancesByDate, displayCurrency.getConverter], 31 | balancesToDisplay 32 | ) 33 | -------------------------------------------------------------------------------- /src/5-entities/accBalances/index.ts: -------------------------------------------------------------------------------- 1 | import { getBalances } from './getBalances' 2 | import { 3 | getBalancesByDate, 4 | getDisplayBalancesByDate, 5 | } from './getBalancesByDate' 6 | import { useBalances, useDisplayBalances } from './useBalances' 7 | 8 | export type { TBalanceNode, TBalanceState } from './shared/types' 9 | 10 | export const accBalanceModel = { 11 | // Selectors 12 | getBalances, 13 | getBalancesByDate, 14 | getDisplayBalancesByDate, 15 | 16 | // Hooks 17 | useBalances, 18 | useDisplayBalances, 19 | } 20 | -------------------------------------------------------------------------------- /src/5-entities/accBalances/shared/convertBalancesToDisplay.ts: -------------------------------------------------------------------------------- 1 | import { TDateDraft, TFxAmount } from '6-shared/types' 2 | import { entries } from '6-shared/helpers/keys' 3 | import { TBalanceNode } from './types' 4 | import { isFinite } from 'lodash' 5 | 6 | export function balancesToDisplay( 7 | list: Array>, 8 | convert: (v: TFxAmount, date: TDateDraft) => number 9 | ): Array> { 10 | return list.map(convertNode) 11 | 12 | function convertNode(node: TBalanceNode): TBalanceNode { 13 | let displayNode: TBalanceNode = { 14 | date: node.date, 15 | balances: { accounts: {}, debtors: {} }, 16 | } 17 | // Add accounts 18 | entries(node.balances.accounts).forEach(([id, amount]) => { 19 | console.assert( 20 | isFinite(convert(amount, node.date)), 21 | 'Not converted correctly: ' + JSON.stringify(amount) 22 | ) 23 | displayNode.balances.accounts[id] = convert(amount, node.date) 24 | }) 25 | // Add debtors 26 | entries(node.balances.debtors).forEach(([id, amount]) => { 27 | console.assert( 28 | isFinite(convert(amount, node.date)), 29 | 'Not converted correctly: ' + JSON.stringify(amount) 30 | ) 31 | displayNode.balances.debtors[id] = convert(amount, node.date) 32 | }) 33 | return displayNode 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/5-entities/accBalances/shared/types.ts: -------------------------------------------------------------------------------- 1 | import { TAccountId, TFxAmount, TISODate } from '6-shared/types' 2 | 3 | export type TBalanceState = { 4 | accounts: Record 5 | debtors: Record 6 | } 7 | 8 | export type TBalanceNode = { 9 | date: TISODate 10 | balances: TBalanceState 11 | } 12 | -------------------------------------------------------------------------------- /src/5-entities/accBalances/useBalances.ts: -------------------------------------------------------------------------------- 1 | import { useMemo } from 'react' 2 | import { TDateDraft, TISODate } from '6-shared/types' 3 | import { GroupBy, toGroup } from '6-shared/helpers/date' 4 | import { keys } from '6-shared/helpers/keys' 5 | 6 | import { useAppSelector } from 'store/index' 7 | import { displayCurrency } from '5-entities/currency/displayCurrency' 8 | import { balancesToDisplay } from './shared/convertBalancesToDisplay' 9 | import { TBalanceNode } from './shared/types' 10 | import { getBalancesByDate } from './getBalancesByDate' 11 | 12 | export function useBalances( 13 | aggregation: GroupBy, 14 | start?: TDateDraft, 15 | end?: TDateDraft 16 | ) { 17 | const list = useAppSelector(getBalancesByDate) 18 | const startDate = toGroup(start || list[0].date, aggregation) 19 | const endDate = toGroup(end || Date.now(), aggregation) 20 | const balances = useMemo(() => { 21 | let byGroup: Record = {} 22 | list.forEach(node => { 23 | let date = toGroup(node.date, aggregation) 24 | if (date < startDate || date > endDate) return 25 | byGroup[date] = { date, balances: node.balances } 26 | }) 27 | return keys(byGroup) 28 | .sort() 29 | .map(group => byGroup[group]) 30 | }, [aggregation, endDate, list, startDate]) 31 | 32 | return balances 33 | } 34 | 35 | export function useDisplayBalances( 36 | aggregation: GroupBy, 37 | start?: TDateDraft, 38 | end?: TDateDraft 39 | ) { 40 | const fxBalances = useBalances(aggregation, start, end) 41 | const convert = useAppSelector(displayCurrency.getConverter) 42 | const balances = useMemo( 43 | () => balancesToDisplay(fxBalances, convert), 44 | [convert, fxBalances] 45 | ) 46 | return balances 47 | } 48 | -------------------------------------------------------------------------------- /src/5-entities/account/index.ts: -------------------------------------------------------------------------------- 1 | import { useAppSelector } from 'store/index' 2 | import { 3 | getAccountList, 4 | getAccounts, 5 | getDebtAccountId, 6 | getInBudgetAccounts, 7 | getPopulatedAccounts, 8 | getSavingAccounts, 9 | } from './selectors' 10 | import { makeAccount } from './shared/makeAccount' 11 | import { patchAccount, setInBudget } from './thunks' 12 | 13 | export type { TAccountDraft } from './thunks' 14 | export type { TAccountPopulated } from './shared/populate' 15 | 16 | export const accountModel = { 17 | getAccounts, 18 | getDebtAccountId, 19 | getPopulatedAccounts, 20 | getAccountList, 21 | getInBudgetAccounts, 22 | getSavingAccounts, 23 | // Hooks 24 | useAccounts: () => useAppSelector(getAccounts), 25 | useDebtAccountId: () => useAppSelector(getDebtAccountId), 26 | usePopulatedAccounts: () => useAppSelector(getPopulatedAccounts), 27 | useAccountList: () => useAppSelector(getAccountList), 28 | useInBudgetAccounts: () => useAppSelector(getInBudgetAccounts), 29 | useSavingAccounts: () => useAppSelector(getSavingAccounts), 30 | // Actions 31 | makeAccount, 32 | // Thunks 33 | patchAccount, 34 | setInBudget, 35 | } 36 | -------------------------------------------------------------------------------- /src/5-entities/account/selectors.ts: -------------------------------------------------------------------------------- 1 | import { createSelector } from '@reduxjs/toolkit' 2 | import { populate } from './shared/populate' 3 | import { AccountType, ById } from '6-shared/types' 4 | import { RootState } from 'store' 5 | import { instrumentModel } from '5-entities/currency/instrument' 6 | import { TAccountPopulated } from './shared/populate' 7 | import { DATA_ACC_NAME } from '../old-hiddenData/constants' 8 | 9 | // SELECTORS 10 | 11 | export const getAccounts = (state: RootState) => state.data.current.account 12 | 13 | export const getDebtAccountId = createSelector([getAccounts], accounts => { 14 | for (const id in accounts) { 15 | if (accounts[id].type === AccountType.Debt) return id 16 | } 17 | }) 18 | 19 | export const getPopulatedAccounts = createSelector( 20 | [getAccounts, instrumentModel.getInstCodeMap], 21 | (accounts, fxIdMap) => { 22 | const result: ById = {} 23 | for (const id in accounts) { 24 | result[id] = populate(accounts[id], fxIdMap) 25 | } 26 | return result 27 | } 28 | ) 29 | 30 | export const getAccountList = createSelector([getPopulatedAccounts], accounts => 31 | Object.values(accounts) 32 | ) 33 | 34 | export const getInBudgetAccounts = createSelector([getAccountList], accounts => 35 | accounts.filter(a => a.inBudget) 36 | ) 37 | 38 | export const getSavingAccounts = createSelector([getAccountList], accounts => 39 | accounts.filter( 40 | acc => !acc.inBudget && acc.type !== 'debt' && acc.title !== DATA_ACC_NAME 41 | ) 42 | ) 43 | -------------------------------------------------------------------------------- /src/5-entities/account/shared/makeAccount.ts: -------------------------------------------------------------------------------- 1 | import { v1 as uuidv1 } from 'uuid' 2 | import { toISODate } from '6-shared/helpers/date' 3 | import { Modify, OptionalExceptFor, TDateDraft } from '6-shared/types' 4 | import { TAccount, TAccountId, AccountType } from '6-shared/types' 5 | 6 | type TAccountDraft = Modify< 7 | OptionalExceptFor, 8 | { startDate?: TDateDraft } 9 | > 10 | 11 | export function makeAccount(draft: TAccountDraft): TAccount { 12 | return { 13 | // Required 14 | user: draft.user, 15 | instrument: draft.instrument, 16 | title: draft.title, 17 | 18 | // Optional 19 | id: draft.id || (uuidv1() as TAccountId), 20 | changed: draft.changed || Date.now(), 21 | role: draft.role || null, 22 | company: draft.company || null, 23 | type: draft.type || AccountType.Cash, 24 | syncID: draft.syncID || null, 25 | 26 | balance: draft.balance || 0, 27 | startBalance: draft.startBalance || 0, 28 | creditLimit: draft.creditLimit || 0, 29 | 30 | inBalance: draft.inBalance || false, 31 | savings: draft.savings || false, 32 | enableCorrection: draft.enableCorrection || false, 33 | enableSMS: draft.enableSMS || false, 34 | archive: draft.archive || false, 35 | private: draft.private || false, 36 | 37 | // Для счетов с типом отличных от 'loan' и 'deposit' в этих полях можно ставить null 38 | capitalization: draft.capitalization || null, 39 | percent: draft.percent || null, 40 | startDate: draft.startDate ? toISODate(draft.startDate) : null, 41 | endDateOffset: draft.endDateOffset || null, 42 | endDateOffsetInterval: draft.endDateOffsetInterval || null, 43 | payoffStep: draft.payoffStep || null, 44 | payoffInterval: draft.payoffInterval || null, 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/5-entities/account/shared/populate.ts: -------------------------------------------------------------------------------- 1 | import { TAccount, TFxCode, AccountType } from '6-shared/types' 2 | import { TInstCodeMap } from '5-entities/currency/instrument' 3 | 4 | export type TAccountPopulated = TAccount & { 5 | startBalanceReal: number 6 | inBudget: boolean 7 | fxCode: TFxCode 8 | } 9 | 10 | export function populate( 11 | raw: TAccount, 12 | fxIdMap: TInstCodeMap 13 | ): TAccountPopulated { 14 | return { 15 | ...raw, 16 | startBalanceReal: getStartBalance(raw), 17 | inBudget: isInBudget(raw), 18 | fxCode: fxIdMap[raw.instrument], 19 | } 20 | } 21 | 22 | function getStartBalance(acc: TAccount): number { 23 | // Для deposit и loan поле startBalance имеет смысл начального взноса/тела кредита 24 | if (acc.type === AccountType.Deposit) return 0 25 | if (acc.type === AccountType.Loan) return 0 26 | return acc.startBalance 27 | } 28 | 29 | function isInBudget(a: TAccount): boolean { 30 | if (a.type === AccountType.Debt) return false 31 | if (a.title.endsWith('📍')) return true 32 | return a.inBalance 33 | } 34 | -------------------------------------------------------------------------------- /src/5-entities/account/thunks.ts: -------------------------------------------------------------------------------- 1 | import { AppThunk } from 'store' 2 | import { sendEvent } from '6-shared/helpers/tracking' 3 | import { TAccount, OptionalExceptFor, TAccountId } from '6-shared/types' 4 | import { applyClientPatch } from 'store/data' 5 | import { getAccounts } from './selectors' 6 | 7 | export type TAccountDraft = OptionalExceptFor 8 | 9 | export const patchAccount = 10 | (draft: TAccountDraft | TAccountDraft[]): AppThunk => 11 | (dispatch, getState) => { 12 | const patched: TAccount[] = [] 13 | let list = Array.isArray(draft) ? draft : [draft] 14 | 15 | list.forEach(draft => { 16 | if (!draft.id) throw new Error('Trying to patch account without id') 17 | let current = getAccounts(getState())[draft.id] 18 | if (!current) throw new Error('Account not found') 19 | patched.push({ ...current, ...draft, changed: Date.now() }) 20 | }) 21 | 22 | sendEvent('Account: edit') 23 | dispatch(applyClientPatch({ account: patched })) 24 | return patched 25 | } 26 | 27 | export const setInBudget = 28 | (id: TAccountId, inBalance: boolean): AppThunk => 29 | (dispatch, getState) => { 30 | sendEvent(`Accounts: Set in budget`) 31 | dispatch(patchAccount({ id, inBalance: !!inBalance })) 32 | } 33 | -------------------------------------------------------------------------------- /src/5-entities/budget/envBudget/budgetStore.ts: -------------------------------------------------------------------------------- 1 | import { TEnvelopeId } from '5-entities/envelope' 2 | import { 3 | HiddenDataType, 4 | makeMonthlyHiddenStore, 5 | } from '5-entities/shared/hidden-store' 6 | 7 | export type TBudgets = Record 8 | 9 | export const budgetStore = makeMonthlyHiddenStore( 10 | HiddenDataType.Budgets 11 | ) 12 | 13 | export const getEnvBudgets = budgetStore.getData 14 | -------------------------------------------------------------------------------- /src/5-entities/budget/envBudget/index.ts: -------------------------------------------------------------------------------- 1 | export { getEnvBudgets } from './budgetStore' 2 | export { setEnvBudget } from './setEnvBudget' 3 | export type { TEnvBudgetUpdate } from './setEnvBudget' 4 | export type { TBudgets } from './budgetStore' 5 | -------------------------------------------------------------------------------- /src/5-entities/budget/envBudget/setEnvBudget.ts: -------------------------------------------------------------------------------- 1 | import { TEnvelopeId } from '5-entities/envelope' 2 | import { keys } from '6-shared/helpers/keys' 3 | import { ByMonth, TISOMonth } from '6-shared/types' 4 | import { AppThunk } from 'store/index' 5 | import { budgetStore, TBudgets } from './budgetStore' 6 | 7 | export type TEnvBudgetUpdate = { 8 | id: TEnvelopeId 9 | month: TISOMonth 10 | value: number 11 | } 12 | 13 | export function setEnvBudget( 14 | upd: TEnvBudgetUpdate | TEnvBudgetUpdate[] 15 | ): AppThunk { 16 | return (dispatch, getState) => { 17 | const curentBudgets = budgetStore.getData(getState()) 18 | const updates = Array.isArray(upd) ? upd : [upd] 19 | if (!upd || !updates.length) return null 20 | 21 | let byMonth: ByMonth = {} 22 | 23 | updates.forEach(({ id, month, value }) => { 24 | // Create month if not exists 25 | if (!byMonth[month]) { 26 | // Copy existing month or create new 27 | if (curentBudgets[month]) byMonth[month] = { ...curentBudgets[month] } 28 | else byMonth[month] = {} 29 | } 30 | if (value) byMonth[month][id] = value 31 | else delete byMonth[month][id] 32 | }) 33 | 34 | keys(byMonth).forEach(month => { 35 | dispatch(budgetStore.setData(byMonth[month], month)) 36 | }) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/5-entities/budget/getBudgets.ts: -------------------------------------------------------------------------------- 1 | import { createSelector } from '@reduxjs/toolkit' 2 | import { toISOMonth } from '6-shared/helpers/date' 3 | import { keys } from '6-shared/helpers/keys' 4 | import { withPerf } from '6-shared/helpers/performance' 5 | import { ByMonth } from '6-shared/types' 6 | import { TSelector } from 'store/index' 7 | import { envelopeModel, EnvType } from '5-entities/envelope' 8 | import { userSettingsModel } from '5-entities/userSettings' 9 | import { getTagBudgets } from './tagBudget' 10 | import { getEnvBudgets, TBudgets } from './envBudget' 11 | 12 | export const getBudgets: TSelector> = createSelector( 13 | [getTagBudgets, getEnvBudgets, userSettingsModel.get], 14 | withPerf('getEnvelopeBudgets', (tagBudgets, hiddenBudgets, userSettings) => { 15 | const { preferZmBudgets } = userSettings 16 | const result: ByMonth = {} 17 | 18 | if (preferZmBudgets) { 19 | keys(tagBudgets).forEach(budgetId => { 20 | const budget = tagBudgets[budgetId] 21 | if (!budget.outcome) return 22 | const month = toISOMonth(budget.date) 23 | const envelopeId = envelopeModel.makeId(EnvType.Tag, String(budget.tag)) 24 | result[month] ??= {} 25 | result[month][envelopeId] = budget.outcome 26 | }) 27 | } 28 | 29 | keys(hiddenBudgets).forEach(month => { 30 | keys(hiddenBudgets[month]).forEach(envelopeId => { 31 | if (preferZmBudgets) { 32 | // Skip Zerro tag budgets if user prefers Zenmoney budgets 33 | const { type } = envelopeModel.parseId(envelopeId) 34 | if (type === EnvType.Tag) return 35 | } 36 | if (!hiddenBudgets[month][envelopeId]) return 37 | result[month] ??= {} 38 | result[month][envelopeId] = hiddenBudgets[month][envelopeId] 39 | }) 40 | }) 41 | 42 | return result 43 | }) 44 | ) 45 | -------------------------------------------------------------------------------- /src/5-entities/budget/index.ts: -------------------------------------------------------------------------------- 1 | import { getBudgets } from './getBudgets' 2 | import { setBudget } from './setBudget' 3 | 4 | export type { TBudgetUpdate } from './setBudget' 5 | export type { TTagBudgetUpdate } from './tagBudget' 6 | export type { TEnvBudgetUpdate } from './envBudget' 7 | 8 | export { getTagBudgets, setTagBudget } from './tagBudget' 9 | export { getEnvBudgets, setEnvBudget } from './envBudget' 10 | 11 | export const budgetModel = { 12 | get: getBudgets, 13 | set: setBudget, 14 | } 15 | -------------------------------------------------------------------------------- /src/5-entities/budget/setBudget.ts: -------------------------------------------------------------------------------- 1 | import { envelopeModel, EnvType } from '5-entities/envelope' 2 | import { userSettingsModel } from '5-entities/userSettings' 3 | import { AppThunk } from 'store/index' 4 | import { setEnvBudget, TEnvBudgetUpdate } from './envBudget' 5 | import { setTagBudget, TTagBudgetUpdate } from './tagBudget' 6 | 7 | export type TBudgetUpdate = TEnvBudgetUpdate 8 | 9 | export function setBudget(upd: TBudgetUpdate | TBudgetUpdate[]): AppThunk { 10 | return (dispatch, getState) => { 11 | const { preferZmBudgets } = userSettingsModel.get(getState()) 12 | const updates = Array.isArray(upd) ? upd : [upd] 13 | 14 | let tagUpdates: TTagBudgetUpdate[] = [] 15 | let envUpdates: TEnvBudgetUpdate[] = [] 16 | 17 | updates.forEach(update => { 18 | const { type, id } = envelopeModel.parseId(update.id) 19 | if (type === EnvType.Tag && preferZmBudgets) { 20 | tagUpdates.push({ 21 | tag: id === 'null' ? null : id, 22 | month: update.month, 23 | value: update.value, 24 | }) 25 | } else { 26 | envUpdates.push(update) 27 | } 28 | }) 29 | 30 | dispatch(setTagBudget(tagUpdates)) 31 | dispatch(setEnvBudget(envUpdates)) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/5-entities/budget/tagBudget/getBudgetId.ts: -------------------------------------------------------------------------------- 1 | import { TTagId } from '6-shared/types' 2 | import { TBudgetId } from '6-shared/types' 3 | import { toISODate } from '6-shared/helpers/date' 4 | import { TDateDraft } from '6-shared/types' 5 | import { toBudgetId } from '6-shared/api/zm-adapter' 6 | 7 | export function getTagBudgetId( 8 | date: TDateDraft, 9 | tag: TTagId | null 10 | ): TBudgetId { 11 | return toBudgetId(toISODate(date), tag) 12 | } 13 | -------------------------------------------------------------------------------- /src/5-entities/budget/tagBudget/index.ts: -------------------------------------------------------------------------------- 1 | export { getTagBudgets } from './selectors' 2 | export { setTagBudget } from './setTagBudget' 3 | export type { TTagBudgetUpdate } from './setTagBudget' 4 | -------------------------------------------------------------------------------- /src/5-entities/budget/tagBudget/makeTagBudget.ts: -------------------------------------------------------------------------------- 1 | import { parseDate, toISODate } from '6-shared/helpers/date' 2 | import { TBudget } from '6-shared/types' 3 | import { OptionalExceptFor, Modify, TDateDraft } from '6-shared/types' 4 | import { getTagBudgetId } from './getBudgetId' 5 | 6 | export type TTagBudgetDraft = Modify< 7 | OptionalExceptFor, 8 | { date: TDateDraft } 9 | > 10 | 11 | export const makeTagBudget = (draft: TTagBudgetDraft): TBudget => ({ 12 | id: getTagBudgetId(draft.date, draft.tag), 13 | user: draft.user, 14 | date: toISODate(parseDate(draft.date)), 15 | tag: draft.tag || null, 16 | changed: draft.changed || Date.now(), 17 | income: draft.income || 0, 18 | incomeLock: draft.incomeLock || true, 19 | outcome: draft.outcome || 0, 20 | outcomeLock: draft.outcomeLock || true, 21 | }) 22 | -------------------------------------------------------------------------------- /src/5-entities/budget/tagBudget/selectors.ts: -------------------------------------------------------------------------------- 1 | import { RootState } from 'store' 2 | 3 | export const getTagBudgets = (state: RootState) => state.data.current.budget 4 | -------------------------------------------------------------------------------- /src/5-entities/budget/tagBudget/setTagBudget.ts: -------------------------------------------------------------------------------- 1 | import { userModel } from '5-entities/user' 2 | import { TISOMonth, TTagId } from '6-shared/types' 3 | import { applyClientPatch } from 'store/data' 4 | import { AppThunk } from 'store/index' 5 | import { getTagBudgetId } from './getBudgetId' 6 | import { makeTagBudget } from './makeTagBudget' 7 | import { getTagBudgets } from './selectors' 8 | 9 | export type TTagBudgetUpdate = { 10 | tag: TTagId | null 11 | month: TISOMonth 12 | value: number 13 | } 14 | 15 | export function setTagBudget( 16 | upd: TTagBudgetUpdate | TTagBudgetUpdate[] 17 | ): AppThunk { 18 | return (dispatch, getState) => { 19 | const updates = Array.isArray(upd) ? upd : [upd] 20 | if (!upd || !updates.length) return null 21 | 22 | const state = getState() 23 | const userId = userModel.getRootUserId(state) 24 | const tagBudgets = getTagBudgets(state) 25 | 26 | const budgets = updates.map(({ tag, month, value }) => { 27 | const id = getTagBudgetId(month, tag) 28 | const current = tagBudgets[id] || {} 29 | return makeTagBudget({ 30 | ...current, 31 | user: current.user || userId, 32 | tag: tag, 33 | date: month, 34 | outcome: value, 35 | changed: Date.now(), 36 | }) 37 | }) 38 | dispatch(applyClientPatch({ budget: budgets })) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/5-entities/currency/displayCurrency/DisplayAmount.tsx: -------------------------------------------------------------------------------- 1 | import { Modify, TFxAmount, TISOMonth } from '6-shared/types' 2 | import { Amount, AmountProps } from '6-shared/ui/Amount' 3 | import { displayCurrency } from './model' 4 | 5 | type TDisplayAmountProps = Modify< 6 | AmountProps, 7 | { 8 | value: TFxAmount | number 9 | month?: TISOMonth 10 | noCurrency?: boolean 11 | } 12 | > 13 | 14 | export const DisplayAmount = (props: TDisplayAmountProps) => { 15 | const { value, month, noCurrency, ...delegated } = props 16 | const [currency] = displayCurrency.useDisplayCurrency() 17 | const convert = displayCurrency.useToDisplay(month || 'current') 18 | const amount = typeof value === 'number' ? value : convert(value) 19 | return ( 20 | 25 | ) 26 | } 27 | -------------------------------------------------------------------------------- /src/5-entities/currency/displayCurrency/index.ts: -------------------------------------------------------------------------------- 1 | export { DisplayAmount } from './DisplayAmount' 2 | export { displayCurrency } from './model' 3 | -------------------------------------------------------------------------------- /src/5-entities/currency/fxRate/converter.ts: -------------------------------------------------------------------------------- 1 | import { createSelector } from '@reduxjs/toolkit' 2 | import { TDateDraft, TFxAmount, TFxCode } from '6-shared/types' 3 | import { convertFx } from '6-shared/helpers/money' 4 | 5 | import { TSelector } from 'store' 6 | import { getFxRatesGetter } from './getFxRatesGetter' 7 | 8 | export type TFxConverter = ( 9 | amount: TFxAmount, 10 | target: TFxCode, 11 | date: TDateDraft | 'current' 12 | ) => number 13 | 14 | export const getConverter: TSelector< 15 | (amount: TFxAmount, target: TFxCode, date: TDateDraft | 'current') => number 16 | > = createSelector([getFxRatesGetter], getter => { 17 | return (amount, target, date) => convertFx(amount, target, getter(date).rates) 18 | }) 19 | -------------------------------------------------------------------------------- /src/5-entities/currency/fxRate/fxRateStore.ts: -------------------------------------------------------------------------------- 1 | import { 2 | HiddenDataType, 3 | makeMonthlyHiddenStore, 4 | } from '5-entities/shared/hidden-store' 5 | import { TFxCode, TISOMonth, TMsTime } from '6-shared/types' 6 | 7 | export type TFxRates = Record 8 | 9 | export type TFxRatesStoredValue = { 10 | date: TISOMonth 11 | changed: TMsTime 12 | rates: TFxRates 13 | } 14 | 15 | export const fxRateStore = makeMonthlyHiddenStore( 16 | HiddenDataType.FxRates 17 | ) 18 | -------------------------------------------------------------------------------- /src/5-entities/currency/fxRate/getFxRatesGetter.ts: -------------------------------------------------------------------------------- 1 | import type { TDateDraft } from '6-shared/types' 2 | import type { TSelector } from 'store' 3 | 4 | import { createSelector } from '@reduxjs/toolkit' 5 | import { keys } from '6-shared/helpers/keys' 6 | import { toISOMonth } from '6-shared/helpers/date' 7 | 8 | import { TFxRateData, getRates, getCurrentRates } from './getFxRates' 9 | 10 | export const getFxRatesGetter: TSelector< 11 | (date: TDateDraft | 'current') => TFxRateData 12 | > = createSelector([getRates, getCurrentRates], (rates, latestRates) => { 13 | const monthsWithRates = keys(rates).sort() 14 | const firstDate = monthsWithRates[0] 15 | const lastDate = monthsWithRates[monthsWithRates.length - 1] 16 | return (date: TDateDraft | 'current'): TFxRateData => { 17 | if (date === 'current') return latestRates 18 | 19 | // Convert date to ISO month 20 | const month = toISOMonth(date) 21 | 22 | // Return exact match if available 23 | if (rates[month]) return rates[month] 24 | 25 | // Use first or last date if the date is outside the available range 26 | if (month <= firstDate) return rates[firstDate] 27 | if (month >= lastDate) return rates[lastDate] 28 | 29 | // Othrwise get rates from the closest past month 30 | const prevDates = monthsWithRates.filter(d => d <= month) 31 | const monthToUse = prevDates[prevDates.length - 1] 32 | const result = rates[monthToUse] 33 | 34 | if (!result) { 35 | // Error happened, return latest rates as a fallback 36 | console.error(`No rates found for ${month}`) 37 | return latestRates 38 | } 39 | 40 | return result 41 | } 42 | }) 43 | -------------------------------------------------------------------------------- /src/5-entities/currency/fxRate/index.ts: -------------------------------------------------------------------------------- 1 | import { useAppSelector } from 'store' 2 | import { getConverter } from './converter' 3 | import { getCurrentRates, getRates } from './getFxRates' 4 | import { getFxRatesGetter } from './getFxRatesGetter' 5 | import { 6 | canFetchRates, 7 | editRates, 8 | freezeCurrentRates, 9 | loadRates, 10 | resetRates, 11 | } from './patchRates' 12 | 13 | export type { TFxRateData } from './getFxRates' 14 | export type { TFxRates } from './fxRateStore' 15 | export type { TFxConverter } from './converter' 16 | 17 | export const fxRateModel = { 18 | // Selectors 19 | get: getRates, 20 | latest: getCurrentRates, 21 | getter: getFxRatesGetter, 22 | converter: getConverter, 23 | 24 | // Hooks 25 | useConverter: () => useAppSelector(getConverter), 26 | useRatesGetter: () => useAppSelector(getFxRatesGetter), 27 | 28 | // Thunks 29 | edit: editRates, 30 | reset: resetRates, 31 | freezeCurrent: freezeCurrentRates, 32 | load: loadRates, 33 | 34 | // Utils 35 | canFetchRates, 36 | } 37 | -------------------------------------------------------------------------------- /src/5-entities/currency/instrument/index.ts: -------------------------------------------------------------------------------- 1 | import { useAppSelector } from 'store' 2 | import { getInstruments, getInstrumentsByCode, getInstCodeMap } from './model' 3 | 4 | export type { TInstCodeMap } from './model' 5 | 6 | export const instrumentModel = { 7 | // Selectors 8 | getInstruments, 9 | getInstrumentsByCode, 10 | getInstCodeMap, 11 | 12 | // Hooks 13 | useInstruments: () => useAppSelector(getInstruments), 14 | useInstrumentsByCode: () => useAppSelector(getInstrumentsByCode), 15 | useInstCodeMap: () => useAppSelector(getInstCodeMap), 16 | } 17 | -------------------------------------------------------------------------------- /src/5-entities/currency/instrument/model.ts: -------------------------------------------------------------------------------- 1 | import { createSelector } from '@reduxjs/toolkit' 2 | import { TInstrument, TFxCode, TInstrumentId } from '6-shared/types' 3 | import { RootState, TSelector } from 'store' 4 | 5 | export const getInstruments = (state: RootState) => 6 | state.data.current.instrument 7 | 8 | export const getInstrumentsByCode: TSelector> = 9 | createSelector([getInstruments], instruments => { 10 | const result: Record = {} 11 | Object.values(instruments).forEach(instrument => { 12 | result[instrument.shortTitle] = instrument 13 | }) 14 | return result 15 | }) 16 | 17 | export type TInstCodeMap = Record 18 | 19 | export const getInstCodeMap: TSelector> = 20 | createSelector([getInstruments], instruments => { 21 | const fxIdMap: TInstCodeMap = {} 22 | Object.values(instruments).forEach(curr => { 23 | fxIdMap[curr.id] = curr.shortTitle 24 | }) 25 | return fxIdMap 26 | }) 27 | -------------------------------------------------------------------------------- /src/5-entities/debtors/debtorGetter.ts: -------------------------------------------------------------------------------- 1 | import { getMerchants } from '5-entities/merchant' 2 | import { cleanPayee } from '../shared/cleanPayee' 3 | import { createSelector } from '@reduxjs/toolkit' 4 | import { TTransaction } from '6-shared/types' 5 | import { TSelector } from 'store/index' 6 | 7 | export const debtorGetter: TSelector<(tr: TTransaction) => string> = 8 | createSelector([getMerchants], merchants => tr => { 9 | const merchantTitle = tr.merchant && merchants[tr.merchant]?.title 10 | return cleanPayee(merchantTitle || tr.payee || '') 11 | }) 12 | -------------------------------------------------------------------------------- /src/5-entities/debtors/index.ts: -------------------------------------------------------------------------------- 1 | import { useAppSelector } from 'store/index' 2 | import { debtorGetter } from './debtorGetter' 3 | import { getDebtors } from './getDebtors' 4 | 5 | export type { TDebtor } from './getDebtors' 6 | 7 | export const debtorModel = { 8 | // Selectors 9 | getDebtors, 10 | detector: debtorGetter, 11 | 12 | // Hooks 13 | useDebtors: () => useAppSelector(getDebtors), 14 | } 15 | -------------------------------------------------------------------------------- /src/5-entities/envBalances/1 - currentFunds.ts: -------------------------------------------------------------------------------- 1 | import type { TSelector } from 'store/index' 2 | import type { TFxAmount } from '6-shared/types' 3 | 4 | import { createSelector } from '@reduxjs/toolkit' 5 | import { addFxAmount } from '6-shared/helpers/money' 6 | import { accountModel } from '5-entities/account' 7 | 8 | export const getCurrentFunds: TSelector = createSelector( 9 | [accountModel.getInBudgetAccounts], 10 | accounts => { 11 | const balances = accounts.map(a => ({ [a.fxCode]: a.balance }) as TFxAmount) 12 | return addFxAmount(...balances) 13 | } 14 | ) 15 | -------------------------------------------------------------------------------- /src/5-entities/envBalances/1 - monthList.ts: -------------------------------------------------------------------------------- 1 | import { createSelector } from '@reduxjs/toolkit' 2 | import { TISOMonth } from '6-shared/types' 3 | import { nextMonth, toISOMonth } from '6-shared/helpers/date' 4 | import { keys } from '6-shared/helpers/keys' 5 | 6 | import { TSelector } from 'store' 7 | import { trModel } from '5-entities/transaction' 8 | import { budgetModel } from '5-entities/budget' 9 | 10 | /** 11 | * Returns the date of first month as ISO. 12 | * To have correct result we should include all transactions 13 | * in our calculations. Otherwise calculated balance will be wrong. 14 | */ 15 | const getFirstMonth: TSelector = createSelector( 16 | [trModel.getTransactionsHistory], 17 | transactions => toISOMonth(transactions[0]?.date || Date.now()) 18 | ) 19 | 20 | /** Returns the last available month to budget. */ 21 | const getLastMonth: TSelector = createSelector( 22 | [budgetModel.get], 23 | budgets => { 24 | const currentMonth = toISOMonth(Date.now()) 25 | const lastBudgetMonth = keys(budgets).sort().pop() || currentMonth 26 | const lastMonth = 27 | lastBudgetMonth > currentMonth ? lastBudgetMonth : currentMonth 28 | // 1 month added to be able to budget in future 29 | return toISOMonth(nextMonth(lastMonth)) 30 | } 31 | ) 32 | 33 | export const getMonthList: TSelector = createSelector( 34 | [getFirstMonth, getLastMonth], 35 | (start, end) => { 36 | const result: TISOMonth[] = [] 37 | let current: TISOMonth = start 38 | do { 39 | result.push(current) 40 | current = toISOMonth(nextMonth(current)) 41 | } while (current <= end) 42 | return result 43 | } 44 | ) 45 | -------------------------------------------------------------------------------- /src/5-entities/envBalances/index.ts: -------------------------------------------------------------------------------- 1 | import { useAppSelector } from 'store/index' 2 | import { getMonthList } from './1 - monthList' 3 | import { getRawActivity } from './1 - rawActivity' 4 | import { getActivity } from './2 - activity' 5 | import { getSortedActivity } from './2 - sortedActivity' 6 | import { getEnvMetrics } from './3 - envMetrics' 7 | import { getMonthTotals } from './4 - monthTotals' 8 | 9 | export type { TRawActivityNode } from './1 - rawActivity' 10 | export type { TActivityNode } from './2 - activity' 11 | export type { TSortedActivityNode, TSortedActivity } from './2 - sortedActivity' 12 | export type { TEnvMetrics } from './3 - envMetrics' 13 | export type { TMonthTotals } from './4 - monthTotals' 14 | 15 | export { EnvActivity } from './1 - rawActivity' 16 | export { TrFilterMode } from './2 - sortedActivity' 17 | 18 | export const balances = { 19 | // Selectors 20 | monthList: getMonthList, 21 | rawActivity: getRawActivity, 22 | activity: getActivity, 23 | sortedActivity: getSortedActivity, 24 | envData: getEnvMetrics, 25 | totals: getMonthTotals, 26 | 27 | // Hooks 28 | useMonthList: () => useAppSelector(getMonthList), 29 | useRawActivity: () => useAppSelector(getRawActivity), 30 | useActivity: () => useAppSelector(getActivity), 31 | useSortedActivity: () => useAppSelector(getSortedActivity), 32 | useEnvData: () => useAppSelector(getEnvMetrics), 33 | useTotals: () => useAppSelector(getMonthTotals), 34 | } 35 | -------------------------------------------------------------------------------- /src/5-entities/envelope/applyStructure.ts: -------------------------------------------------------------------------------- 1 | import { AppThunk } from 'store' 2 | import { 3 | flattenStructure, 4 | normalizeStructure, 5 | TGroupNode, 6 | } from './shared/structure' 7 | import { getEnvelopes } from './getEnvelopes' 8 | import { patchEnvelope, TEnvelopeDraft } from './patchEnvelope' 9 | 10 | export const applyStructure = 11 | (newStructure: TGroupNode[]): AppThunk => 12 | (dispatch, getState) => { 13 | const envelopes = getEnvelopes(getState()) 14 | const patches = [] as TEnvelopeDraft[] 15 | flattenStructure(normalizeStructure(newStructure)).forEach( 16 | (node, index) => { 17 | if (node.type === 'group') return 18 | const env = envelopes[node.id] 19 | let patch: TEnvelopeDraft = { 20 | id: node.id, 21 | group: node.group, 22 | parent: node.parent, 23 | indexRaw: index, 24 | } 25 | const needPatch = 26 | env.group !== patch.group || 27 | env.parent !== patch.parent || 28 | env.indexRaw !== patch.indexRaw 29 | if (needPatch) patches.push(patch) 30 | } 31 | ) 32 | if (patches.length) dispatch(patchEnvelope(patches)) 33 | } 34 | -------------------------------------------------------------------------------- /src/5-entities/envelope/index.ts: -------------------------------------------------------------------------------- 1 | import { useAppSelector } from 'store/index' 2 | import { applyStructure } from './applyStructure' 3 | import { 4 | getEnvelopes, 5 | getEnvelopeStructure, 6 | getKeepingEnvelopes, 7 | } from './getEnvelopes' 8 | import { patchEnvelope } from './patchEnvelope' 9 | import { envId } from './shared/envelopeId' 10 | import { flattenStructure } from './shared/structure' 11 | 12 | export type { TEnvNode, TGroupNode } from './shared/structure' 13 | export type { TEnvelopeDraft } from './patchEnvelope' 14 | export type { TEnvelopeId } from './shared/envelopeId' 15 | export type { TEnvelope } from './shared/makeEnvelope' 16 | 17 | export { envelopeVisibility } from './shared/metaData' 18 | export { EnvType } from './shared/envelopeId' 19 | 20 | export const envelopeModel = { 21 | // Selectors 22 | getEnvelopes, 23 | getEnvelopeStructure, 24 | getKeepingEnvelopes, 25 | 26 | // Hooks 27 | useEnvelopes: () => useAppSelector(getEnvelopes), 28 | useEnvelopeStructure: () => useAppSelector(getEnvelopeStructure), 29 | 30 | // Thunk 31 | patchEnvelope, 32 | applyStructure, 33 | 34 | // Helpers 35 | parseId: envId.parse, 36 | makeId: envId.get, 37 | flattenStructure, 38 | } 39 | -------------------------------------------------------------------------------- /src/5-entities/envelope/shared/compareEnvelopes.ts: -------------------------------------------------------------------------------- 1 | import { envId, EnvType } from './envelopeId' 2 | import { TEnvelope } from './makeEnvelope' 3 | 4 | const nullTagId = envId.get(EnvType.Tag, null) 5 | 6 | export function compareEnvelopes(a: TEnvelope, b: TEnvelope) { 7 | // Sort by index if it's present 8 | if (a.indexRaw !== undefined && b.indexRaw !== undefined) 9 | return a.indexRaw - b.indexRaw 10 | if (a.indexRaw !== undefined) return -1 11 | if (b.indexRaw !== undefined) return 1 12 | 13 | // Sort by type 14 | if (a.type !== b.type) { 15 | const typeOrder = [ 16 | EnvType.Tag, 17 | EnvType.Account, 18 | EnvType.Merchant, 19 | EnvType.Payee, 20 | ] 21 | return typeOrder.indexOf(a.type) - typeOrder.indexOf(b.type) 22 | } 23 | 24 | // Null category should be the first one 25 | if (a.id === nullTagId) return -1 26 | if (b.id === nullTagId) return 1 27 | 28 | // Finally sort by name 29 | return a.name.localeCompare(b.name) 30 | } 31 | -------------------------------------------------------------------------------- /src/5-entities/envelope/shared/envelopeId.ts: -------------------------------------------------------------------------------- 1 | import { TAccountId, TMerchantId, TTagId } from '6-shared/types' 2 | 3 | export enum EnvType { 4 | Tag = 'tag', 5 | Account = 'account', 6 | Merchant = 'merchant', 7 | Payee = 'payee', 8 | } 9 | 10 | export type TEnvelopeId = 11 | | `${EnvType.Tag}#${TTagId}` 12 | | `${EnvType.Account}#${TAccountId}` 13 | | `${EnvType.Merchant}#${TMerchantId}` 14 | | `${EnvType.Payee}#${string}` 15 | 16 | /** Encode and decode envelope ids */ 17 | export const envId = { 18 | /** Combines entity type and its id into envelope id */ 19 | get: (type: EnvType, id: string | null) => { 20 | return `${type}#${id}` as TEnvelopeId 21 | }, 22 | /** Splits envelope id into its type and entity id */ 23 | parse: (id: TEnvelopeId) => { 24 | return { 25 | type: id.split('#')[0] as EnvType, 26 | id: id.split('#')[1], 27 | } 28 | }, 29 | } 30 | -------------------------------------------------------------------------------- /src/5-entities/envelope/shared/metaData.ts: -------------------------------------------------------------------------------- 1 | import { 2 | HiddenDataType, 3 | makeSimpleHiddenStore, 4 | } from '5-entities/shared/hidden-store' 5 | import { ById, OptionalExceptFor, TFxCode } from '6-shared/types' 6 | import { AppThunk } from 'store' 7 | import { TEnvelopeId } from './envelopeId' 8 | 9 | export enum envelopeVisibility { 10 | hidden = 'hidden', 11 | visible = 'visible', 12 | auto = 'auto', 13 | } 14 | 15 | export type TEnvelopeMeta = { 16 | id: TEnvelopeId 17 | group?: string 18 | index?: number 19 | visibility?: envelopeVisibility 20 | parent?: TEnvelopeId 21 | comment?: string 22 | currency?: TFxCode 23 | keepIncome?: boolean 24 | carryNegatives?: boolean 25 | } 26 | 27 | const envelopeMetaStore = makeSimpleHiddenStore>( 28 | HiddenDataType.EnvelopeMeta, 29 | {} 30 | ) 31 | 32 | export const getEnvelopeMeta = envelopeMetaStore.getData 33 | 34 | export type TEnvelopeMetaPatch = OptionalExceptFor 35 | 36 | export const patchEnvelopeMeta = 37 | (updates: TEnvelopeMetaPatch | TEnvelopeMetaPatch[]): AppThunk => 38 | (dispatch, getState) => { 39 | const currentData = getEnvelopeMeta(getState()) 40 | const updateList = Array.isArray(updates) ? updates : [updates] 41 | 42 | const newData = { ...currentData } 43 | updateList.forEach(update => { 44 | newData[update.id] = { ...currentData[update.id], ...update } 45 | }) 46 | 47 | dispatch(envelopeMetaStore.setData(newData)) 48 | } 49 | -------------------------------------------------------------------------------- /src/5-entities/goal/goalStore.ts: -------------------------------------------------------------------------------- 1 | import { TEnvelopeId } from '5-entities/envelope' 2 | import { 3 | HiddenDataType, 4 | makeMonthlyHiddenStore, 5 | } from '5-entities/shared/hidden-store' 6 | import { TGoal } from './shared/types' 7 | 8 | export type TGoals = Record 9 | 10 | export const goalStore = makeMonthlyHiddenStore(HiddenDataType.Goals) 11 | 12 | export const getRawGoals = goalStore.getData 13 | -------------------------------------------------------------------------------- /src/5-entities/goal/index.ts: -------------------------------------------------------------------------------- 1 | import { useAppSelector } from 'store/index' 2 | import { getGoals } from './getGoals' 3 | import { getTotals } from './getTotals' 4 | import { getRawGoals } from './goalStore' 5 | import { goalToWords } from './shared/helpers' 6 | import { setGoal } from './setGoal' 7 | 8 | export type { TGoal } from './shared/types' 9 | export type { TGoals } from './goalStore' 10 | export type { TGoalInfo } from './getGoals' 11 | 12 | export { goalType } from './shared/types' 13 | 14 | export const goalModel = { 15 | // Selectors 16 | get: getGoals, 17 | getTotals: getTotals, 18 | getRaw: getRawGoals, 19 | 20 | // Hooks 21 | useGoals: () => useAppSelector(getGoals), 22 | useTotals: () => useAppSelector(getTotals), 23 | 24 | // Thunks 25 | set: setGoal, 26 | 27 | // Helpers 28 | toWords: goalToWords, 29 | } 30 | -------------------------------------------------------------------------------- /src/5-entities/goal/setGoal.ts: -------------------------------------------------------------------------------- 1 | import { sendEvent } from '6-shared/helpers/tracking' 2 | import { TISOMonth } from '6-shared/types' 3 | import { keys } from '6-shared/helpers/keys' 4 | import { AppThunk } from 'store' 5 | import { TEnvelopeId } from '5-entities/envelope' 6 | import { goalStore } from './goalStore' 7 | import { makeGoal } from './shared/helpers' 8 | import { TGoal } from './shared/types' 9 | 10 | export const setGoal = 11 | (month: TISOMonth, id: TEnvelopeId, goal?: TGoal | null): AppThunk => 12 | (dispatch, getState) => { 13 | const state = getState() 14 | const goals = goalStore.getData(state) 15 | const newGoal = makeGoal(goal) 16 | 17 | // Update goals in given month 18 | const currentGoals = goals[month] || {} 19 | const newGoals = { ...currentGoals, [id]: newGoal } 20 | dispatch(goalStore.setData(newGoals, month)) 21 | 22 | // null-goals are blocking goals from continuing in future 23 | // if we are settin a new goal we should delete all future blockers 24 | if (newGoal) removeFutureGoalBlocks() 25 | 26 | // Track these events 27 | if (newGoal !== null) sendEvent(`Goals: set ${newGoal.type} goal`) 28 | else sendEvent(`Goals: delete goal`) 29 | 30 | function removeFutureGoalBlocks() { 31 | const futureMonths = keys(goals) 32 | .sort((a, b) => a.localeCompare(b)) 33 | .filter(date => date > month) 34 | 35 | for (const date of futureMonths) { 36 | // Remove blocker-goal 37 | if (goals[date][id] === null) { 38 | const newGoals = { ...goals[date] } 39 | delete newGoals[id] 40 | dispatch(goalStore.setData(newGoals, date)) 41 | return 42 | } 43 | 44 | // If there is a set goal in future we should stop deleting null-goals 45 | if (goals[date][id]) break 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/5-entities/goal/shared/types.ts: -------------------------------------------------------------------------------- 1 | import { TISODate } from '6-shared/types' 2 | 3 | export enum goalType { 4 | /** 5 | * Monthly contribution 6 | * Every month I suppose to put certain amount of money into envelope 7 | */ 8 | MONTHLY = 'monthly', 9 | 10 | /** 11 | * Monthly spend 12 | * I need certain amount of money for every month 13 | */ 14 | MONTHLY_SPEND = 'monthlySpend', 15 | 16 | /** 17 | * Target envelope balance 18 | * 2 variants: 19 | * - with `end` date: I need to have certain amount of money in envelope by certain date 20 | * - without `end` date: I need to have certain amount of money in envelope 21 | */ 22 | TARGET_BALANCE = 'targetBalance', 23 | 24 | /** 25 | * Income percent 26 | * I want to put certain percent of my income into envelope 27 | */ 28 | INCOME_PERCENT = 'incomePercent', 29 | } 30 | 31 | export type TGoal = { 32 | type: goalType 33 | amount: number 34 | end?: TISODate 35 | } 36 | -------------------------------------------------------------------------------- /src/5-entities/merchant/index.ts: -------------------------------------------------------------------------------- 1 | export type { TMerchantDraft } from './patchMerchant' 2 | export { patchMerchant } from './patchMerchant' 3 | export { getMerchants } from './model' 4 | -------------------------------------------------------------------------------- /src/5-entities/merchant/model.ts: -------------------------------------------------------------------------------- 1 | import { RootState } from 'store' 2 | 3 | export const getMerchants = (state: RootState) => state.data.current.merchant 4 | -------------------------------------------------------------------------------- /src/5-entities/merchant/patchMerchant.ts: -------------------------------------------------------------------------------- 1 | import { sendEvent } from '6-shared/helpers/tracking' 2 | import { OptionalExceptFor, TMerchant } from '6-shared/types' 3 | import { AppThunk } from 'store' 4 | import { applyClientPatch } from 'store/data' 5 | import { getMerchants } from './model' 6 | 7 | export type TMerchantDraft = OptionalExceptFor 8 | 9 | export const patchMerchant = 10 | (draft: TMerchantDraft | TMerchantDraft[]): AppThunk => 11 | (dispatch, getState) => { 12 | const patched: TMerchant[] = [] 13 | let list = Array.isArray(draft) ? draft : [draft] 14 | 15 | list.forEach(draft => { 16 | if (!draft.id) throw new Error('Trying to patch tag without id') 17 | let current = getMerchants(getState())[draft.id] 18 | if (!current) throw new Error('Merchant not found') 19 | patched.push({ ...current, ...draft, changed: Date.now() }) 20 | }) 21 | 22 | sendEvent('Merchant: edit') 23 | dispatch(applyClientPatch({ merchant: patched })) 24 | return patched 25 | } 26 | -------------------------------------------------------------------------------- /src/5-entities/old-hiddenData/constants.ts: -------------------------------------------------------------------------------- 1 | // Account with data reminders 2 | export const DATA_ACC_NAME = '🤖 [Zerro Data]' 3 | 4 | // Reminder payee names 5 | export enum DataReminderType { 6 | GOALS = 'goals', 7 | ACC_LINKS = 'accLinks', 8 | TAG_ORDER = 'tagOrder', 9 | TAG_META = 'tagMeta', 10 | } 11 | 12 | export enum oldGoalType { 13 | MONTHLY = 'monthly', // monthly contribution 14 | MONTHLY_SPEND = 'monthlySpend', // monthly spend 15 | TARGET_BALANCE = 'targetBalance', // 16 | } 17 | -------------------------------------------------------------------------------- /src/5-entities/old-hiddenData/goals/helpers.ts: -------------------------------------------------------------------------------- 1 | import { oldGoalType } from '../constants' 2 | import { TDateDraft, TOldGoal } from '6-shared/types' 3 | import { formatMoney } from '6-shared/helpers/money' 4 | import { parseDate } from '6-shared/helpers/date' 5 | 6 | const { MONTHLY, MONTHLY_SPEND, TARGET_BALANCE } = oldGoalType 7 | 8 | const formatMonth = (monthDate: TDateDraft): string => { 9 | if (!monthDate) return '' 10 | const date = parseDate(monthDate) 11 | const MM = date.getMonth() 12 | const YYYY = date.getFullYear() 13 | const isSameYear = new Date().getFullYear() === YYYY 14 | const months = [ 15 | 'январю', 16 | 'февралю', 17 | 'марту', 18 | 'апрелю', 19 | 'маю', 20 | 'июню', 21 | 'июлю', 22 | 'августу', 23 | 'сентябрю', 24 | 'октябрю', 25 | 'ноябрю', 26 | 'декабрю', 27 | ] 28 | 29 | return `${months[MM]} ${isSameYear ? 'этого года' : YYYY}` 30 | } 31 | 32 | export const goalToWords = ({ type, amount, end }: TOldGoal): string => { 33 | const formattedSum = formatMoney(amount, null, 0) 34 | switch (type) { 35 | case MONTHLY: 36 | return `Откладываю ${formattedSum} каждый месяц` 37 | case MONTHLY_SPEND: 38 | return `Нужно ${formattedSum} на месяц` 39 | case TARGET_BALANCE: 40 | if (end) return `Хочу накопить ${formattedSum} к ${formatMonth(end)}` 41 | else return `Хочу накопить ${formattedSum}` 42 | default: 43 | throw new Error(`Unsupported type ${type}`) 44 | } 45 | } 46 | 47 | export const makeGoal = ({ type, amount, end }: TOldGoal): TOldGoal => { 48 | return { type, amount, end } 49 | } 50 | -------------------------------------------------------------------------------- /src/5-entities/old-hiddenData/helpers.ts: -------------------------------------------------------------------------------- 1 | import { accountModel } from '5-entities/account' 2 | import { makeReminder } from '5-entities/reminder' 3 | import { TAccount } from '6-shared/types' 4 | import { DataReminderType, DATA_ACC_NAME } from './constants' 5 | 6 | // DATA ACCOUNT 7 | export function makeDataAcc(user: number): TAccount { 8 | return accountModel.makeAccount({ 9 | user, 10 | instrument: 2, 11 | title: DATA_ACC_NAME, 12 | archive: true, 13 | }) 14 | } 15 | 16 | // DATA REMINDER 17 | export function makeDataReminder( 18 | user: number, 19 | account: string, 20 | type: DataReminderType, 21 | data = '' 22 | ) { 23 | return makeReminder({ 24 | user, 25 | incomeAccount: account, 26 | outcomeAccount: account, 27 | income: 1, 28 | startDate: '2020-01-01', 29 | endDate: '2020-01-01', 30 | payee: type, 31 | comment: JSON.stringify(data), 32 | }) 33 | } 34 | -------------------------------------------------------------------------------- /src/5-entities/old-hiddenData/thunks.ts: -------------------------------------------------------------------------------- 1 | import { makeDataAcc, makeDataReminder } from './helpers' 2 | import { getDataReminders, getDataAccountId } from './selectors' 3 | import { DataReminderType } from './constants' 4 | import { AppThunk } from 'store' 5 | import { applyClientPatch } from 'store/data' 6 | import { userModel } from '5-entities/user' 7 | 8 | const prepareData: AppThunk = (dispatch, getState) => { 9 | let state = getState() 10 | const user = userModel.getRootUserId(state) 11 | if (!user) return 12 | // If no data account create one 13 | let dataAccId = getDataAccountId(state) 14 | if (!dataAccId) { 15 | const acc = makeDataAcc(user) 16 | dispatch(applyClientPatch({ account: [acc] })) 17 | dataAccId = acc.id 18 | } 19 | } 20 | 21 | export const setHiddenData = 22 | (type: DataReminderType, data: any): AppThunk => 23 | (dispatch, getState) => { 24 | dispatch(prepareData) 25 | const state = getState() 26 | const user = userModel.getRootUserId(state) 27 | if (!user) return 28 | const dataAcc = getDataAccountId(state) as string 29 | const reminder = 30 | getDataReminders(state)[type] || makeDataReminder(user, dataAcc, type) 31 | dispatch( 32 | applyClientPatch({ 33 | reminder: [ 34 | { 35 | ...reminder, 36 | comment: JSON.stringify(data), 37 | changed: Date.now(), 38 | }, 39 | ], 40 | }) 41 | ) 42 | } 43 | -------------------------------------------------------------------------------- /src/5-entities/reminder/index.ts: -------------------------------------------------------------------------------- 1 | export * from './model' 2 | 3 | export { makeReminder } from './makeReminder' 4 | export { setReminder, deleteReminder } from './setReminder' 5 | -------------------------------------------------------------------------------- /src/5-entities/reminder/makeReminder.ts: -------------------------------------------------------------------------------- 1 | import { v1 as uuidv1 } from 'uuid' 2 | import { TReminder } from '6-shared/types' 3 | import { toISODate } from '6-shared/helpers/date' 4 | import { Modify, OptionalExceptFor, TDateDraft } from '6-shared/types' 5 | 6 | type ReminderDraft = Modify< 7 | OptionalExceptFor, 8 | { 9 | startDate?: TDateDraft 10 | endDate?: TDateDraft 11 | } 12 | > 13 | 14 | export function makeReminder(draft: ReminderDraft): TReminder { 15 | return { 16 | // Required 17 | user: draft.user, 18 | incomeAccount: draft.incomeAccount, 19 | outcomeAccount: draft.outcomeAccount, 20 | 21 | // Optional 22 | id: draft.id || uuidv1(), 23 | changed: draft.changed || Date.now(), 24 | 25 | incomeInstrument: draft.incomeInstrument || 2, 26 | income: draft.income || 0, 27 | outcomeInstrument: draft.outcomeInstrument || 2, 28 | outcome: draft.outcome || 0, 29 | 30 | tag: draft.tag || null, 31 | merchant: draft.merchant || null, 32 | payee: draft.payee || null, 33 | comment: draft.comment || null, 34 | 35 | interval: draft.interval || null, 36 | step: draft.step || 0, 37 | points: draft.points || [0], 38 | startDate: toISODate(draft.startDate || Date.now()), 39 | endDate: toISODate(draft.endDate || Date.now()), 40 | notify: draft.notify || false, 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/5-entities/reminder/model.ts: -------------------------------------------------------------------------------- 1 | import { RootState } from 'store' 2 | 3 | // SELECTORS 4 | export const getReminders = (state: RootState) => state.data.current.reminder 5 | -------------------------------------------------------------------------------- /src/5-entities/shared/cleanPayee.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Cleans name from whitespaces and punctuation marks. 3 | * Leaves only digits and latin and cyrillic letters. 4 | */ 5 | export function cleanPayee(name: string) { 6 | return name.replace(/[^\d\wа-яА-ЯёЁ]/g, '').toLowerCase() 7 | } 8 | -------------------------------------------------------------------------------- /src/5-entities/shared/hidden-store/dataAccount.ts: -------------------------------------------------------------------------------- 1 | import { createSelector } from '@reduxjs/toolkit' 2 | import { AppThunk } from 'store' 3 | import { accountModel } from '5-entities/account' 4 | import { applyClientPatch } from 'store/data' 5 | import { userModel } from '5-entities/user' 6 | import { TAccountId } from '6-shared/types' 7 | 8 | export const DATA_ACC_NAME = '🤖 [Zerro Data]' 9 | 10 | /** 11 | * This is helper account which is used to store reminders with hidden data. 12 | * We need this one to be able easily delete all zerro reminders. 13 | * */ 14 | export const getDataAccountId = createSelector( 15 | [accountModel.getAccounts], 16 | accounts => { 17 | for (const id in accounts) { 18 | if (accounts[id].title === DATA_ACC_NAME) return id as TAccountId 19 | } 20 | } 21 | ) 22 | 23 | export function prepareDataAccount(): AppThunk { 24 | return (dispatch, getState) => { 25 | let state = getState() 26 | const user = userModel.getRootUser(state) 27 | if (!user) { 28 | throw new Error('No root user') 29 | } 30 | let dataAccId = getDataAccountId(state) 31 | if (dataAccId) return dataAccId 32 | 33 | // If no data account create one 34 | const acc = accountModel.makeAccount({ 35 | title: DATA_ACC_NAME, 36 | user: user.id, 37 | instrument: user.currency, 38 | }) 39 | dispatch(applyClientPatch({ account: [acc] })) 40 | return acc.id 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/5-entities/shared/hidden-store/helpers.ts: -------------------------------------------------------------------------------- 1 | export function parseComment(comment: string | null) { 2 | if (!comment) return null 3 | try { 4 | return JSON.parse(comment) 5 | } catch { 6 | return null 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/5-entities/shared/hidden-store/index.ts: -------------------------------------------------------------------------------- 1 | export { makeMonthlyHiddenStore } from './monthlyStoreFactory' 2 | export { makeSimpleHiddenStore } from './simpleStoreFactory' 3 | export { HiddenDataType } from './types' 4 | -------------------------------------------------------------------------------- /src/5-entities/shared/hidden-store/types.ts: -------------------------------------------------------------------------------- 1 | export enum HiddenDataType { 2 | Goals = 'goals', 3 | FxRates = 'fxRates', 4 | Budgets = 'budgets', 5 | LinkedAccounts = 'linkedAccounts', 6 | LinkedDebtors = 'linkedDebtors', 7 | EnvelopeMeta = 'EnvelopeMeta', 8 | UserSettings = 'UserSettings', 9 | TagOrder = 'tagOrder', 10 | } 11 | -------------------------------------------------------------------------------- /src/5-entities/tag/index.tsx: -------------------------------------------------------------------------------- 1 | export * from './model' 2 | -------------------------------------------------------------------------------- /src/5-entities/tag/model/index.ts: -------------------------------------------------------------------------------- 1 | import { useAppSelector } from 'store' 2 | import { getPopulatedTags, getTags, getTagsTree } from './model' 3 | import { makeTag } from './makeTag' 4 | import { createTag, patchTag } from './thunks' 5 | 6 | export type { TagTreeNode } from './model' 7 | export type { TTagPopulated } from './populateTags' 8 | export type { TTagDraft } from './thunks' 9 | 10 | export const tagModel = { 11 | // Selectors 12 | getTags, 13 | getPopulatedTags, 14 | getTagsTree, 15 | 16 | // Hooks 17 | useTags: () => useAppSelector(getTags), 18 | usePopulatedTags: () => useAppSelector(getPopulatedTags), 19 | useTagsTree: () => useAppSelector(getTagsTree), 20 | 21 | // Helpers 22 | makeTag, 23 | 24 | // Thunks 25 | patchTag, 26 | createTag, 27 | } 28 | -------------------------------------------------------------------------------- /src/5-entities/tag/model/makeTag.ts: -------------------------------------------------------------------------------- 1 | import { v1 as uuidv1 } from 'uuid' 2 | import { OptionalExceptFor, TTag } from '6-shared/types' 3 | import { t } from 'i18next' 4 | 5 | type TagDraft = OptionalExceptFor 6 | 7 | export function makeTag(raw: TagDraft): TTag { 8 | return { 9 | id: raw.id || uuidv1(), 10 | changed: raw.changed || Date.now(), 11 | user: raw.user, 12 | title: raw.title, 13 | icon: raw.icon || null, 14 | budgetIncome: raw.budgetIncome || false, 15 | budgetOutcome: raw.budgetOutcome || false, 16 | required: raw.required || false, 17 | color: raw.color || null, 18 | picture: raw.picture || null, 19 | staticId: raw.staticId || null, 20 | showIncome: raw.showIncome || false, 21 | showOutcome: raw.showOutcome || false, 22 | parent: raw.parent || null, 23 | } 24 | } 25 | 26 | export const nullTag = makeTag({ 27 | // TODO: ??? i18n 28 | title: t('common:tagNull'), 29 | user: 0, 30 | id: 'null', 31 | budgetIncome: true, 32 | budgetOutcome: true, 33 | }) 34 | -------------------------------------------------------------------------------- /src/5-entities/tag/model/model.ts: -------------------------------------------------------------------------------- 1 | import type { Modify } from '6-shared/types' 2 | import type { TTagPopulated } from './populateTags' 3 | import { createSelector } from '@reduxjs/toolkit' 4 | import { RootState } from 'store' 5 | import { populateTags } from './populateTags' 6 | 7 | // SELECTORS 8 | export const getTags = (state: RootState) => state.data.current.tag 9 | export const getPopulatedTags = createSelector([getTags], populateTags) 10 | 11 | // TODO below are deprecated methods 12 | 13 | export type TagTreeNode = Modify 14 | export const getTagsTree = createSelector([getPopulatedTags], tags => { 15 | let result = [] 16 | for (const id in tags) { 17 | if (tags[id].parent) continue 18 | const tag = { ...tags[id], children: [] } as TagTreeNode 19 | if (tags[id].children) 20 | tag.children = tags[id].children.map(id => tags[id]).sort(compareTags) 21 | result.push(tag) 22 | } 23 | result.sort(compareTags) 24 | return result 25 | }) 26 | 27 | function compareTags(tag1: T, tag2: T) { 28 | return tag1.name.localeCompare(tag2.name) 29 | } 30 | -------------------------------------------------------------------------------- /src/5-entities/tag/model/thunks.ts: -------------------------------------------------------------------------------- 1 | import type { OptionalExceptFor, TTag } from '6-shared/types' 2 | import type { AppThunk } from 'store' 3 | import { sendEvent } from '6-shared/helpers/tracking' 4 | import { applyClientPatch } from 'store/data' 5 | import { userModel } from '5-entities/user' 6 | import { makeTag } from './makeTag' 7 | import { getTags } from './model' 8 | 9 | export type TTagDraft = OptionalExceptFor 10 | 11 | export const patchTag = 12 | (draft: TTagDraft | TTagDraft[]): AppThunk => 13 | (dispatch, getState) => { 14 | const patched: TTag[] = [] 15 | let list = Array.isArray(draft) ? draft : [draft] 16 | 17 | list.forEach(draft => { 18 | if (!draft.id) throw new Error('Trying to patch tag without id') 19 | if (draft.id === 'null') throw new Error('Trying to patch null tag') 20 | let current = getTags(getState())[draft.id] 21 | if (!current) throw new Error('Tag not found') 22 | patched.push({ ...current, ...draft, changed: Date.now() }) 23 | }) 24 | 25 | sendEvent('Tag: edit') 26 | dispatch(applyClientPatch({ tag: patched })) 27 | return patched 28 | } 29 | 30 | export const createTag = 31 | (draft: OptionalExceptFor): AppThunk => 32 | (dispatch, getState) => { 33 | if (hasId(draft)) return dispatch(patchTag(draft)) 34 | if (!draft.title) throw new Error('Trying to create tag without title') 35 | let user = userModel.getRootUserId(getState()) 36 | if (!user) throw new Error('No user') 37 | const newTag = makeTag({ ...draft, user }) 38 | 39 | sendEvent('Tag: create') 40 | dispatch(applyClientPatch({ tag: [newTag] })) 41 | return [newTag] 42 | } 43 | 44 | const hasId = (tag: Partial): tag is TTagDraft => !!tag.id 45 | -------------------------------------------------------------------------------- /src/5-entities/tag/ui/TagChip.tsx: -------------------------------------------------------------------------------- 1 | import type { TTagId } from '6-shared/types' 2 | 3 | import React, { FC } from 'react' 4 | import { useTranslation } from 'react-i18next' 5 | import { Chip, ChipProps } from '@mui/material' 6 | import { CloseIcon } from '6-shared/ui/Icons' 7 | import { tagModel, TTagPopulated } from '../model' 8 | 9 | export const TagChip: FC = ({ id, ...rest }) => { 10 | const { t } = useTranslation() 11 | let tag = tagModel.usePopulatedTags()[id] 12 | let label = getTagLabel(tag) 13 | if (id === 'mixed') label = t('mixedCategories') 14 | return } label={label} {...rest} /> 15 | } 16 | 17 | function getTagLabel(tag?: TTagPopulated) { 18 | if (!tag) return null 19 | if (tag.icon) return `${tag.symbol} ${tag.name}` 20 | return tag.title 21 | } 22 | -------------------------------------------------------------------------------- /src/5-entities/transaction/helpers.ts: -------------------------------------------------------------------------------- 1 | import { TAccountId, TTransaction } from '6-shared/types' 2 | import { parseDate } from '6-shared/helpers/date' 3 | 4 | export function compareTrDates(tr1: TTransaction, tr2: TTransaction) { 5 | if (tr1.date < tr2.date) return 1 6 | if (tr1.date > tr2.date) return -1 7 | return tr2.created - tr1.created 8 | } 9 | 10 | export const isDeleted = (tr: TTransaction) => { 11 | if (tr.deleted) return true 12 | if (tr.income < 0.0001 && tr.outcome < 0.0001) return true 13 | return false 14 | } 15 | 16 | export enum TrType { 17 | Income = 'income', 18 | Outcome = 'outcome', 19 | Transfer = 'transfer', 20 | IncomeDebt = 'incomeDebt', 21 | OutcomeDebt = 'outcomeDebt', 22 | } 23 | 24 | export function getType(tr: TTransaction, debtId?: TAccountId): TrType { 25 | if (debtId && tr.incomeAccount === debtId) return TrType.OutcomeDebt 26 | if (debtId && tr.outcomeAccount === debtId) return TrType.IncomeDebt 27 | if (tr.income && tr.outcome) return TrType.Transfer 28 | if (tr.outcome) return TrType.Outcome 29 | return TrType.Income 30 | } 31 | 32 | export function getTime(tr: TTransaction) { 33 | const date = parseDate(tr.date) 34 | const creationDate = parseDate(tr.created) 35 | creationDate.setFullYear(date.getFullYear(), date.getMonth(), date.getDate()) 36 | return creationDate 37 | } 38 | 39 | export function getMainTag(tr: TTransaction) { 40 | if (tr.tag) return tr.tag[0] 41 | else return null 42 | } 43 | 44 | export function isViewed(tr: TTransaction) { 45 | if (tr.deleted) return true 46 | if (tr.viewed === true) return true 47 | if (tr.viewed === undefined) return true 48 | return false 49 | } 50 | -------------------------------------------------------------------------------- /src/5-entities/transaction/index.ts: -------------------------------------------------------------------------------- 1 | import { useAppSelector } from 'store/index' 2 | import { checkRaw } from './filtering' 3 | import { compareTrDates, getType, isViewed } from './helpers' 4 | import { makeTransaction } from './makeTransaction' 5 | import { 6 | getHistoryStart, 7 | getSortedTransactions, 8 | getTransactions, 9 | getTransactionsHistory, 10 | } from './model' 11 | import { 12 | deleteTransactions, 13 | deleteTransactionsPermanently, 14 | markViewed, 15 | restoreTransaction, 16 | splitTransfer, 17 | applyChangesToTransaction, 18 | recreateTransaction, 19 | bulkEditTransactions, 20 | } from './thunks' 21 | 22 | export type { TransactionPatch } from './thunks' 23 | export type { TrCondition } from './filtering' 24 | export { TrType } from './helpers' 25 | 26 | export const trModel = { 27 | // Selectors 28 | getTransactions, 29 | getSortedTransactions, 30 | getTransactionsHistory, 31 | getHistoryStart, 32 | 33 | // Hooks 34 | useTransactions: () => useAppSelector(getTransactions), 35 | useSortedTransactions: () => useAppSelector(getSortedTransactions), 36 | useTransactionsHistory: () => useAppSelector(getTransactionsHistory), 37 | 38 | // Helpers 39 | compareTrDates, 40 | makeTransaction, 41 | getType, 42 | isViewed, 43 | 44 | //Filtering 45 | checkRaw, 46 | 47 | // Thunks 48 | deleteTransactions, 49 | deleteTransactionsPermanently, 50 | markViewed, 51 | restoreTransaction, 52 | splitTransfer, 53 | applyChangesToTransaction, 54 | recreateTransaction, 55 | bulkEditTransactions, 56 | } 57 | -------------------------------------------------------------------------------- /src/5-entities/transaction/model.ts: -------------------------------------------------------------------------------- 1 | import { createSelector } from '@reduxjs/toolkit' 2 | import { compareTrDates, isDeleted } from './helpers' 3 | import { RootState, TSelector } from 'store' 4 | import { withPerf } from '6-shared/helpers/performance' 5 | import { TISODate } from '6-shared/types' 6 | import { toISODate } from '6-shared/helpers/date' 7 | 8 | export const getTransactions = (state: RootState) => 9 | state.data.current.transaction 10 | 11 | /** 12 | * Transactions sorted from newest to oldest 13 | */ 14 | export const getSortedTransactions = createSelector( 15 | [getTransactions], 16 | withPerf('getSortedTransactions', transactions => 17 | Object.values(transactions).sort(compareTrDates) 18 | ) 19 | ) 20 | 21 | /** 22 | * Transactions sorted from oldest to newest without deleted 23 | */ 24 | export const getTransactionsHistory = createSelector( 25 | [getSortedTransactions], 26 | withPerf('getTransactionsHistory', transactions => 27 | transactions.filter(tr => !isDeleted(tr)).reverse() 28 | ) 29 | ) 30 | 31 | export const getHistoryStart: TSelector = createSelector( 32 | [getTransactionsHistory], 33 | history => { 34 | const firstReasonableDate = '2000-01-01' as TISODate 35 | const currentDate = toISODate(new Date()) 36 | const firstTr = history.find( 37 | tr => tr.date >= firstReasonableDate && tr.date <= currentDate 38 | ) 39 | if (!firstTr) return currentDate 40 | return firstTr.date 41 | } 42 | ) 43 | -------------------------------------------------------------------------------- /src/5-entities/user/index.ts: -------------------------------------------------------------------------------- 1 | export { userModel } from './model' 2 | -------------------------------------------------------------------------------- /src/5-entities/user/model.ts: -------------------------------------------------------------------------------- 1 | import { TFxCode, TUserId } from '6-shared/types' 2 | import { RootState, useAppSelector } from 'store' 3 | import { instrumentModel } from '5-entities/currency/instrument' 4 | 5 | const getUsers = (state: RootState) => state.data.current.user 6 | 7 | const getRootUser = (state: RootState) => { 8 | const users = getUsers(state) 9 | for (const id in users) { 10 | if (!users[id].parent) return users[id] 11 | } 12 | return null 13 | } 14 | 15 | const getRootUserId = (state: RootState) => 16 | getRootUser(state)?.id || (0 as TUserId) 17 | 18 | const getUserInstrumentId = (state: RootState) => getRootUser(state)?.currency 19 | 20 | const getUserCurrency = (state: RootState): TFxCode => { 21 | const userInstrument = getUserInstrumentId(state) 22 | if (typeof userInstrument !== 'number') return 'USD' 23 | return instrumentModel.getInstCodeMap(state)[userInstrument] 24 | } 25 | 26 | export const userModel = { 27 | getUsers, 28 | getRootUser, 29 | getRootUserId, 30 | getUserInstrumentId, 31 | getUserCurrency, 32 | // Hooks 33 | useRootUserId: () => useAppSelector(getRootUserId), 34 | useUserCurrency: () => useAppSelector(getUserCurrency), 35 | useUserInstrumentId: () => useAppSelector(getUserInstrumentId), 36 | } 37 | -------------------------------------------------------------------------------- /src/5-entities/userSettings/index.ts: -------------------------------------------------------------------------------- 1 | import { useAppSelector } from 'store/index' 2 | import { 3 | getUserSettings, 4 | patchUserSettings, 5 | resetUserSettings, 6 | } from './userSettings' 7 | 8 | export type { TUserSettings, TUserSettingsPatch } from './userSettings' 9 | 10 | export const userSettingsModel = { 11 | // Selectors 12 | get: getUserSettings, 13 | 14 | // Hooks 15 | useUserSettings: () => useAppSelector(getUserSettings), 16 | 17 | // Thunk 18 | patch: patchUserSettings, 19 | reset: resetUserSettings, 20 | } 21 | -------------------------------------------------------------------------------- /src/5-entities/userSettings/userSettings.ts: -------------------------------------------------------------------------------- 1 | import { 2 | HiddenDataType, 3 | makeSimpleHiddenStore, 4 | } from '5-entities/shared/hidden-store' 5 | import { createSelector } from '@reduxjs/toolkit' 6 | import { keys } from '6-shared/helpers/keys' 7 | import { AppThunk, TSelector } from 'store' 8 | 9 | export type TUserSettings = { 10 | /** Shows if user already closed notification about migration from 0 to 1 version */ 11 | sawMigrationAlert: boolean 12 | 13 | /** This flag determines which budgets to use */ 14 | preferZmBudgets: boolean 15 | } 16 | export type TUserSettingsPatch = Partial 17 | export type TStoredUserSettings = Partial 18 | 19 | const userSettingsStore = makeSimpleHiddenStore( 20 | HiddenDataType.UserSettings, 21 | {} 22 | ) 23 | 24 | export const getUserSettings: TSelector = createSelector( 25 | [userSettingsStore.getData], 26 | raw => ({ 27 | sawMigrationAlert: raw.sawMigrationAlert ?? false, 28 | preferZmBudgets: raw.preferZmBudgets ?? false, 29 | }) 30 | ) 31 | 32 | export const patchUserSettings = 33 | (update: TUserSettingsPatch): AppThunk => 34 | (dispatch, getState) => { 35 | const currentData = userSettingsStore.getData(getState()) 36 | const newData = { ...currentData, ...update } 37 | 38 | // Remove undefined keys 39 | keys(newData).forEach(key => { 40 | if (newData[key] === undefined) delete newData[key] 41 | }) 42 | 43 | dispatch(userSettingsStore.setData(newData)) 44 | } 45 | 46 | export const resetUserSettings = userSettingsStore.resetData 47 | -------------------------------------------------------------------------------- /src/6-shared/api/storage.ts: -------------------------------------------------------------------------------- 1 | import { openDB } from 'idb' 2 | import { idbBaseName, idbStoreName } from '../config' 3 | 4 | const VERSION = 1 5 | 6 | export function getIDBStorage(base: string, store: string) { 7 | const dbPromise = openDB(base, VERSION, { 8 | upgrade(db) { 9 | db.createObjectStore(store) 10 | }, 11 | }) 12 | return { 13 | set: async (key: string, value: any) => { 14 | return (await dbPromise).put(store, value, key) 15 | }, 16 | get: async (key: string) => { 17 | return (await dbPromise).get(store, key) 18 | }, 19 | clear: async () => { 20 | return (await dbPromise).clear(store) 21 | }, 22 | } 23 | } 24 | 25 | export const storage = getIDBStorage(idbBaseName, idbStoreName) 26 | -------------------------------------------------------------------------------- /src/6-shared/api/tokenStorage.ts: -------------------------------------------------------------------------------- 1 | import { TToken } from '6-shared/types' 2 | 3 | const TOKEN_KEY = 'zm_token' 4 | 5 | export const tokenStorage = { 6 | get: () => localStorage.getItem(TOKEN_KEY) as TToken, 7 | set: (token: TToken) => 8 | token 9 | ? localStorage.setItem(TOKEN_KEY, token) 10 | : localStorage.removeItem(TOKEN_KEY), 11 | clear: () => localStorage.removeItem(TOKEN_KEY), 12 | } 13 | -------------------------------------------------------------------------------- /src/6-shared/api/zenmoney/endpoints.ts: -------------------------------------------------------------------------------- 1 | // ZenMoney endpoints 2 | export const endpoints = { 3 | ru: { 4 | auth: 'https://api.zenmoney.ru/oauth2/authorize/', 5 | token: 'https://api.zenmoney.ru/oauth2/token/', 6 | diff: 'https://api.zenmoney.ru/v8/diff/', 7 | }, 8 | app: { 9 | auth: 'https://api.zenmoney.app/oauth2/authorize/', 10 | token: 'https://api.zenmoney.app/oauth2/token/', 11 | diff: 'https://api.zenmoney.app/v8/diff/', 12 | }, 13 | } 14 | export type EndpointPreference = keyof typeof endpoints 15 | -------------------------------------------------------------------------------- /src/6-shared/api/zenmoney/errorExamples.ts: -------------------------------------------------------------------------------- 1 | export {} 2 | 3 | const noTag = { 4 | code: 'validationError', 5 | message: 6 | 'Invalid Relation "Tag" in Object Budget {{budget_id}}. Tag {{tag_id}} Doesn\'t Exist', 7 | details: { 8 | object: 'budget', 9 | objectID: '{{budget_id}}', 10 | relation: 'tag', 11 | relationID: '{{tag_id}}', 12 | }, 13 | } 14 | 15 | const serverError = { 16 | code: 'serverError', 17 | message: 18 | 'Server Inner Error. Try Again After Some Time. if Error Occurs Again Please Connect Zenmoney Support service.', 19 | } 20 | 21 | const wrongTimeFormat = { 22 | code: 'validationError', 23 | message: 24 | 'Wrong Format of currentClientTimestamp. Please Check Your Local Time', 25 | } 26 | 27 | const invalidTransaction = { 28 | code: 'validationError', 29 | message: 30 | 'Invalid Object Transaction {{transaction_id}}. Transfer Transaction Must Have Both Income and Outcome Positive', 31 | } 32 | 33 | const wrongProperty = { 34 | code: 'validationError', 35 | message: 36 | 'Invalid Property "Outcome" in Object Transaction {{transaction_id}}. Wrong Value', 37 | } 38 | 39 | const wrongProperty2 = { 40 | code: 'validationError', 41 | message: 42 | 'Invalid Property "User" in Object Reminder {{reminder_id}}. Wrong User of Object', 43 | } 44 | -------------------------------------------------------------------------------- /src/6-shared/api/zenmoney/fetchDiff.ts: -------------------------------------------------------------------------------- 1 | import type { TToken, TZmDiff, TZmRequest } from '6-shared/types' 2 | import type { EndpointPreference } from './endpoints' 3 | import { endpoints } from './endpoints' 4 | 5 | export const fakeToken = 'fake_token' 6 | 7 | export async function fetchDiff( 8 | token: TToken, 9 | preference: EndpointPreference, 10 | diff: TZmDiff = { serverTimestamp: 0 } 11 | ) { 12 | if (!token) throw Error('No token') 13 | 14 | if (token === fakeToken) { 15 | // If token is fake, pretend we got data from server 16 | return { ...diff, serverTimestamp: Math.ceil(Date.now() / 1000) } 17 | } 18 | 19 | const url = endpoints[preference].diff 20 | const body: TZmRequest = { 21 | ...diff, 22 | currentClientTimestamp: Math.floor(Date.now() / 1000), 23 | } 24 | 25 | const response = await fetch(url, { 26 | method: 'POST', 27 | body: JSON.stringify(body), 28 | headers: { 29 | 'Content-Type': 'application/json', 30 | Authorization: `Bearer ${token}`, 31 | }, 32 | }) 33 | const json = await response.json() 34 | if (json.error) throw Error(JSON.stringify(json.error)) 35 | 36 | return json as TZmDiff 37 | } 38 | -------------------------------------------------------------------------------- /src/6-shared/api/zenmoney/index.ts: -------------------------------------------------------------------------------- 1 | import { fetchDiff, fakeToken } from './fetchDiff' 2 | import { authorize, processAuthCode } from './auth' 3 | export type { EndpointPreference } from './endpoints' 4 | 5 | export const zenmoney = { 6 | processAuthCode, 7 | authorize, 8 | fetchDiff, 9 | fakeToken, 10 | } 11 | -------------------------------------------------------------------------------- /src/6-shared/api/zm-adapter/index.ts: -------------------------------------------------------------------------------- 1 | export { convertDiff } from './converters' 2 | export { toBudgetId } from './toBudgetId' 3 | -------------------------------------------------------------------------------- /src/6-shared/api/zm-adapter/toBudgetId.ts: -------------------------------------------------------------------------------- 1 | import type { TBudgetId, TTagId, TISODate } from '6-shared/types' 2 | 3 | export const toBudgetId = (date: TISODate, tag: TTagId | null): TBudgetId => 4 | `${date}#${tag}` 5 | -------------------------------------------------------------------------------- /src/6-shared/api/zmPreferenceStorage.ts: -------------------------------------------------------------------------------- 1 | import type { EndpointPreference } from '6-shared/api/zenmoney' 2 | 3 | const STORAGE_KEY = 'zm_server' 4 | 5 | export const zmPreferenceStorage = { 6 | get: (): EndpointPreference => { 7 | return window.localStorage.getItem(STORAGE_KEY) === 'app' ? 'app' : 'ru' 8 | }, 9 | set: (preference: EndpointPreference) => 10 | preference 11 | ? localStorage.setItem(STORAGE_KEY, preference) 12 | : localStorage.removeItem(STORAGE_KEY), 13 | clear: () => localStorage.removeItem(STORAGE_KEY), 14 | } 15 | -------------------------------------------------------------------------------- /src/6-shared/config.ts: -------------------------------------------------------------------------------- 1 | // Parameters for ZenMoney requests 2 | export const clientId = import.meta.env.REACT_APP_CLIENT_ID as string 3 | export const clientSecret = import.meta.env.REACT_APP_CLIENT_SECRET as string 4 | export const redirectUri = import.meta.env.REACT_APP_REDIRECT_URI as string 5 | 6 | // Tracking parameters 7 | export const sentryDSN = import.meta.env.REACT_APP_SENTRY_DSN as string 8 | export const ymid = import.meta.env.REACT_APP_YMID as string 9 | export const gaid = import.meta.env.REACT_APP_GAID as string 10 | 11 | // Info about the app 12 | export const appVersion = APP_VERSION 13 | export const appPublicUrl = import.meta.env.BASE_URL 14 | export const isProduction = import.meta.env.PROD 15 | 16 | // Database parameters 17 | export const idbBaseName = 'zerro_data' 18 | export const idbStoreName = 'serverData' 19 | -------------------------------------------------------------------------------- /src/6-shared/helpers/color/makeColorArray.ts: -------------------------------------------------------------------------------- 1 | import * as materialColors from '@mui/material/colors' 2 | import { mainColors } from '../../ui/theme/createTheme' 3 | 4 | type Shade = keyof (typeof materialColors)['red'] 5 | type ColorGroups = Exclude 6 | const allColors: ColorGroups[] = [ 7 | 'red', 8 | 'pink', 9 | 'purple', 10 | 'deepPurple', 11 | 'indigo', 12 | 'blue', 13 | 'lightBlue', 14 | 'cyan', 15 | 'teal', 16 | 'green', 17 | 'lightGreen', 18 | 'lime', 19 | 'yellow', 20 | 'amber', 21 | 'orange', 22 | 'deepOrange', 23 | 'brown', 24 | 'grey', 25 | 'blueGrey', 26 | ] 27 | 28 | const allShades: Shade[] = [ 29 | 50, 30 | 100, 31 | 200, 32 | 300, 33 | 400, 34 | 500, 35 | 600, 36 | 700, 37 | 800, 38 | 900, 39 | 'A100', 40 | 'A200', 41 | 'A400', 42 | 'A700', 43 | ] 44 | 45 | type MakeColorArrayProps = { 46 | shades?: Shade[] 47 | colors?: ColorGroups[] 48 | byShades?: boolean 49 | } 50 | export function makeColorArray(props: MakeColorArrayProps = {}) { 51 | const doNotUse = new Set(Object.values(mainColors)) 52 | const { shades = allShades, colors = allColors, byShades } = props 53 | const colorArray: string[] = [] 54 | if (byShades) 55 | shades.forEach(shade => 56 | colors.forEach(color => { 57 | const hex = materialColors[color][shade] 58 | if (hex) colorArray.push(hex) 59 | }) 60 | ) 61 | else 62 | colors.forEach(color => 63 | shades.forEach(shade => { 64 | const hex = materialColors[color][shade] 65 | if (hex) colorArray.push(hex) 66 | }) 67 | ) 68 | return colorArray.filter(x => !doNotUse.has(x)) 69 | } 70 | -------------------------------------------------------------------------------- /src/6-shared/helpers/date/formatDate.ts: -------------------------------------------------------------------------------- 1 | import { isToday, format, isYesterday, isThisYear } from 'date-fns' 2 | import ru from 'date-fns/locale/ru' 3 | import en from 'date-fns/locale/en-GB' 4 | import { TDateDraft } from '6-shared/types' 5 | import { t } from 'i18next' 6 | import { parseDate } from './utils' 7 | import { i18n } from '6-shared/localization' 8 | 9 | /** 10 | * Formats date. 11 | * @link https://date-fns.org/v2.25.0/docs/format doc 12 | * @param date 13 | * @param template 14 | */ 15 | export function formatDate(date: TDateDraft, template?: string): string { 16 | // TODO: need more elegant solution 😬 17 | const locale = (i18n.resolvedLanguage || i18n.language) === 'ru' ? ru : en 18 | const opts = { locale } 19 | const d = parseDate(date) 20 | if (template) return format(d, template, opts) 21 | const thisYearDate = format(d, `d MMMM, EEEEEE`, opts) 22 | if (isToday(d)) return `${t('common:today')}, ${thisYearDate}` 23 | if (isYesterday(d)) return `${t('common:yesterday')}, ${thisYearDate}` 24 | if (isThisYear(d)) return thisYearDate 25 | return format(d, 'd MMMM yyyy, EEEEEE', opts) 26 | } 27 | -------------------------------------------------------------------------------- /src/6-shared/helpers/date/index.ts: -------------------------------------------------------------------------------- 1 | export * from './utils' 2 | export { GroupBy, toGroup, nextGroup, makeDateArray } from './makeDateArray' 3 | export { formatDate } from './formatDate' 4 | -------------------------------------------------------------------------------- /src/6-shared/helpers/date/makeDateArray.ts: -------------------------------------------------------------------------------- 1 | import { TDateDraft, TISODate } from '6-shared/types' 2 | import { nextDay, nextMonth, nextYear, toISODate } from './utils' 3 | 4 | export enum GroupBy { 5 | Day = 'day', 6 | Month = 'month', 7 | Year = 'year', 8 | } 9 | 10 | export function toGroup(date: TDateDraft, aggregation: GroupBy): TISODate { 11 | const isoDate = toISODate(date) 12 | switch (aggregation) { 13 | case GroupBy.Year: 14 | return (isoDate.slice(0, 4) + '-01-01') as TISODate 15 | case GroupBy.Month: 16 | return (isoDate.slice(0, 7) + '-01') as TISODate 17 | case GroupBy.Day: 18 | return isoDate 19 | default: 20 | throw new Error(`Unknown aggregation: ${aggregation}`) 21 | } 22 | } 23 | 24 | export function nextGroup(date: TDateDraft, aggregation: GroupBy): TISODate { 25 | const currGroup = toGroup(date, aggregation) 26 | switch (aggregation) { 27 | case GroupBy.Year: 28 | return toISODate(nextYear(currGroup)) 29 | case GroupBy.Month: 30 | return toISODate(nextMonth(currGroup)) 31 | case GroupBy.Day: 32 | return toISODate(nextDay(currGroup)) 33 | default: 34 | throw new Error(`Unknown aggregation: ${aggregation}`) 35 | } 36 | } 37 | 38 | export function makeDateArray( 39 | from: TDateDraft, 40 | to: TDateDraft = new Date(), 41 | aggregation: GroupBy = GroupBy.Month 42 | ): Array { 43 | let current = toGroup(from, aggregation) 44 | let last = toGroup(to, aggregation) 45 | const months = [current] 46 | while (current < last) { 47 | current = nextGroup(current, aggregation) 48 | months.push(current) 49 | } 50 | return months 51 | } 52 | -------------------------------------------------------------------------------- /src/6-shared/helpers/keys.ts: -------------------------------------------------------------------------------- 1 | export function keys(o: O) { 2 | return Object.keys(o) as (keyof O)[] 3 | } 4 | 5 | type Entries = { 6 | [K in keyof T]: [K, T[K]] 7 | }[keyof T][] 8 | 9 | export function entries(obj: T) { 10 | return Object.entries(obj) as Entries 11 | } 12 | -------------------------------------------------------------------------------- /src/6-shared/helpers/money/index.ts: -------------------------------------------------------------------------------- 1 | export { formatMoney, getCurrencySymbol, rateToWords } from './format' 2 | 3 | export { 4 | round, 5 | createFxAmount, 6 | add, 7 | sub, 8 | addFxAmount, 9 | subFxAmount, 10 | isEqualFxAmount, 11 | isZero, 12 | convertFx, 13 | } from './currencyHelpers' 14 | -------------------------------------------------------------------------------- /src/6-shared/helpers/performance.ts: -------------------------------------------------------------------------------- 1 | export const withPerf = , U>( 2 | name: string, 3 | fn: (...args: T) => U 4 | ) => { 5 | return (...args: T): U => { 6 | const t0 = performance.now() 7 | const res = fn(...args) 8 | const time = +(performance.now() - t0).toFixed(4) 9 | //@ts-ignore 10 | if (window?.zerro?.logsShow) console.log('⏱ ' + name.padEnd(32, ' '), time) 11 | 12 | //@ts-ignore 13 | if (window.zerro) { 14 | //@ts-ignore 15 | window.zerro.logs ??= {} 16 | //@ts-ignore 17 | window.zerro.logs[name] ??= [] 18 | //@ts-ignore 19 | window.zerro.logs[name].push(time) 20 | } 21 | return res 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/6-shared/helpers/pluralize.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Returns text in a correct form 3 | * @param n - number 4 | * @param textForms - text forms e.g. `['минута', 'минуты', 'минут']` 5 | * @returns string 6 | */ 7 | export default function pluralize( 8 | n: number, 9 | textForms: [string, string, string] 10 | ) { 11 | let n1 = Math.abs(n) % 100 12 | let n2 = n1 % 10 13 | if (n1 > 10 && n1 < 20) return textForms[2] 14 | if (n2 > 1 && n2 < 5) return textForms[1] 15 | if (n2 === 1) return textForms[0] 16 | return textForms[2] 17 | } 18 | -------------------------------------------------------------------------------- /src/6-shared/helpers/random.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Returns a random integer between min (inclusive) and max (inclusive). 3 | */ 4 | export function randomInt(min: number, max: number): number { 5 | min = Math.ceil(min) 6 | max = Math.floor(max) 7 | return Math.floor(Math.random() * (max - min + 1)) + min 8 | } 9 | -------------------------------------------------------------------------------- /src/6-shared/helpers/receipt.ts: -------------------------------------------------------------------------------- 1 | import { TDateDraft } from '6-shared/types' 2 | import { isValidDate, parseDate } from './date' 3 | 4 | interface TReciept { 5 | /** Timestamp */ 6 | t: Date 7 | /** Amount */ 8 | s: number 9 | /** */ 10 | fn: string 11 | /** Reciept number */ 12 | i: string 13 | /** */ 14 | fp: string 15 | /** Type of transaction */ 16 | n: string 17 | } 18 | 19 | // Reciept string looks like this 20 | // t=20211028T1636&s=1299.00&fn=9287440301110113&i=19313&fp=1992968429&n=1 21 | 22 | /** Parses reciept string */ 23 | export function parseReceipt(string: string): TReciept | null { 24 | try { 25 | return parseReceiptUnsafe(string) 26 | } catch (e) { 27 | console.error('Error parsing reciept', e) 28 | console.error('Reciept:', string) 29 | return null 30 | } 31 | } 32 | 33 | function stringToObject(str: string) { 34 | return str.split('&').reduce( 35 | (acc, str) => { 36 | const [key, val] = str.split('=') 37 | if (key && val) acc[key] = val 38 | return acc 39 | }, 40 | {} as Record 41 | ) 42 | } 43 | 44 | function parseReceiptUnsafe(string: string): TReciept { 45 | let obj = stringToObject(string) 46 | const date = parseDate(obj.t as TDateDraft) 47 | if (!isValidDate(date) || isNaN(+obj.s)) { 48 | throw new Error('Unknown reciept format') 49 | } 50 | return { 51 | t: date, 52 | s: +obj.s || 0, 53 | fn: obj.fn || '', 54 | i: obj.i || '', 55 | fp: obj.fp || '', 56 | n: obj.n || '', 57 | } as TReciept 58 | } 59 | -------------------------------------------------------------------------------- /src/6-shared/helpers/storybookDecorator.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Box, BoxProps } from '@mui/material' 3 | import { store } from 'store' 4 | import { Providers } from '1-app/Providers' 5 | 6 | const decorator = (boxProps: BoxProps) => (story: any) => ( 7 | 8 | 17 | {story()} 18 | 19 | 20 | ) 21 | 22 | export default decorator 23 | -------------------------------------------------------------------------------- /src/6-shared/helpers/tracking.ts: -------------------------------------------------------------------------------- 1 | import reactGA from 'react-ga' 2 | import * as Sentry from '@sentry/browser' 3 | import { ErrorInfo } from 'react' 4 | import { History } from 'history' 5 | import { 6 | appVersion, 7 | gaid, 8 | isProduction, 9 | sentryDSN, 10 | ymid, 11 | } from '6-shared/config' 12 | 13 | export function initSentry() { 14 | if (isProduction && sentryDSN) { 15 | Sentry.init({ release: appVersion, dsn: sentryDSN }) 16 | } 17 | } 18 | 19 | export function captureError(error: Error, errorInfo?: ErrorInfo) { 20 | if (!isProduction) return 21 | if (!error) return 22 | 23 | if (errorInfo) { 24 | Sentry.withScope(scope => { 25 | // @ts-ignore 26 | scope.setExtras(errorInfo) 27 | Sentry.captureException(error) 28 | }) 29 | } else { 30 | Sentry.captureException(error) 31 | } 32 | } 33 | 34 | export function initTracking(history: History) { 35 | if (isProduction && gaid) { 36 | reactGA.initialize(gaid) 37 | history.listen(location => { 38 | reactGA.set({ page: location.pathname }) // Update the user's current page 39 | reactGA.pageview(location.pathname) // Record a pageview for the given page 40 | }) 41 | } 42 | } 43 | 44 | export function setUserId(userId: number) { 45 | if (isProduction && userId) { 46 | reactGA.set({ userId }) 47 | } 48 | } 49 | 50 | export function sendEvent(event: string) { 51 | if (event && isProduction) { 52 | // @ts-ignore 53 | if (window.ym && ymid) { 54 | // @ts-ignore 55 | window.ym(ymid, 'reachGoal', event) 56 | } 57 | 58 | const eventArr = event.split(': ') 59 | reactGA.event({ 60 | category: eventArr[0], 61 | action: eventArr[1] || '-', 62 | label: eventArr[2] || '', 63 | }) 64 | } else console.log('📫', event) 65 | } 66 | -------------------------------------------------------------------------------- /src/6-shared/historyPopovers/index.tsx: -------------------------------------------------------------------------------- 1 | export { PopoverManager, registerPopover } from './PopoverManager' 2 | export { popoverStack } from './popoverStack' 3 | -------------------------------------------------------------------------------- /src/6-shared/hooks/useCachedValue.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react' 2 | 3 | /** 4 | * Updates value only when clock is true. 5 | * Used inside modals to prevent re-renders while animating it out. 6 | */ 7 | export function useCachedValue(value: T, clock: boolean): T { 8 | const [cachedValue, setCachedValue] = useState(value) 9 | useEffect(() => { 10 | if (clock) setCachedValue(value) 11 | }, [value, clock]) 12 | return cachedValue 13 | } 14 | -------------------------------------------------------------------------------- /src/6-shared/hooks/useDebounce.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react' 2 | // Taken from here https://usehooks.com/useDebounce/ 3 | 4 | export function useDebounce(value: T, delay: number): T { 5 | const [debouncedValue, setDebouncedValue] = useState(value) 6 | 7 | useEffect(() => { 8 | const handler = setTimeout(() => setDebouncedValue(value), delay) 9 | return () => clearTimeout(handler) 10 | }, [value, delay]) 11 | 12 | return debouncedValue 13 | } 14 | -------------------------------------------------------------------------------- /src/6-shared/hooks/useHomeBar.ts: -------------------------------------------------------------------------------- 1 | import json2mq from 'json2mq' 2 | import useMediaQuery from '@mui/material/useMediaQuery' 3 | 4 | export function useHomeBar() { 5 | const iPhoneXXS11ProMediaQuery = json2mq({ 6 | screen: true, 7 | minDeviceWidth: 375, 8 | maxDeviceWidth: 812, 9 | '-webkit-device-pixel-ratio': 3, 10 | }) 11 | const iPhoneXR11MediaQuery = json2mq({ 12 | screen: true, 13 | minDeviceWidth: 414, 14 | maxDeviceWidth: 896, 15 | '-webkit-device-pixel-ratio': 2, 16 | }) 17 | const iPhoneXSMax11ProMaxMediaQuery = json2mq({ 18 | screen: true, 19 | minDeviceWidth: 414, 20 | maxDeviceWidth: 896, 21 | '-webkit-device-pixel-ratio': 3, 22 | }) 23 | const isiPhoneWithHomeBar = useMediaQuery( 24 | `${iPhoneXXS11ProMediaQuery}, ${iPhoneXR11MediaQuery}, ${iPhoneXSMax11ProMaxMediaQuery}` 25 | ) 26 | // I don't know why, but for iPhone 8 Plus media query above is also met, so I had to use this workaround. 27 | const isiPhone8Plus = 28 | window.screen.height / window.screen.width === 736 / 414 && 29 | window.devicePixelRatio === 3 30 | const hasHomeBar = 31 | // @ts-ignore 32 | window.navigator.standalone && isiPhoneWithHomeBar && !isiPhone8Plus 33 | return hasHomeBar 34 | } 35 | -------------------------------------------------------------------------------- /src/6-shared/hooks/useSearchParam.ts: -------------------------------------------------------------------------------- 1 | import { Location } from 'history' 2 | import { useCallback, useEffect, useState } from 'react' 3 | import { useHistory } from 'react-router' 4 | 5 | type TUpdateMethod = 'push' | 'replace' 6 | 7 | export function useSearchParam( 8 | key: string, 9 | defaultMethod: TUpdateMethod = 'replace' 10 | ) { 11 | const history = useHistory() 12 | const [val, setVal] = useState(getParam(history.location, key)) 13 | 14 | useEffect( 15 | () => history.listen(location => setVal(getParam(location, key))), 16 | [history, key] 17 | ) 18 | 19 | const setValue = useCallback( 20 | (value?: T | null, method: TUpdateMethod = defaultMethod) => 21 | history[method](getModifiedPath(key, value), history.location.state), 22 | [history, key, defaultMethod] 23 | ) 24 | 25 | return [val, setValue] as [T | null, (value?: T | null) => void] 26 | } 27 | 28 | function getParam(location: Location, key: string) { 29 | const value = new URLSearchParams(location.search).get(key) 30 | return value ? (decodeURIComponent(value) as T) : null 31 | } 32 | 33 | function getModifiedPath(key: string, value?: string | null) { 34 | const url = new URL(window.location.href) 35 | url.searchParams.delete(key) 36 | if (value) url.searchParams.append(key, value) 37 | return url.pathname + url.hash + url.search 38 | } 39 | -------------------------------------------------------------------------------- /src/6-shared/hooks/useToggle.ts: -------------------------------------------------------------------------------- 1 | // Source https://usehooks.com/useToggle/ 2 | import { useCallback, useState } from 'react' 3 | 4 | // Parameter is the boolean, with default "false" value 5 | export const useToggle = (initialState: boolean = false) => { 6 | // Initialize the state 7 | const [state, setState] = useState(initialState) 8 | // Define and memorize toggler function in case we pass down the comopnent, 9 | // This function change the boolean value to it's opposite value 10 | const toggle = useCallback((): void => setState(state => !state), []) 11 | return [state, toggle] as [boolean, typeof toggle] 12 | } 13 | -------------------------------------------------------------------------------- /src/6-shared/localization/LangSwitcher.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactNode } from 'react' 2 | import { useTranslation } from 'react-i18next' 3 | 4 | type LangSwitcherProps = { 5 | [key: string]: ReactNode 6 | } 7 | 8 | export const LangSwitcher = (props: LangSwitcherProps) => { 9 | const { i18n } = useTranslation() 10 | 11 | const getChildForCurrentLang = () => { 12 | // Return child for current language 13 | const currentLang = i18n.resolvedLanguage || i18n.language 14 | if (props[currentLang]) return props[currentLang] 15 | 16 | // Return first fallback language that has a child 17 | const fallbackLng = i18n.options.fallbackLng 18 | if (typeof fallbackLng === 'string' && props[fallbackLng]) { 19 | return props[fallbackLng] 20 | } 21 | if (Array.isArray(fallbackLng)) { 22 | for (const fallback of fallbackLng) { 23 | if (props[fallback]) return props[fallback] 24 | } 25 | } 26 | 27 | // Return null if no child found 28 | return null 29 | } 30 | 31 | return <>{getChildForCurrentLang()} 32 | } 33 | -------------------------------------------------------------------------------- /src/6-shared/localization/dateLocalization.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ruDateLocale from 'date-fns/locale/ru' 3 | import enDateLocale from 'date-fns/locale/en-US' 4 | import { i18n } from './i18n' 5 | 6 | import { LocalizationProvider as DatePickerLocalizationProvider } from '@mui/x-date-pickers' 7 | import { AdapterDateFns } from '@mui/x-date-pickers/AdapterDateFns' 8 | import { useTranslation } from 'react-i18next' 9 | 10 | function getDateLocale(int: typeof i18n) { 11 | switch (int.language) { 12 | case 'ru': 13 | return ruDateLocale 14 | default: 15 | return enDateLocale 16 | } 17 | } 18 | 19 | export function LocalizationProvider(props: { children: React.ReactNode }) { 20 | const { i18n } = useTranslation() 21 | return ( 22 | 26 | {props.children} 27 | 28 | ) 29 | } 30 | -------------------------------------------------------------------------------- /src/6-shared/localization/index.ts: -------------------------------------------------------------------------------- 1 | export * from './i18n' 2 | export { LangSwitcher } from './LangSwitcher' 3 | export { LocalizationProvider } from './dateLocalization' 4 | -------------------------------------------------------------------------------- /src/6-shared/types/index.ts: -------------------------------------------------------------------------------- 1 | export * from './ts-utils' 2 | export * from './types' 3 | export * from './data-entities' 4 | -------------------------------------------------------------------------------- /src/6-shared/types/ts-utils.ts: -------------------------------------------------------------------------------- 1 | import { TISODate, TISOMonth } from './types' 2 | 3 | /** Objects stored in collection by id */ 4 | export type ByIdOld = { [id: string]: T } 5 | export type ById = Record 6 | 7 | export type ByMonth = Record 8 | export type ByDate = Record 9 | 10 | /** Edit type */ 11 | export type Modify = Omit & R 12 | 13 | /** Make all properties otional except some lited ones */ 14 | export type OptionalExceptFor = Partial & 15 | Pick 16 | -------------------------------------------------------------------------------- /src/6-shared/ui/AdaptivePopover.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { 3 | Popover, 4 | PopoverProps, 5 | SwipeableDrawer, 6 | SwipeableDrawerProps, 7 | Theme, 8 | useMediaQuery, 9 | } from '@mui/material' 10 | 11 | const radius = '16px' 12 | const br = { 13 | top: [0, 0, radius, radius].join(' '), 14 | left: [radius, 0, 0, radius].join(' '), 15 | right: [0, radius, radius, 0].join(' '), 16 | bottom: [radius, radius, 0, 0].join(' '), 17 | } 18 | 19 | export const AdaptivePopover = ( 20 | props: PopoverProps & { anchor?: SwipeableDrawerProps['anchor'] } 21 | ) => { 22 | const isMobile = useMediaQuery(theme => theme.breakpoints.down('md')) 23 | const { transitionDuration, anchorEl, anchor, ...rest } = props 24 | 25 | if (isMobile) { 26 | const placement = anchor || 'bottom' 27 | return ( 28 | {}} 31 | disableSwipeToOpen 32 | {...rest} 33 | onClose={e => { 34 | if (rest.onClose) rest.onClose({}, 'backdropClick') 35 | }} 36 | slotProps={{ 37 | paper: { 38 | sx: { 39 | maxHeight: 'calc(100vh - 48px)', 40 | borderRadius: br[placement], 41 | }, 42 | }, 43 | }} 44 | /> 45 | ) 46 | } 47 | 48 | return 49 | } 50 | -------------------------------------------------------------------------------- /src/6-shared/ui/Amount.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react' 2 | import { formatMoney } from '6-shared/helpers/money' 3 | import { TFxCode } from '6-shared/types' 4 | 5 | export type AmountProps = React.DetailedHTMLProps< 6 | React.HTMLAttributes, 7 | HTMLSpanElement 8 | > & { 9 | value: number 10 | currency?: TFxCode 11 | sign?: boolean 12 | noShade?: boolean 13 | decimals?: number 14 | decMode?: 'always' | 'ifOnly' | 'ifAny' 15 | intProps?: React.HTMLProps 16 | decProps?: React.HTMLProps 17 | } 18 | 19 | const decStyle = { opacity: 0.5 } 20 | 21 | export const Amount: FC = props => { 22 | const { 23 | value = 0, 24 | currency, 25 | sign, 26 | noShade = false, 27 | decimals = 2, 28 | decMode = 'always', 29 | intProps, 30 | decProps, 31 | ...rest 32 | } = props 33 | let dec = decimals 34 | if (decMode === 'always') dec = decimals 35 | else if (decMode === 'ifOnly') 36 | dec = value !== 0 && value < 1 && value > -1 ? decimals : 0 37 | else if (decMode === 'ifAny') dec = value % 1 ? decimals : 0 38 | else throw Error('Unknown decMode ' + decMode) 39 | 40 | let str = '' 41 | if (value === 0) str = formatMoney(0, currency, dec) 42 | if (value < 0) 43 | str = (sign === false ? '' : '−') + formatMoney(-value, currency, dec) 44 | if (value > 0) str = (sign ? '+' : '') + formatMoney(value, currency, dec) 45 | const arr = str.split(',') 46 | if (arr.length === 2) { 47 | return ( 48 | 49 | {arr[0]}, 50 | 51 | {arr[1]} 52 | 53 | 54 | ) 55 | } 56 | return {str} 57 | } 58 | -------------------------------------------------------------------------------- /src/6-shared/ui/ColorPickerPopover/colors.ts: -------------------------------------------------------------------------------- 1 | import { makeColorArray } from '6-shared/helpers/color/makeColorArray' 2 | 3 | export const zmColors = [ 4 | '#CC3077', 5 | '#FB8D01', 6 | '#43A047', 7 | '#29B6F6', 8 | '#1564C0', 9 | '#9C26B0', 10 | ] 11 | 12 | export const colors = makeColorArray({ 13 | shades: [ 14 | // 900, 15 | 'A700', 16 | 'A400', 17 | 'A100', 18 | ], 19 | colors: ['lightGreen', 'yellow', 'orange', 'red', 'pink', 'brown'], 20 | byShades: true, 21 | }).concat( 22 | makeColorArray({ 23 | shades: [ 24 | 'A100', 25 | 'A400', 26 | 'A700', 27 | // 900, 28 | ], 29 | colors: ['teal', 'lightBlue', 'indigo', 'purple', 'blueGrey', 'grey'], 30 | byShades: true, 31 | }) 32 | ) 33 | -------------------------------------------------------------------------------- /src/6-shared/ui/ColorPickerPopover/styles.scss: -------------------------------------------------------------------------------- 1 | .color-check-label { 2 | width: 48px; 3 | height: 48px; 4 | position: relative; 5 | display: inline-block; 6 | cursor: pointer; 7 | 8 | & input { 9 | position: absolute; 10 | opacity: 0; 11 | } 12 | 13 | &:hover .marker { 14 | transform: scale(1.1); 15 | } 16 | 17 | &:active .marker { 18 | transform: scale(1); 19 | } 20 | 21 | & .marker { 22 | position: absolute; 23 | transform: scale(1); 24 | top: 8px; 25 | left: 8px; 26 | right: 8px; 27 | bottom: 8px; 28 | border-radius: 50%; 29 | transition: 0.2s ease-in-out; 30 | will-change: border-radius, transform; 31 | 32 | &.checked { 33 | border-radius: 6px; 34 | transform: rotate(45deg) scale(1.1); 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/6-shared/ui/RadialProgress.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react' 2 | import { useAppTheme } from '6-shared/ui/theme' 3 | 4 | export type RadialProgressProps = React.SVGProps & { 5 | size?: number 6 | value: number 7 | } 8 | 9 | export const RadialProgress: FC = ({ 10 | size = 16, 11 | value, 12 | ...rest 13 | }) => { 14 | value = value < 0 ? 0 : value 15 | const completed = value >= 1 16 | const theme = useAppTheme() 17 | const colorSuccess = theme.palette.success.main 18 | const colorMain = theme.palette.text.secondary 19 | 20 | const r = 12 21 | const length = 2 * Math.PI * r 22 | return ( 23 | 24 | 34 | 35 | 36 | 48 | 49 | 50 | 58 | 59 | ) 60 | } 61 | -------------------------------------------------------------------------------- /src/6-shared/ui/SmartDialog.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { 3 | Dialog, 4 | DialogProps, 5 | SwipeableDrawer, 6 | Theme, 7 | useMediaQuery, 8 | } from '@mui/material' 9 | import { popoverStack } from '6-shared/historyPopovers' 10 | 11 | export type TSmartDialogProps = Omit & { elKey: string } 12 | 13 | const drawerPaperProps = { 14 | sx: { maxHeight: 'calc(100vh - 48px)', borderRadius: '8px 8px 0 0' }, 15 | } 16 | 17 | export function SmartDialog(props: TSmartDialogProps) { 18 | const { elKey, ...dialogProps } = props 19 | const [open, onOpen, onClose] = popoverStack.usePopoverState(elKey) 20 | const isMobile = useMediaQuery(theme => theme.breakpoints.down('sm')) 21 | 22 | return isMobile ? ( 23 | 34 | ) : ( 35 | 41 | ) 42 | } 43 | -------------------------------------------------------------------------------- /src/6-shared/ui/SnackbarProvider.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, useCallback, useContext, useState } from 'react' 2 | import { IconButton, Slide, SlideProps, Snackbar } from '@mui/material' 3 | import { useCachedValue } from '6-shared/hooks/useCachedValue' 4 | import { CloseIcon } from './Icons' 5 | 6 | export type TSnackBarProps = { 7 | message: string 8 | autoHideDuration?: number 9 | } 10 | 11 | const SnackbarContext = React.createContext<(msg: TSnackBarProps) => void>( 12 | () => {} 13 | ) 14 | 15 | export const useSnackbar = () => useContext(SnackbarContext) 16 | 17 | export const SnackbarProvider: FC<{ children: React.ReactNode }> = props => { 18 | const [msg, setMsg] = useState(null) 19 | const onClose = useCallback(() => setMsg(null), []) 20 | const isOpened = Boolean(msg) 21 | const cached = useCachedValue(msg, isOpened) 22 | 23 | const setMessage = useCallback((msg: TSnackBarProps) => { 24 | setMsg(msg) 25 | }, []) 26 | 27 | return ( 28 | 29 | {props.children} 30 | 43 | 44 | 45 | } 46 | slots={{ 47 | transition: TransitionLeft, 48 | }} 49 | /> 50 | 51 | ) 52 | } 53 | 54 | function TransitionLeft(props: SlideProps) { 55 | return 56 | } 57 | -------------------------------------------------------------------------------- /src/6-shared/ui/Tooltip.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react' 2 | import { 3 | styled, 4 | Tooltip as MaterialTooltip, 5 | tooltipClasses, 6 | TooltipProps, 7 | } from '@mui/material' 8 | import { Modify } from '6-shared/types' 9 | 10 | const StyledTooltip = styled(({ className, ...props }: TooltipProps) => ( 11 | 12 | ))(({ theme }) => ({ 13 | [`& .${tooltipClasses.tooltip}`]: { 14 | fontSize: theme.typography.fontSize, 15 | }, 16 | })) 17 | 18 | type CustomTooltipProps = Modify< 19 | TooltipProps, 20 | { title?: TooltipProps['title'] } 21 | > 22 | 23 | export const Tooltip: FC = ({ title, ...props }) => { 24 | if (!title) return <>{props.children} 25 | return 26 | } 27 | -------------------------------------------------------------------------------- /src/6-shared/ui/Total.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Box, BoxProps, Typography } from '@mui/material' 3 | import { Amount, AmountProps } from '6-shared/ui/Amount' 4 | 5 | interface TotalProps extends BoxProps { 6 | title: string 7 | amountColor?: string 8 | align?: 'center' | 'right' | 'left' 9 | value: AmountProps['value'] 10 | currency?: AmountProps['currency'] 11 | sign?: AmountProps['sign'] 12 | decMode?: AmountProps['decMode'] 13 | noShade?: AmountProps['noShade'] 14 | } 15 | 16 | export function Total({ 17 | align = 'center', 18 | title = '', 19 | value = 0, 20 | currency, 21 | sign = undefined, 22 | decMode, 23 | noShade = false, 24 | amountColor, 25 | ...rest 26 | }: TotalProps) { 27 | return ( 28 | 29 | 35 | 40 | 47 | 48 | 49 | ) 50 | } 51 | -------------------------------------------------------------------------------- /src/6-shared/ui/theme/AppThemeProvider.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react' 2 | import { Helmet } from 'react-helmet' 3 | import { Global, css } from '@emotion/react' 4 | import CssBaseline from '@mui/material/CssBaseline' 5 | import { ThemeProvider, Theme } from '@mui/material/styles' 6 | import { appTheme } from './createTheme' 7 | import { THEME_KEY, fixOldTheme, useAppTheme, useColorScheme } from './hooks' 8 | 9 | import './styles.scss' 10 | 11 | fixOldTheme() 12 | 13 | const GlobalVaribles = (props: { theme: Theme }) => { 14 | const styles = css` 15 | :root { 16 | --c-bg: ${props.theme.palette.background.default}; 17 | --c-scrollbar: ${props.theme.palette.divider}; 18 | --c-primary: ${props.theme.palette.primary.main}; 19 | } 20 | ` 21 | return 22 | } 23 | 24 | export const AppThemeProvider: FC<{ children?: React.ReactNode }> = props => { 25 | const { mode } = useColorScheme() 26 | 27 | return ( 28 | 29 | 30 | {props.children} 31 | 32 | ) 33 | } 34 | 35 | const WithTheme: FC = () => { 36 | const theme = useAppTheme() 37 | return ( 38 | <> 39 | 40 | 41 | 42 | 43 | 44 | 45 | ) 46 | } 47 | -------------------------------------------------------------------------------- /src/6-shared/ui/theme/hooks.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | useColorScheme as useMuiColorScheme, 3 | useMediaQuery, 4 | useTheme, 5 | } from '@mui/material' 6 | import { useCallback } from 'react' 7 | 8 | export const THEME_KEY = 'theme' 9 | 10 | export const useAppTheme = () => useTheme() 11 | 12 | export function useColorScheme() { 13 | const { mode, setMode } = useMuiColorScheme() 14 | const prefersDark = useMediaQuery('(prefers-color-scheme: dark)') 15 | const systemTheme = prefersDark ? 'dark' : 'light' 16 | 17 | const toggle = useCallback(() => { 18 | if (!mode || mode === 'system') { 19 | setMode(systemTheme === 'dark' ? 'light' : 'dark') 20 | return 21 | } 22 | const nextTheme = mode === 'light' ? 'dark' : 'light' 23 | setMode(nextTheme === systemTheme ? 'system' : nextTheme) 24 | }, [mode, setMode, systemTheme]) 25 | 26 | return { 27 | mode: !mode || mode === 'system' ? systemTheme : mode, 28 | toggle, 29 | } 30 | } 31 | 32 | export function fixOldTheme() { 33 | const theme = localStorage.getItem(THEME_KEY) 34 | if (theme === '"dark"') { 35 | localStorage.setItem(THEME_KEY, 'dark') 36 | } 37 | if (theme === '"light"') { 38 | localStorage.setItem(THEME_KEY, 'light') 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/6-shared/ui/theme/index.tsx: -------------------------------------------------------------------------------- 1 | export { AppThemeProvider } from './AppThemeProvider' 2 | export { useColorScheme, useAppTheme } from './hooks' 3 | -------------------------------------------------------------------------------- /src/6-shared/ui/theme/styles.scss: -------------------------------------------------------------------------------- 1 | @import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;700&display=swap'); 2 | 3 | html { 4 | box-sizing: border-box; 5 | font-family: 'IBM Plex Sans', Arial, sans-serif; 6 | } 7 | 8 | *, 9 | *::before, 10 | *::after { 11 | box-sizing: inherit; 12 | -webkit-tap-highlight-color: transparent; 13 | } 14 | 15 | ::-webkit-scrollbar { 16 | width: 12px; 17 | height: 12px; 18 | } 19 | 20 | ::-webkit-scrollbar-corner { 21 | background: var(--c-bg); 22 | } 23 | 24 | ::-webkit-scrollbar-thumb { 25 | background-color: var(--c-scrollbar); 26 | border-radius: 6px; 27 | border: 2px solid var(--c-bg); 28 | } 29 | 30 | @media (max-width: 880px) { 31 | * { 32 | scrollbar-width: none; 33 | overflow: -moz-scrollbars-none; 34 | } 35 | 36 | ::-webkit-scrollbar { 37 | display: none; 38 | } 39 | } 40 | 41 | .hidden-scroll { 42 | scrollbar-width: none; 43 | overflow: -moz-scrollbars-none; 44 | 45 | &::-webkit-scrollbar { 46 | display: none; 47 | } 48 | } 49 | 50 | .red-gradient { 51 | background-color: rgb(230, 35, 0); 52 | background-image: linear-gradient( 53 | 105deg, 54 | rgb(187, 59, 138) 0%, 55 | rgb(246, 144, 113) 100% 56 | ); 57 | background-size: 100%; 58 | background-repeat: repeat; 59 | background-clip: text; 60 | -webkit-background-clip: text; 61 | -webkit-text-fill-color: transparent; 62 | -moz-text-fill-color: transparent; 63 | } 64 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | // import { scan } from 'react-scan' // must be imported before React and React DOM 2 | import React from 'react' 3 | import '6-shared/localization' 4 | import { createRoot } from 'react-dom/client' 5 | import { MainApp } from '1-app' 6 | 7 | // scan({ enabled: true }) 8 | 9 | const container = document.getElementById('root') 10 | if (!container) throw new Error('No root container') 11 | const root = createRoot(container) 12 | 13 | root.render() 14 | -------------------------------------------------------------------------------- /src/store/data/index.ts: -------------------------------------------------------------------------------- 1 | import reducer from './slice' 2 | export default reducer 3 | 4 | // ACTIONS 5 | export { applyServerPatch, applyClientPatch, resetData } from './slice' 6 | 7 | // SELECTORS 8 | export { 9 | getDiff, 10 | getChangedNum, 11 | getLastChangeTime, 12 | getLastSyncTime, 13 | } from './selectors' 14 | -------------------------------------------------------------------------------- /src/store/data/selectors.ts: -------------------------------------------------------------------------------- 1 | import { RootState } from 'store' 2 | import { getItemsCount } from './shared/getItemsCount' 3 | import { getLastDiffChange } from './shared/getLastDiffChange' 4 | 5 | export const getDiff = (state: RootState) => state.data.diff 6 | 7 | export const getChangedNum = (state: RootState) => { 8 | return getItemsCount(getDiff(state)) 9 | } 10 | 11 | export const getLastChangeTime = (state: RootState) => { 12 | return getLastDiffChange(getDiff(state)) 13 | } 14 | 15 | export const getLastSyncTime = (state: RootState) => { 16 | return state.data.current.serverTimestamp 17 | } 18 | -------------------------------------------------------------------------------- /src/store/data/shared/getItemsCount.ts: -------------------------------------------------------------------------------- 1 | import { TDiff } from '6-shared/types' 2 | 3 | /** 4 | * Counts all items in a diff object 5 | */ 6 | export function getItemsCount(diff?: TDiff | null): number { 7 | if (!diff) return 0 8 | let count = 0 9 | Object.values(diff).forEach(array => { 10 | if (typeof array === 'number') return 11 | count += array.length 12 | }) 13 | return count 14 | } 15 | -------------------------------------------------------------------------------- /src/store/data/shared/getLastDiffChange.ts: -------------------------------------------------------------------------------- 1 | import { TDiff, TMsTime } from '6-shared/types' 2 | 3 | /** 4 | * Returns last time an item was changed in the diff 5 | */ 6 | export function getLastDiffChange(diff?: TDiff | null): TMsTime { 7 | if (!diff) return 0 8 | let lastChange = 0 9 | Object.values(diff).forEach(array => { 10 | if (typeof array === 'number') return 11 | array.forEach(item => { 12 | let changed = 0 13 | if ('changed' in item) changed = item.changed 14 | if ('stamp' in item) changed = item.stamp 15 | lastChange = Math.max(changed, lastChange) 16 | }) 17 | }) 18 | return lastChange 19 | } 20 | -------------------------------------------------------------------------------- /src/store/displayCurrency.ts: -------------------------------------------------------------------------------- 1 | import { createSlice, PayloadAction } from '@reduxjs/toolkit' 2 | import { AppThunk, RootState } from 'store' 3 | import { TFxCode } from '6-shared/types' 4 | import { sendEvent } from '6-shared/helpers/tracking' 5 | 6 | const KEY = 'display-currency' 7 | const savedCurrency = { 8 | get: () => { 9 | const raw = localStorage.getItem(KEY) 10 | if (!raw) return null 11 | try { 12 | return JSON.parse(raw) as TFxCode 13 | } catch (error) { 14 | return null 15 | } 16 | }, 17 | set: (currency: TFxCode | null) => { 18 | if (currency) localStorage.setItem(KEY, JSON.stringify(currency)) 19 | else localStorage.removeItem(KEY) 20 | }, 21 | } 22 | 23 | const initialState = savedCurrency.get() 24 | 25 | const { reducer, actions } = createSlice({ 26 | name: 'displayCurrency', 27 | initialState, 28 | reducers: { 29 | setCurrency: (_, action: PayloadAction) => action.payload, 30 | }, 31 | }) 32 | 33 | // REDUCER 34 | export default reducer 35 | 36 | // ACTIONS 37 | export const { setCurrency } = actions 38 | 39 | // SELECTORS 40 | export const getSavedCurrency = (state: RootState) => state.displayCurrency 41 | 42 | export const setSavedCurrency = 43 | (currency: TFxCode | null): AppThunk => 44 | (dispatch, getState) => { 45 | savedCurrency.set(currency) 46 | dispatch(setCurrency(currency)) 47 | sendEvent('DisplayCurrency: set') 48 | } 49 | -------------------------------------------------------------------------------- /src/store/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | configureStore, 3 | UnknownAction, 4 | ThunkAction, 5 | ThunkDispatch, 6 | } from '@reduxjs/toolkit' 7 | import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux' 8 | import { tokenStorage } from '6-shared/api/tokenStorage' 9 | 10 | import data from './data' 11 | import token from './token' 12 | import isPending from './isPending' 13 | import lastSync from './lastSync' 14 | import displayCurrency from './displayCurrency' 15 | 16 | export const store = configureStore({ 17 | reducer: { 18 | data, 19 | isPending, 20 | lastSync, 21 | token, 22 | displayCurrency, 23 | }, 24 | preloadedState: { token: tokenStorage.get() }, 25 | middleware: getDefaultMiddleware => 26 | getDefaultMiddleware({ immutableCheck: false, serializableCheck: false }), 27 | }) 28 | 29 | export type RootState = ReturnType 30 | export type AppThunk = ThunkAction< 31 | ReturnType, 32 | RootState, 33 | unknown, 34 | UnknownAction 35 | > 36 | export type TSelector = (state: RootState) => T 37 | 38 | // App hooks 39 | export type AppDispatch = ThunkDispatch // typeof store.dispatch 40 | export const useAppDispatch = () => useDispatch() 41 | export const useAppSelector: TypedUseSelectorHook = useSelector 42 | -------------------------------------------------------------------------------- /src/store/isPending.ts: -------------------------------------------------------------------------------- 1 | import { createSlice } from '@reduxjs/toolkit' 2 | import { RootState } from 'store' 3 | 4 | const { reducer, actions } = createSlice({ 5 | name: 'isPending', 6 | initialState: false, 7 | reducers: { 8 | setPending: (state, action) => !!action.payload, 9 | }, 10 | }) 11 | 12 | // REDUCER 13 | export default reducer 14 | 15 | // ACTIONS 16 | export const { setPending } = actions 17 | 18 | // SELECTORS 19 | export const getPendingState = (state: RootState) => state.isPending 20 | -------------------------------------------------------------------------------- /src/store/lastSync.ts: -------------------------------------------------------------------------------- 1 | import { createSlice, PayloadAction } from '@reduxjs/toolkit' 2 | import { RootState } from 'store' 3 | 4 | interface LastSync { 5 | finishedAt: number 6 | isSuccessful: boolean | null 7 | errorMessage: string | null 8 | } 9 | 10 | const initialState: LastSync = { 11 | finishedAt: 0, 12 | isSuccessful: null, 13 | errorMessage: null, 14 | } 15 | 16 | const { reducer, actions } = createSlice({ 17 | name: 'lastSync', 18 | initialState, 19 | reducers: { 20 | setSyncData: (state, action: PayloadAction) => ({ 21 | ...state, 22 | ...action.payload, 23 | }), 24 | }, 25 | }) 26 | 27 | // REDUCER 28 | export default reducer 29 | 30 | // ACTIONS 31 | export const { setSyncData } = actions 32 | 33 | // SELECTORS 34 | export const getLastSyncInfo = (state: RootState) => state.lastSync 35 | -------------------------------------------------------------------------------- /src/store/token.ts: -------------------------------------------------------------------------------- 1 | import { createSlice, PayloadAction } from '@reduxjs/toolkit' 2 | import { RootState } from 'store' 3 | import { TToken } from '6-shared/types' 4 | 5 | const { reducer, actions } = createSlice({ 6 | name: 'token', 7 | initialState: null as TToken, 8 | reducers: { 9 | setToken: (_, action: PayloadAction) => action.payload, 10 | }, 11 | }) 12 | 13 | // REDUCER 14 | export default reducer 15 | 16 | // ACTIONS 17 | export const { setToken } = actions 18 | 19 | // SELECTORS 20 | export const getToken = (state: RootState) => state.token 21 | export const getLoginState = (state: RootState) => !!getToken(state) 22 | -------------------------------------------------------------------------------- /src/stories/AccountList.stories.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | import AccountList from '3-widgets/account/AccountList' 3 | import { context } from './shared/context' 4 | 5 | import type { Meta, StoryObj } from '@storybook/react' 6 | 7 | const meta = { 8 | title: 'AccountList', 9 | component: AccountList, 10 | decorators: [context], 11 | } satisfies Meta 12 | 13 | export default meta 14 | type Story = StoryObj 15 | 16 | export const Primary: Story = { args: {} } 17 | */ 18 | -------------------------------------------------------------------------------- /src/stories/AmountInput.stories.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | import React from 'react' 3 | import { StoryObj, Meta } from '@storybook/react' 4 | import { AmountInput } from '6-shared/ui/AmountInput' 5 | import { context } from './shared/context' 6 | 7 | const meta = { 8 | title: 'AmountInput', 9 | component: AmountInput, 10 | decorators: [context], 11 | } satisfies Meta 12 | 13 | export default meta 14 | 15 | type Story = StoryObj 16 | 17 | export const Primary: Story = { 18 | args: { 19 | onChange: (value: number) => console.log(value), 20 | value: 12004.23, 21 | currency: 'RUB', 22 | label: 'Доход', 23 | }, 24 | } 25 | 26 | */ 27 | -------------------------------------------------------------------------------- /src/stories/EmojiIcon.stories.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | import React from 'react' 3 | import { EmojiIcon } from '6-shared/ui/EmojiIcon' 4 | import { Meta, StoryObj } from '@storybook/react' 5 | import { context } from './shared/context' 6 | 7 | const meta = { 8 | title: 'EmojiIcon', 9 | component: EmojiIcon, 10 | decorators: [context], 11 | argTypes: { 12 | color: { control: { type: 'color' } }, 13 | }, 14 | } satisfies Meta 15 | 16 | export default meta 17 | 18 | type Story = StoryObj 19 | 20 | export const Icon: Story = { 21 | args: { symbol: '💩' }, 22 | } 23 | 24 | */ 25 | -------------------------------------------------------------------------------- /src/stories/GoalProgress.stories.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | import React from 'react' 3 | import { Meta, StoryObj } from '@storybook/react' 4 | import { RadialProgress } from '6-shared/ui/RadialProgress' 5 | 6 | const meta = { 7 | title: 'GoalProgress', 8 | component: RadialProgress, 9 | argTypes: { 10 | value: { control: { type: 'range', min: -1, max: 2, step: 0.1 } }, 11 | size: { control: { type: 'range', min: 8, max: 120, step: 4 } }, 12 | }, 13 | } satisfies Meta 14 | 15 | export default meta 16 | 17 | type Story = StoryObj 18 | 19 | export const Progress: Story = { 20 | args: { size: 16, value: 0.4 }, 21 | } 22 | 23 | */ 24 | -------------------------------------------------------------------------------- /src/stories/Map.stories.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | import React from 'react' 3 | import { StoryObj, Meta } from '@storybook/react' 4 | import { Map } from '3-widgets/transaction/TransactionPreview/Map' 5 | import { context } from './shared/context' 6 | 7 | const meta = { 8 | title: 'Transaction/Details', 9 | component: Map, 10 | decorators: [context], 11 | } satisfies Meta 12 | 13 | export default meta 14 | type Story = StoryObj 15 | 16 | export const MapComp: Story = { 17 | args: { 18 | longitude: 30.321, 19 | latitude: 60.0762, 20 | }, 21 | } 22 | 23 | */ 24 | -------------------------------------------------------------------------------- /src/stories/Reciept.stories.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | import React from 'react' 3 | import { StoryObj, Meta } from '@storybook/react' 4 | import { Reciept } from '3-widgets/transaction/TransactionPreview/Reciept' 5 | import { context } from './shared/context' 6 | 7 | const meta = { 8 | title: 'Transaction/Details', 9 | component: Reciept, 10 | decorators: [context], 11 | } satisfies Meta 12 | 13 | export default meta 14 | type Story = StoryObj 15 | 16 | export const QRCode: Story = { 17 | args: { 18 | value: 19 | 't=20190320T2303&s=5803.00&fn=9251440300007971&i=141637&fp=4087570038&n=1', 20 | }, 21 | } 22 | 23 | */ 24 | -------------------------------------------------------------------------------- /src/stories/TagSelect.stories.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | import React from 'react' 3 | import { Meta, StoryObj } from '@storybook/react' 4 | import { TagSelect } from '../5-entities/tag/ui/TagSelect' 5 | import { context } from './shared/context' 6 | 7 | const meta = { 8 | title: 'TagSelect', 9 | component: TagSelect, 10 | decorators: [context], 11 | } satisfies Meta 12 | 13 | export default meta 14 | type Story = StoryObj 15 | 16 | export const TagSelectStory: Story = { 17 | args: { 18 | onChange: (value: string | null) => console.log(value), 19 | }, 20 | } 21 | 22 | */ 23 | -------------------------------------------------------------------------------- /src/stories/shared/DemoProviders.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react' 2 | import { store } from 'store' 3 | import { applyServerPatch } from 'store/data' 4 | import { Providers } from '1-app/Providers' 5 | import { getDemoData } from './data' 6 | 7 | store.dispatch(applyServerPatch(getDemoData())) 8 | 9 | export const DemoProviders: FC<{ children?: React.ReactNode }> = props => ( 10 | {props.children} 11 | ) 12 | -------------------------------------------------------------------------------- /src/stories/shared/context.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { DemoProviders } from './DemoProviders' 3 | 4 | export const context = (Story: any) => ( 5 | 6 | 7 | 8 | ) 9 | -------------------------------------------------------------------------------- /src/worker/index.ts: -------------------------------------------------------------------------------- 1 | // @ts-ignore Module not found 2 | // eslint-disable-next-line import/no-webpack-loader-syntax 3 | import Worker from './worker?worker' 4 | import * as Comlink from 'comlink' 5 | import { WorkerObj } from './worker' 6 | import { AppDispatch } from 'store' 7 | 8 | type Message = { action: string; payload?: any } 9 | 10 | const worker = new Worker() 11 | 12 | export const subscribe = (callback: any) => { 13 | worker.addEventListener('message', callback) 14 | } 15 | export const unsubscribe = (callback: any) => { 16 | worker.removeEventListener('message', callback) 17 | } 18 | export const sendMessage = (message: Message) => { 19 | worker.postMessage(message) 20 | } 21 | 22 | export const bindWorkerToStore = (dispatch: AppDispatch) => 23 | worker.addEventListener('message', (message: any) => { 24 | if (typeof message.data?.action === 'string') { 25 | console.log(`⚙️ ${message.data?.action}`, message.data) 26 | // dispatch(message.data) 27 | } 28 | }) 29 | 30 | export const workerMethods = Comlink.wrap(worker) 31 | 32 | export const { 33 | convertZmToLocal, 34 | getLocalData, 35 | clearStorage, 36 | saveLocalData, 37 | sync, 38 | } = workerMethods 39 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "noFallthroughCasesInSwitch": true, 4 | "baseUrl": "src", 5 | "target": "esnext", 6 | "lib": ["dom", "dom.iterable", "es2021"], 7 | "allowJs": true, 8 | "skipLibCheck": true, 9 | "esModuleInterop": true, 10 | "allowSyntheticDefaultImports": true, 11 | "strict": true, 12 | "forceConsistentCasingInFileNames": true, 13 | "module": "esnext", 14 | "moduleResolution": "node", 15 | "resolveJsonModule": true, 16 | "isolatedModules": true, 17 | "noEmit": true, 18 | "jsx": "react-jsx" 19 | }, 20 | "include": [ 21 | "src", 22 | "typings/theme.d.ts", 23 | "typings/vite-env.d.ts", 24 | "typings/i18n.d.ts" 25 | ], 26 | "references": [{ "path": "./tsconfig.node.json" }] 27 | } 28 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "module": "ESNext", 5 | "moduleResolution": "Node", 6 | "allowSyntheticDefaultImports": true 7 | }, 8 | "include": ["vite.config.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /typings/i18n.d.ts: -------------------------------------------------------------------------------- 1 | import 'i18next' 2 | import 'react-i18next' 3 | import { resources, defaultNS } from '6-shared/localization' 4 | 5 | declare module 'i18next' { 6 | interface CustomTypeOptions { 7 | defaultNS: typeof defaultNS 8 | resources: (typeof resources)['ru'] 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /typings/theme.d.ts: -------------------------------------------------------------------------------- 1 | import { Theme as MaterialTheme } from '@mui/material' 2 | import { RootState } from 'store' 3 | 4 | declare module '@emotion/react' { 5 | interface Theme extends MaterialTheme {} 6 | } 7 | 8 | declare module 'react-redux' { 9 | interface DefaultRootState extends RootState {} 10 | } 11 | -------------------------------------------------------------------------------- /typings/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | declare const APP_VERSION: string 4 | -------------------------------------------------------------------------------- /vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "rewrites": [{ "source": "/(.*)", "destination": "/index.html" }] 3 | } 4 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import react from '@vitejs/plugin-react' 3 | import tsconfigPaths from 'vite-tsconfig-paths' 4 | import mdx from '@mdx-js/rollup' 5 | import { VitePWA } from 'vite-plugin-pwa' 6 | 7 | // https://vitejs.dev/config/ 8 | export default defineConfig({ 9 | plugins: [ 10 | tsconfigPaths(), 11 | react(), 12 | mdx(), 13 | VitePWA({ 14 | registerType: 'autoUpdate', 15 | filename: 'service-worker.js', 16 | workbox: { 17 | // Increase the default 2,097,152 (2MiB) limit 18 | maximumFileSizeToCacheInBytes: 3_000_000, 19 | }, 20 | }), 21 | ], 22 | build: { 23 | outDir: 'dist', 24 | sourcemap: true, 25 | }, 26 | envPrefix: 'REACT_APP_', 27 | define: { 28 | APP_VERSION: JSON.stringify(process.env.npm_package_version), 29 | }, 30 | }) 31 | --------------------------------------------------------------------------------