From 2f40aab3f7de03557e74e10ef1175d600a443b77 Mon Sep 17 00:00:00 2001 From: Dmitri Pisarev Date: Tue, 28 Jul 2026 12:19:44 +0300 Subject: [PATCH 1/4] Modernize frontend foundations and performance --- .babelrc | 12 +- .circleci/config.yml | 89 +- .eslintignore | 21 - .eslintrc.cjs | 159 - .nvmrc | 1 + README.md | 47 +- app/Routes.tsx | 184 +- app/client.tsx | 38 +- app/components/BottomNav/BottomNav.tsx | 4 +- app/components/Button/Button.tsx | 2 +- app/components/ButtonBox/ButtonBox.tsx | 4 +- app/components/Calendar/Calendar.tsx | 4 +- .../CalendarToggle/CalendarToggle.tsx | 4 +- .../ChangeThemeButton/ChangeThemeButton.tsx | 4 +- .../CustomPrayers/CustomPrayerInput.tsx | 4 +- .../CustomPrayers/CustomPrayers.tsx | 4 +- app/components/Dialog/Dialog.tsx | 2 +- app/components/DotsMenu/DotsMenu.tsx | 4 +- app/components/Drawer/Drawer.tsx | 4 +- app/components/Drawer/DrawerWithHeader.tsx | 4 +- .../ErrorMessage404/ErrorMessage404.tsx | 4 +- .../ErrorMessage500/ErrorMessage500.tsx | 4 +- .../FindInPageButton/FindInPageButton.tsx | 6 +- app/components/Header/Header.tsx | 4 +- app/components/InPageSearch/InPageSearch.tsx | 4 +- app/components/Input/Input.tsx | 4 +- app/components/LayoutInner/LayoutInner.tsx | 12 +- .../NamesEditor/NamesEditorInput.tsx | 4 +- app/components/Nav/Nav.tsx | 4 +- app/components/NotFound/NotFound.tsx | 21 +- app/components/Note/Note.tsx | 4 +- app/components/Parts/Parts.tsx | 4 +- app/components/Promo/Promo.tsx | 84 - .../RouteErrorBoundary/RouteErrorBoundary.css | 4 + .../RouteErrorBoundary/RouteErrorBoundary.tsx | 127 + app/components/RteText/RteText.tsx | 13 +- .../ScriptEditor/ScriptEditorInput.tsx | 4 +- .../ScriptEditor/ScriptEditorToggle.tsx | 4 +- .../ScriptEditorPromo/ScriptEditorPromo.tsx | 12 +- .../ScriptVersionSelector/DuplicateButton.tsx | 4 +- .../ScriptVersionSelector/ScriptVersion.tsx | 4 +- .../ScriptVersionSelector.tsx | 4 +- .../ScrollRestoration/ScrollRestoration.tsx | 74 +- app/components/SelectBox/SelectBox.tsx | 8 +- app/components/SermonList/SermonList.tsx | 6 +- app/components/SermonSmall/SermonSmall.tsx | 12 +- .../SettingsButton/SettingsButton.tsx | 2 +- app/components/Share/Share.tsx | 2 +- app/components/Share/ShareLogin.tsx | 8 +- app/components/ShareIcon/ShareIcon.tsx | 2 +- app/components/SolidSection/SolidSection.tsx | 4 +- app/components/Textarea/Textarea.tsx | 4 +- app/components/ThemeControl/ThemeControl.tsx | 4 +- app/components/Typography/Typography.tsx | 63 +- app/components/UpdatePrompt/UpdatePrompt.tsx | 18 +- app/components/Zoom/Zoom.tsx | 2 +- app/components/ZoomControl/ZoomControl.tsx | 2 +- app/components/svgs/Bell.tsx | 2 +- app/components/svgs/Bible.tsx | 2 +- app/components/svgs/Burger.tsx | 2 +- app/components/svgs/CalendarIcon.tsx | 4 +- app/components/svgs/Check.tsx | 2 +- app/components/svgs/Cross.tsx | 2 +- app/components/svgs/CrossCircle.tsx | 2 +- app/components/svgs/CrossIcon.tsx | 2 +- app/components/svgs/Dots.tsx | 2 +- app/components/svgs/Duplicate.tsx | 4 +- app/components/svgs/ExternalLink.tsx | 4 +- app/components/svgs/Gear.tsx | 2 +- app/components/svgs/Heart.tsx | 2 +- app/components/svgs/LeftIcon.tsx | 4 +- app/components/svgs/Moon.tsx | 2 +- app/components/svgs/Pencil.tsx | 6 +- app/components/svgs/PencilLine.tsx | 2 +- app/components/svgs/PlusIcon.tsx | 4 +- app/components/svgs/Prayer.tsx | 2 +- app/components/svgs/PrayerCheck.tsx | 10 +- app/components/svgs/QuestionIcon.tsx | 2 +- app/components/svgs/QuestionMarkCircle.tsx | 10 +- app/components/svgs/RightIcon.tsx | 4 +- app/components/svgs/Sermons.tsx | 2 +- app/components/svgs/Switch.tsx | 2 +- app/components/svgs/System.tsx | 2 +- app/components/svgs/TrashIcon.tsx | 4 +- app/components/svgs/Visibility.tsx | 2 +- app/components/svgs/VisibilityOff.tsx | 2 +- .../AddSharedVersion/AddSharedVersion.tsx | 12 +- app/containers/App.tsx | 62 +- app/containers/AuthProvider.tsx | 8 +- .../HabitTracker/CalendarStreakWidget.tsx | 20 +- .../HabitTracker/ContributionGraph.tsx | 44 +- .../HabitTracker/PostPrayerPrompt.tsx | 7 +- app/containers/HabitTracker/PrayerSetup.tsx | 7 +- app/containers/Hymns/Hymn.tsx | 6 +- app/containers/Hymns/HymnButton.tsx | 10 +- app/containers/Hymns/Hymns.tsx | 6 +- app/containers/Main/Banner.tsx | 2 +- app/containers/Main/Books.tsx | 2 +- app/containers/Main/BorderedSection.tsx | 4 +- app/containers/Main/Canon.tsx | 2 +- app/containers/Main/EasterService.tsx | 2 +- app/containers/Main/HeaderMain.tsx | 16 +- app/containers/Main/HeadingBar.tsx | 4 +- app/containers/Main/IosPrompt.tsx | 4 +- app/containers/Main/Links.tsx | 2 +- app/containers/Main/Main.tsx | 22 +- app/containers/Main/Oct30.tsx | 2 +- app/containers/Main/Oct30Week.tsx | 2 +- app/containers/Main/Peace.tsx | 2 +- app/containers/Main/ReadingGroup.tsx | 4 +- app/containers/Main/ReadingItem.tsx | 4 +- app/containers/Main/ReadingList.tsx | 2 +- app/containers/Main/ReadingsForService.tsx | 27 +- app/containers/Main/Saints.tsx | 62 +- app/containers/Main/SectionHeading.tsx | 4 +- app/containers/Main/Sermons.tsx | 2 +- app/containers/Main/Services.tsx | 28 +- app/containers/Main/SettingsMenu.tsx | 6 +- app/containers/Main/Sing.tsx | 2 +- app/containers/Main/ThisDays.tsx | 23 +- app/containers/Main/TroparionFavs.tsx | 4 +- app/containers/Main/TroparionFavsHome.tsx | 6 +- app/containers/Profile/Profile.tsx | 91 +- app/containers/Readings/ReadingItem.tsx | 4 +- app/containers/Readings/ReadingItemSingle.tsx | 15 +- app/containers/Readings/Readings.tsx | 66 +- app/containers/Saint/Saint.tsx | 6 +- app/containers/Sermon/Sermon.tsx | 6 +- app/containers/SermonDetail/SermonDetail.tsx | 6 +- app/containers/SermonList/SermonList.tsx | 12 +- app/containers/Service/LanguageSwitcher.tsx | 2 +- .../Service/ParallelLanguageBar.tsx | 2 +- app/containers/Service/Service.tsx | 20 +- app/containers/Service/TOCSwitcher.tsx | 106 +- .../Service/Texts/CustomPrayer/index.dyn.tsx | 2 +- .../Service/Texts/EasterHours/index.dyn.tsx | 2 +- .../Service/Texts/FirstHour/index.dyn.tsx | 2 +- .../Service/Texts/Jacob/index.dyn.tsx | 2 +- app/containers/Service/Texts/Jacob/index.mdx | 3 +- .../Texts/Liturgies/Katekhumen/Katekhumen.tsx | 2 +- .../Service/Texts/Liturgies/Vasiliy.mdx | 2 +- .../Service/Texts/Liturgies/Vasiliy.tsx | 2 +- .../Liturgies/VespersWithVasilyChristmas.mdx | 2 +- .../Liturgies/VespersWithVasilyEpiphany.mdx | 2 +- .../VespersWithVasilyPassionSaturday.mdx | 2 +- .../VespersWithVasilyPassionThursday.mdx | 2 +- .../Texts/Liturgies/VespersWithZlatoust.mdx | 2 +- .../Service/Texts/Liturgies/Zlatoust.mdx | 2 +- .../Service/Texts/Liturgies/Zlatoust.tsx | 2 +- .../Service/Texts/Lpod/index.dyn.tsx | 2 +- .../Service/Texts/Matins/index.dyn.tsx | 2 +- app/containers/Service/Texts/MdxLoader.tsx | 4 +- .../Texts/MolitvaPamyati/MolitvaPamyati.mdx | 2 +- .../Service/Texts/SixthHour/index.dyn.tsx | 2 +- .../Service/Texts/VariableSection.tsx | 4 +- .../Service/Texts/Vespers/index.dyn.tsx | 2 +- .../Service/Texts/mdxContracts.test.mjs | 226 + app/containers/ThisDay/ThisDay.tsx | 6 +- app/containers/Updates/Updates.tsx | 24 +- app/containers/Updates/UpdatesAdmin.tsx | 59 +- app/data/contracts.ts | 140 + app/data/queryKeys.test.mjs | 39 + app/data/queryKeys.ts | 16 + app/domain/dayInfo/calendar.ts | 68 + app/domain/dayInfo/dailySelections.ts | 190 + app/domain/dayInfo/fasting.ts | 350 + app/domain/dayInfo/feasts.ts | 371 + app/domain/dayInfo/palette.ts | 9 + app/domain/getDayInfo.test.mjs | 272 +- app/domain/getDayInfo.ts | 982 +-- app/forEachPolyfill.js | 4 - app/hooks/useAddSharedVersion.ts | 40 +- app/hooks/useApp.ts | 29 +- app/hooks/useAudio.ts | 74 +- app/hooks/useDay.ts | 30 +- app/hooks/useExternalDay.ts | 19 +- app/hooks/useFilteredSermons.ts | 46 +- app/hooks/useHymns.ts | 26 +- app/hooks/useParts.ts | 17 +- app/hooks/useReading.ts | 39 +- app/hooks/useReadings.ts | 17 +- app/hooks/useSaint.ts | 16 +- app/hooks/useSermon.ts | 16 +- app/hooks/useSermonFacets.ts | 25 +- app/hooks/useSharedService.ts | 48 +- app/hooks/useUpdateTOC.ts | 73 +- app/state/TOCState.ts | 17 +- app/styles/emotion.d.ts | 8 + app/styles/getTheme.ts | 75 +- app/utils/StorageAPIWrapper.ts | 2 +- app/wdyr.js | 8 - docs/modernization/01-foundation-and-ci.md | 103 + docs/modernization/02-performance.md | 148 + .../03-dependencies-and-tooling.md | 182 + .../04-code-quality-and-architecture.md | 150 + docs/modernization/05-test-coverage.md | 177 + docs/modernization/README.md | 199 + docs/modernization/asset-performance.md | 71 + .../modernization/frontend-state-ownership.md | 93 + docs/modernization/performance-report.json | 39 + eslint.config.js | 219 + output/playwright/.gitignore | 2 + package.json | 159 +- playwright.config.ts | 40 + quality-baseline.json | 1802 ++++ scripts/bundle-report.mjs | 112 + scripts/quality-ratchet.mjs | 176 + scripts/run-tests.mjs | 47 + tests/e2e/app.spec.ts | 151 + tsconfig.strict.json | 10 + webpack.base.js | 20 +- webpack.dev.js | 2 +- webpack.prod.js | 2 +- www/assets/books.svg | 2 +- www/assets/icons/annunciation.svg | 2 +- www/assets/icons/ascension.svg | 2 +- www/assets/icons/baptism.svg | 2 +- www/assets/icons/baptist.png | Bin 281664 -> 275898 bytes www/assets/icons/christmas.svg | 2 +- www/assets/icons/exile.svg | 2 +- www/assets/icons/greatLent.jpg | Bin 80203 -> 78991 bytes www/assets/icons/krestovozdvizhenie.svg | 2 +- www/assets/icons/lestvichnik.svg | 2 +- www/assets/icons/mathew.svg | 1244 +-- www/assets/icons/oct30.svg | 2 +- www/assets/icons/passionTuesday.svg | 2 +- www/assets/icons/samar.svg | 419 +- www/assets/icons/transfiguration.png | Bin 323256 -> 313010 bytes www/assets/icons/triumph.svg | 2 +- www/assets/peace.svg | 2 +- yarn.lock | 7740 ++++++++--------- 231 files changed, 10594 insertions(+), 8158 deletions(-) delete mode 100644 .eslintignore delete mode 100644 .eslintrc.cjs create mode 100644 .nvmrc create mode 100644 app/components/RouteErrorBoundary/RouteErrorBoundary.css create mode 100644 app/components/RouteErrorBoundary/RouteErrorBoundary.tsx create mode 100644 app/containers/Service/Texts/mdxContracts.test.mjs create mode 100644 app/data/contracts.ts create mode 100644 app/data/queryKeys.test.mjs create mode 100644 app/data/queryKeys.ts create mode 100644 app/domain/dayInfo/calendar.ts create mode 100644 app/domain/dayInfo/dailySelections.ts create mode 100644 app/domain/dayInfo/fasting.ts create mode 100644 app/domain/dayInfo/feasts.ts create mode 100644 app/domain/dayInfo/palette.ts delete mode 100644 app/forEachPolyfill.js create mode 100644 app/styles/emotion.d.ts delete mode 100644 app/wdyr.js create mode 100644 docs/modernization/01-foundation-and-ci.md create mode 100644 docs/modernization/02-performance.md create mode 100644 docs/modernization/03-dependencies-and-tooling.md create mode 100644 docs/modernization/04-code-quality-and-architecture.md create mode 100644 docs/modernization/05-test-coverage.md create mode 100644 docs/modernization/README.md create mode 100644 docs/modernization/asset-performance.md create mode 100644 docs/modernization/frontend-state-ownership.md create mode 100644 docs/modernization/performance-report.json create mode 100644 eslint.config.js create mode 100644 output/playwright/.gitignore create mode 100644 playwright.config.ts create mode 100644 quality-baseline.json create mode 100644 scripts/bundle-report.mjs create mode 100644 scripts/quality-ratchet.mjs create mode 100644 scripts/run-tests.mjs create mode 100644 tests/e2e/app.spec.ts create mode 100644 tsconfig.strict.json diff --git a/.babelrc b/.babelrc index eadc4af1b3..a4c8c269ee 100755 --- a/.babelrc +++ b/.babelrc @@ -1,4 +1,12 @@ { - "presets": [["@babel/preset-env", { "targets": { "browsers": ["ie >= 11", "safari > 9"] } }], "@babel/preset-react", "@babel/preset-typescript"], - "plugins": ["react-hot-loader/babel", "@babel/plugin-proposal-class-properties", "transform-optional-chaining"] + "presets": [ + [ + "@babel/preset-env", + { + "targets": "> 0.5%, last 2 versions, Firefox ESR, not dead" + } + ], + ["@babel/preset-react", { "runtime": "automatic" }], + "@babel/preset-typescript" + ] } diff --git a/.circleci/config.yml b/.circleci/config.yml index 0d558e5b38..38dc3d89ff 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -1,38 +1,55 @@ -# Javascript Node CircleCI 2.0 configuration file -# -# Check https://circleci.com/docs/2.0/language-javascript/ for more details -# -version: 2 +version: 2.1 + jobs: - build: - docker: - # specify the version you desire here - - image: circleci/node:7.10 - - # Specify service dependencies here if necessary - # CircleCI maintains a library of pre-built images - # documented at https://circleci.com/docs/2.0/circleci-images/ - # - image: circleci/mongo:3.4.4 - - working_directory: ~/repo - - steps: - - checkout - - # Download and cache dependencies - - restore_cache: - keys: - - v1-dependencies-{{ checksum "package.json" }} - # fallback to using the latest cache if no exact match is found - - v1-dependencies- - - - run: yarn install - - - save_cache: - paths: - - node_modules - key: v1-dependencies-{{ checksum "package.json" }} - - # run tests! - - run: yarn test + build: + docker: + - image: cimg/node:22.14.0 + + working_directory: ~/repo + + steps: + - checkout + + - run: + name: Pin Yarn + command: | + corepack enable + corepack prepare yarn@1.22.22 --activate + + - restore_cache: + keys: + - v2-yarn-{{ checksum "package.json" }}-{{ checksum "yarn.lock" }} + - v2-yarn- + + - run: + name: Install dependencies + command: yarn install --frozen-lockfile + + - save_cache: + key: v2-yarn-{{ checksum "package.json" }}-{{ checksum "yarn.lock" }} + paths: + - ~/.cache/yarn + + - run: + name: Test + command: yarn test + + - run: + name: Quality ratchets + command: yarn quality + + - run: + name: Production build + command: | + mkdir -p /tmp/build-reports + yarn build 2>&1 | tee /tmp/build-reports/build-output.txt + + - run: + name: Report artifact size + command: | + node scripts/bundle-report.mjs --out /tmp/build-reports/bundle-report.json + cat /tmp/build-reports/bundle-report.json + - store_artifacts: + path: /tmp/build-reports + destination: build-reports diff --git a/.eslintignore b/.eslintignore deleted file mode 100644 index 44eef6358e..0000000000 --- a/.eslintignore +++ /dev/null @@ -1,21 +0,0 @@ -node_modules -parse/processed -parse/raw -parse/cache -convertBibleQuote/new -convertBibleQuote/bible -/.idea -/electron/app -/android/app -/www/built -/www/service-worker.* -/www/version -/www/index.html -.gradle -TWA/build/ -TWA/app -TWA/assetlinks.json -TWA/local.properties -android.keystore -app-release-* -.idea diff --git a/.eslintrc.cjs b/.eslintrc.cjs deleted file mode 100644 index fe4b95045f..0000000000 --- a/.eslintrc.cjs +++ /dev/null @@ -1,159 +0,0 @@ -/* eslint-disable @typescript-eslint/no-var-requires */ -const fs = require('fs'); -const path = require('path'); - -const prettierOptions = JSON.parse(fs.readFileSync(path.resolve(__dirname, '.prettierrc'), 'utf8')); - -// 0: off, 1: warn, 2: error - -module.exports = { - parser: '@typescript-eslint/parser', - extends: [ - 'standard', - 'standard-jsx', - 'prettier', - 'prettier/react', - 'plugin:eslint-comments/recommended', - 'plugin:import/errors', - 'plugin:import/warnings', - 'plugin:prettier/recommended', - 'plugin:@typescript-eslint/eslint-recommended', - 'plugin:@typescript-eslint/recommended', - ], - plugins: ['@typescript-eslint', 'prettier', 'react', 'react-hooks', 'jsx-a11y', 'promise'], - env: { - jest: true, - browser: true, - node: true, - es6: true, - }, - parserOptions: { - ecmaVersion: 6, - sourceType: 'module', - ecmaFeatures: { - jsx: true, - }, - }, - overrides: [ - { - files: ['*.ts', '*.tsx'], - parser: '@typescript-eslint/parser', - extends: [ - 'standard-with-typescript', - 'plugin:@typescript-eslint/recommended', - 'plugin:@typescript-eslint/recommended-requiring-type-checking', - 'prettier/@typescript-eslint', - ], - plugins: ['@typescript-eslint'], - rules: { - 'no-extra-boolean-cast': 0, - 'comma-dangle': [2, 'only-multiline', { functions: 'never' }], - 'no-return-await': 0, - '@typescript-eslint/interface-name-prefix': 0, - '@typescript-eslint/member-ordering': 0, - '@typescript-eslint/no-unused-vars': 2, - '@typescript-eslint/naming-convention': 0, - '@typescript-eslint/member-delimiter-style': 0, - '@typescript-eslint/strict-boolean-expressions': 0, - '@typescript-eslint/prefer-nullish-coalescing': 0, - '@typescript-eslint/return-await': 0, - '@typescript-eslint/restrict-template-expressions': 0, - '@typescript-eslint/explicit-module-boundary-types': 0, - '@typescript-eslint/explicit-function-return-type': 0, - }, - parserOptions: { - project: 'tsconfig.json', - sourceType: 'module', - }, - }, - ], - settings: { - 'import/resolver': { - node: { - extensions: ['.js', '.jsx', '.ts', '.tsx'], - }, - }, - react: { - version: 'detect', - }, - }, - globals: { - React: 'writable', - JSX: 'writable', - }, - rules: { - 'prettier/prettier': [2, prettierOptions], - 'no-underscore-dangle': 0, - 'arrow-body-style': [2, 'as-needed'], - 'class-methods-use-this': 0, - 'no-multiple-empty-lines': 2, - 'comma-dangle': [2, 'only-multiline', { functions: 'never' }], - 'import/no-unresolved': 2, - 'import/order': [ - 2, - { - 'newlines-between': 'always', - pathGroupsExcludedImportTypes: ['builtin'], - }, - ], - 'jsx-a11y/aria-props': 2, - 'jsx-a11y/heading-has-content': 0, - 'jsx-a11y/label-has-associated-control': [ - 2, - { - // NOTE: If this error triggers, either disable it or add - // your custom components, labels and attributes via these options - // See https://github.com/evcohen/eslint-plugin-jsx-a11y/blob/master/docs/rules/label-has-associated-control.md - controlComponents: ['Input'], - }, - ], - 'jsx-a11y/label-has-for': 0, - 'jsx-a11y/mouse-events-have-key-events': 2, - 'jsx-a11y/role-has-required-aria-props': 2, - 'jsx-a11y/role-supports-aria-props': 2, - 'max-len': 0, - 'newline-per-chained-call': 0, - 'no-confusing-arrow': 0, - 'no-console': 1, - 'no-debugger': 1, - 'no-extra-boolean-cast': 0, - 'no-nested-ternary': 0, - 'no-param-reassign': 2, - 'no-shadow': 0, - 'no-var': 2, - 'no-void': [2, { allowAsStatement: true }], - 'no-empty': 2, - 'no-empty-function': 2, - 'no-unused-vars': [2, { argsIgnorePattern: '^_', args: 'after-used', ignoreRestSiblings: true }], - 'no-use-before-define': 0, - 'prefer-template': 2, - quotes: [2, 'single', { avoidEscape: true, allowTemplateLiterals: false }], - 'react/destructuring-assignment': 0, - 'react/jsx-closing-tag-location': 0, - 'react/forbid-prop-types': 0, - 'react/jsx-boolean-value': 0, - 'react/jsx-first-prop-new-line': [2, 'multiline'], - 'react/jsx-filename-extension': 0, - 'react/jsx-handler-names': 0, - 'react/jsx-no-target-blank': 0, - 'react/jsx-uses-vars': 2, - 'react/require-default-props': 0, - 'react/require-extension': 0, - 'react/self-closing-comp': 0, - 'react/sort-comp': 0, - 'react/react-in-jsx-scope': 0, - 'require-yield': 0, - 'max-classes-per-file': 0, - 'react/static-property-placement': 0, - 'react/state-in-constructor': 0, - 'react/jsx-props-no-spreading': 0, - 'react/display-name': 0, - 'sort-keys': 0, - 'no-restricted-globals': 0, - camelcase: [2, { allow: ['^UNSAFE_', '_UNSTABLE'] }], - 'eslint-comments/disable-enable-pair': 0, - 'node/no-exports-assign': 0, - 'arrow-body-style': 0, - }, -}; -/* eslint-enable @typescript-eslint/no-var-requires */ diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000000..7d41c735d7 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +22.14.0 diff --git a/README.md b/README.md index 6163b098ff..403731fb3b 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,46 @@ # Orthodox Calendar -## Installation +## Frontend setup -First get this up and running (see its readme): -https://github.com/dimaip/calendar-backend - -Then: +The supported frontend toolchain is Node 22.14.0 and Yarn 1.22.22. With `nvm`: +```sh +nvm use +corepack enable +corepack prepare yarn@1.22.22 --activate +yarn install --frozen-lockfile ``` -git clone https://github.com/dimaip/calendar -cd calendar -yarn + +Start the backend described in the +[calendar-backend README](https://github.com/dimaip/calendar-backend), then run: + +```sh yarn start ``` -Or `yarn build` for production build. +## Verification + +```sh +yarn test +yarn quality +yarn build +yarn analyze:bundle +``` + +`yarn test` discovers every frontend `*.test.mjs` file below `app`. The full historical +lint and TypeScript backlogs remain available through `yarn lint`; `yarn quality` +uses per-file diagnostic ratchets so that existing debt can decrease without +blocking unrelated work. Run `yarn quality:baseline` only when intentionally +accepting a reviewed baseline change. + +`yarn analyze:bundle` reads the latest production build and reports initial raw +and gzip bytes, total JavaScript, and the generated precache footprint. + +## Browser support + +The web build targets maintained browsers with more than 0.5% global usage, the +latest two releases, and Firefox ESR. Browsers that their vendors no longer +support are excluded. This matches React 18's modern-browser baseline and avoids +shipping legacy ES5 polyfills in every initial load. Capacitor and TWA targets +remain unchanged and are verified through the production build and offline smoke +suite. diff --git a/app/Routes.tsx b/app/Routes.tsx index 0a3695f204..e474c28623 100644 --- a/app/Routes.tsx +++ b/app/Routes.tsx @@ -1,5 +1,5 @@ -import React from 'react'; -import { Route, Switch, Redirect, useHistory, useParams } from 'react-router-dom'; +import React, { Suspense, useEffect, useRef } from 'react'; +import { Navigate, Route, Routes as RouterRoutes, useLocation, useParams } from 'react-router-dom'; import Main from 'containers/Main/Main'; import NotFound from 'components/NotFound/NotFound'; import Readings from 'containers/Readings/Readings'; @@ -8,11 +8,10 @@ import Sermon from 'containers/Sermon/Sermon'; import Saint from 'containers/Saint/Saint'; import ThisDay from 'containers/ThisDay/ThisDay'; import Service from 'containers/Service/Service'; -import { Global, css as rcss } from '@emotion/react'; +import { Global, ThemeProvider, css as rcss } from '@emotion/react'; +import { css } from '@emotion/css'; import useDay from 'hooks/useDay'; import getTheme from 'styles/getTheme'; -import { ThemeProvider } from 'emotion-theming'; -import { css } from 'emotion'; import langState from 'state/langState'; import { useRecoilValue, useSetRecoilState } from 'recoil'; import { LangContext } from 'containers/Service/LangContext'; @@ -20,22 +19,42 @@ import pendingUpdateState from 'state/pendingUpdateState'; import UpdatePrompt from 'components/UpdatePrompt/UpdatePrompt'; import isParallelState from 'state/isParallel'; import { Promo } from 'components/Promo/Promo'; -import { Hymns } from 'containers/Hymns/Hymns'; -import { Hymn } from 'containers/Hymns/Hymn'; -import Profile from 'containers/Profile/Profile'; -import AddSharedVersion from 'containers/AddSharedVersion/AddSharedVersion'; import Loader from 'components/Loader/Loader'; import themeState from 'state/themeState'; -import SermonListContainer from 'containers/SermonList/SermonList'; -import SermonDetail from 'containers/SermonDetail/SermonDetail'; import SettingsMenu from 'containers/Main/SettingsMenu'; -import Updates from 'containers/Updates/Updates'; -import UpdatesAdmin from 'containers/Updates/UpdatesAdmin'; import checkVersion from './checkVersion'; +const Hymns = React.lazy(async () => { + const module = await import(/* webpackChunkName: "route-hymns" */ 'containers/Hymns/Hymns'); + return { default: module.Hymns }; +}); +const Hymn = React.lazy(async () => { + const module = await import(/* webpackChunkName: "route-hymns" */ 'containers/Hymns/Hymn'); + return { default: module.Hymn }; +}); +const Profile = React.lazy( + async () => await import(/* webpackChunkName: "route-profile" */ 'containers/Profile/Profile') +); +const AddSharedVersion = React.lazy( + async () => + await import(/* webpackChunkName: "route-shared-version" */ 'containers/AddSharedVersion/AddSharedVersion') +); +const SermonListContainer = React.lazy( + async () => await import(/* webpackChunkName: "route-sermons" */ 'containers/SermonList/SermonList') +); +const SermonDetail = React.lazy( + async () => await import(/* webpackChunkName: "route-sermons" */ 'containers/SermonDetail/SermonDetail') +); +const Updates = React.lazy( + async () => await import(/* webpackChunkName: "route-updates" */ 'containers/Updates/Updates') +); +const UpdatesAdmin = React.lazy( + async () => await import(/* webpackChunkName: "route-updates-admin" */ 'containers/Updates/UpdatesAdmin') +); + const DateRoutes = () => { - const { date } = useParams(); + const { date = '' } = useParams<'date'>(); const themeStateValue = useRecoilValue(themeState); const tomorrowDateObj = new Date(date); @@ -49,35 +68,17 @@ const DateRoutes = () => { return ( - - -
- - -
- - - - - - - - - - - - - - - - - - - - - - - + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + {/* */} @@ -85,16 +86,29 @@ const DateRoutes = () => { ); }; -export default () => { +const Routes = () => { const langStateValue = useRecoilValue(langState); - const history = useHistory(); + const location = useLocation(); + const previousLocationKey = useRef(location.key); const setPendingUpdate = useSetRecoilState(pendingUpdateState); - history.listen(async () => { - const newVersion = await checkVersion(); - if (newVersion) { - setPendingUpdate(newVersion); + + useEffect(() => { + if (previousLocationKey.current === location.key) { + return undefined; } - }); + previousLocationKey.current = location.key; + + let active = true; + void checkVersion().then((newVersion) => { + if (active && newVersion) { + setPendingUpdate(newVersion); + } + }); + + return () => { + active = false; + }; + }, [location.key, setPendingUpdate]); const isParallel = useRecoilValue(isParallelState); const themeStateValue = useRecoilValue(themeState); const theme = getTheme(undefined, themeStateValue); @@ -125,54 +139,32 @@ export default () => { `} > - - { - const date = dateFormat(new Date(), 'yyyy-mm-dd'); - return ; - }} - /> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + }> + + + } + /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + ); }; + +export default Routes; diff --git a/app/client.tsx b/app/client.tsx index dd2860236f..b35883f19f 100644 --- a/app/client.tsx +++ b/app/client.tsx @@ -1,14 +1,7 @@ // Signals to script in index.html that the app assets have been loaded -// import './wdyr'; -import './forEachPolyfill'; -import 'array-flat-polyfill'; -import 'unfetch/polyfill/index.js'; -import 'element-closest-polyfill'; -import 'regenerator-runtime/runtime.js'; import './sharePolyfill'; import React from 'react'; import { createRoot } from 'react-dom/client'; -import { AppContainer } from 'react-hot-loader'; import App from 'containers/App'; import { RecoilRoot } from 'recoil'; import TagManager from 'react-gtm-module'; @@ -21,7 +14,6 @@ import Worker from './precache.worker.js'; import './redirectToHome'; import { isCapacitor } from 'utils/deviceInfo'; import precache from 'precache.ts'; -import { SyncWithDB } from 'containers/RecoilSync'; window.APP_LOADED = true; const isProd = process.env.NODE_ENV === 'production'; @@ -46,33 +38,13 @@ if (isProd) { }); } -let preloadedState = {}; - -try { - preloadedState = localStorage.getItem('persistedState') ? JSON.parse(localStorage.getItem('persistedState')) : {}; -} catch (e) { - console.warn(e); -} - const rootElement = document.getElementById('react-root'); const root = createRoot(rootElement); -const render = (Component) => { - return root.render( - - - - - - ); -}; - -render(App); - -if (module.hot) { - module.hot.accept('containers/App', () => { - render(App); - }); -} +root.render( + + + +); if (isProd) { serviceWorker.register(); diff --git a/app/components/BottomNav/BottomNav.tsx b/app/components/BottomNav/BottomNav.tsx index 97237bd2cd..5845871fcc 100644 --- a/app/components/BottomNav/BottomNav.tsx +++ b/app/components/BottomNav/BottomNav.tsx @@ -1,7 +1,7 @@ import React from 'react'; -import { css } from 'emotion'; +import { css } from '@emotion/css'; import { Link, useParams } from 'react-router-dom'; -import { useTheme } from 'emotion-theming'; +import { useTheme } from '@emotion/react'; import CrossIcon from 'components/svgs/CrossIcon'; import Bible from 'components/svgs/Bible'; import Prayer from 'components/svgs/Prayer'; diff --git a/app/components/Button/Button.tsx b/app/components/Button/Button.tsx index 3e280c2408..f97b527579 100644 --- a/app/components/Button/Button.tsx +++ b/app/components/Button/Button.tsx @@ -1,5 +1,5 @@ import React, { forwardRef } from 'react'; -import { css } from 'emotion'; +import { css } from '@emotion/css'; const Button = forwardRef((props, ref) => ( - - - ); }; diff --git a/app/components/RouteErrorBoundary/RouteErrorBoundary.css b/app/components/RouteErrorBoundary/RouteErrorBoundary.css new file mode 100644 index 0000000000..0a89f24cc9 --- /dev/null +++ b/app/components/RouteErrorBoundary/RouteErrorBoundary.css @@ -0,0 +1,4 @@ +.route-error-boundary__button:focus { + outline: 2px solid currentColor; + outline-offset: 3px; +} diff --git a/app/components/RouteErrorBoundary/RouteErrorBoundary.tsx b/app/components/RouteErrorBoundary/RouteErrorBoundary.tsx new file mode 100644 index 0000000000..6e02368c1d --- /dev/null +++ b/app/components/RouteErrorBoundary/RouteErrorBoundary.tsx @@ -0,0 +1,127 @@ +import React, { Component, createRef } from 'react'; +import * as Sentry from '@sentry/react'; +import { useLocation } from 'react-router-dom'; + +import './RouteErrorBoundary.css'; + +const pageStyles: React.CSSProperties = { + alignItems: 'center', + boxSizing: 'border-box', + display: 'flex', + justifyContent: 'center', + minHeight: '100vh', + padding: '24px', +}; + +const messageStyles: React.CSSProperties = { + fontFamily: "'PTRootUI', 'Helvetica', sans-serif", + maxWidth: '520px', +}; + +const headingStyles: React.CSSProperties = { + fontSize: '24px', + marginBottom: '12px', +}; + +const actionsStyles: React.CSSProperties = { + display: 'flex', + flexWrap: 'wrap', + gap: '12px', + marginTop: '24px', +}; + +const buttonStyles: React.CSSProperties = { + border: '1px solid currentColor', + borderRadius: '4px', + cursor: 'pointer', + font: 'inherit', + padding: '10px 14px', +}; + +interface ApplicationErrorBoundaryProps { + children: React.ReactNode; + resetKey: string; +} + +interface ApplicationErrorBoundaryState { + error: Error | null; +} + +class ApplicationErrorBoundary extends Component { + state: ApplicationErrorBoundaryState = { + error: null, + }; + + private readonly headingRef = createRef(); + + static getDerivedStateFromError(error: Error): ApplicationErrorBoundaryState { + return { error }; + } + + componentDidCatch(error: Error, errorInfo: React.ErrorInfo): void { + Sentry.captureException?.(error, { + extra: { + componentStack: errorInfo.componentStack, + }, + }); + this.headingRef.current?.focus(); + } + + componentDidUpdate(previousProps: ApplicationErrorBoundaryProps): void { + if (this.state.error && previousProps.resetKey !== this.props.resetKey) { + this.setState({ error: null }); + } + } + + private readonly retry = (): void => { + this.setState({ error: null }); + }; + + private readonly reload = (): void => { + window.location.reload(); + }; + + render(): React.ReactNode { + if (!this.state.error) { + return this.props.children; + } + + return ( +
+
+

+ Что-то пошло не так +

+

Не удалось открыть эту страницу. Можно попробовать ещё раз или перезагрузить приложение.

+
+ + +
+
+
+ ); + } +} + +const RouteErrorBoundary = ({ children }: { children: React.ReactNode }) => { + const location = useLocation(); + const resetKey = `${location.key}:${location.pathname}${location.search}${location.hash}`; + + return {children}; +}; + +export default RouteErrorBoundary; diff --git a/app/components/RteText/RteText.tsx b/app/components/RteText/RteText.tsx index 34722870b5..ac1aaa8c73 100644 --- a/app/components/RteText/RteText.tsx +++ b/app/components/RteText/RteText.tsx @@ -1,10 +1,15 @@ import React, { useRef } from 'react'; -import { css } from 'emotion'; -import { useTheme } from 'emotion-theming'; +import { css } from '@emotion/css'; +import { useTheme } from '@emotion/react'; import useAudio from 'hooks/useAudio'; -const RteText = React.forwardRef(({ html = '', className = '' }, ref) => { - const localRef = useRef(); +interface RteTextProps { + html?: string; + className?: string; +} + +const RteText = React.forwardRef(({ html = '', className = '' }, ref) => { + const localRef = useRef(null); const effectiveRef = ref || localRef; const theme = useTheme(); const htmlWithStrongSlashes = html diff --git a/app/components/ScriptEditor/ScriptEditorInput.tsx b/app/components/ScriptEditor/ScriptEditorInput.tsx index 0270f5e35c..1f58d01277 100644 --- a/app/components/ScriptEditor/ScriptEditorInput.tsx +++ b/app/components/ScriptEditor/ScriptEditorInput.tsx @@ -1,6 +1,6 @@ import React, { useState } from 'react'; -import { css } from 'emotion'; -import { useTheme } from 'emotion-theming'; +import { css } from '@emotion/css'; +import { useTheme } from '@emotion/react'; import Cross from 'components/svgs/Cross'; import Button from 'components/Button/Button'; import Pencil from 'components/svgs/Pencil'; diff --git a/app/components/ScriptEditor/ScriptEditorToggle.tsx b/app/components/ScriptEditor/ScriptEditorToggle.tsx index bbd6ce5be5..e720ab196a 100644 --- a/app/components/ScriptEditor/ScriptEditorToggle.tsx +++ b/app/components/ScriptEditor/ScriptEditorToggle.tsx @@ -1,12 +1,12 @@ import React from 'react'; import Button from 'components/Button/Button'; -import { css } from 'emotion'; +import { css } from '@emotion/css'; import { useRecoilState } from 'recoil'; import scriptEditorIsActiveState from 'state/scriptEditorIsActiveState'; import scriptVersionsState from 'state/scriptVersionsState'; import currentScriptVersionState from 'state/currentScriptVersion'; import Cross from 'components/svgs/Cross'; -import { useTheme } from 'emotion-theming'; +import { useTheme } from '@emotion/react'; import Pencil from 'components/svgs/Pencil'; const ScriptEditorToggle = ({ serviceId }) => { diff --git a/app/components/ScriptEditorPromo/ScriptEditorPromo.tsx b/app/components/ScriptEditorPromo/ScriptEditorPromo.tsx index 50422c41ac..9aef928c7e 100644 --- a/app/components/ScriptEditorPromo/ScriptEditorPromo.tsx +++ b/app/components/ScriptEditorPromo/ScriptEditorPromo.tsx @@ -1,14 +1,14 @@ import React from 'react'; -import { css } from 'emotion'; -import Button from '@material-ui/core/Button'; -import { useTheme } from 'emotion-theming'; +import { css } from '@emotion/css'; +import Button from '@mui/material/Button'; +import { useTheme } from '@emotion/react'; import { useRecoilState } from 'recoil'; import TagManager from 'react-gtm-module'; import scriptEditorPromoDismissedState from 'state/scriptEditorPromoDismissedState'; -import { useHistory, useParams } from 'react-router-dom'; +import { useNavigate, useParams } from 'react-router-dom'; export const ScriptEditorPromo = (): JSX.Element | null => { - const history = useHistory(); + const navigate = useNavigate(); const { date } = useParams(); const theme = useTheme(); @@ -68,7 +68,7 @@ export const ScriptEditorPromo = (): JSX.Element | null => { setTimeout(() => { setPrompoDismissed(true); }, 0); - history.push(`/date/${date}/service/${new Date().getHours() < 13 ? 'matins' : 'vespers'}`); + void navigate(`/date/${date}/service/${new Date().getHours() < 13 ? 'matins' : 'vespers'}`); }} size="large" style={{ diff --git a/app/components/ScriptVersionSelector/DuplicateButton.tsx b/app/components/ScriptVersionSelector/DuplicateButton.tsx index 3c6910b8c8..431b3ca311 100644 --- a/app/components/ScriptVersionSelector/DuplicateButton.tsx +++ b/app/components/ScriptVersionSelector/DuplicateButton.tsx @@ -1,6 +1,6 @@ import React, { useContext } from 'react'; -import { css } from 'emotion'; -import { useTheme } from 'emotion-theming'; +import { css } from '@emotion/css'; +import { useTheme } from '@emotion/react'; import Button from 'components/Button/Button'; import { Version } from 'state/scriptVersionsState'; import Duplicate from 'components/svgs/Duplicate'; diff --git a/app/components/ScriptVersionSelector/ScriptVersion.tsx b/app/components/ScriptVersionSelector/ScriptVersion.tsx index a2ba1507e7..06db1227d6 100644 --- a/app/components/ScriptVersionSelector/ScriptVersion.tsx +++ b/app/components/ScriptVersionSelector/ScriptVersion.tsx @@ -1,7 +1,7 @@ import scriptVersionsState, { Version } from 'state/scriptVersionsState'; import React, { useState } from 'react'; -import { css } from 'emotion'; -import { useTheme } from 'emotion-theming'; +import { css } from '@emotion/css'; +import { useTheme } from '@emotion/react'; import { useRecoilState } from 'recoil'; import { useSession } from 'containers/AuthProvider'; import Button from 'components/Button/Button'; diff --git a/app/components/ScriptVersionSelector/ScriptVersionSelector.tsx b/app/components/ScriptVersionSelector/ScriptVersionSelector.tsx index 10a02b8ff7..5b68737e82 100644 --- a/app/components/ScriptVersionSelector/ScriptVersionSelector.tsx +++ b/app/components/ScriptVersionSelector/ScriptVersionSelector.tsx @@ -1,6 +1,6 @@ import React, { useState } from 'react'; -import { css } from 'emotion'; -import { useTheme } from 'emotion-theming'; +import { css } from '@emotion/css'; +import { useTheme } from '@emotion/react'; import { useRecoilState } from 'recoil'; import Button from 'components/Button/Button'; import Drawer from 'components/Drawer/Drawer'; diff --git a/app/components/ScrollRestoration/ScrollRestoration.tsx b/app/components/ScrollRestoration/ScrollRestoration.tsx index 0dbe4962a4..1579e525fd 100644 --- a/app/components/ScrollRestoration/ScrollRestoration.tsx +++ b/app/components/ScrollRestoration/ScrollRestoration.tsx @@ -1,5 +1,5 @@ -import React, { useEffect, memo, useRef } from 'react'; -import { withRouter, RouteComponentProps } from 'react-router-dom'; +import React, { useEffect, useLayoutEffect, useRef } from 'react'; +import { useLocation } from 'react-router-dom'; export const scrollTo = (scrollnumber = 0): number => window.requestAnimationFrame(() => { @@ -14,57 +14,49 @@ export const getScrollPage = (): number => { return window.pageYOffset || docScrollTop; }; -interface Props { - visitedUrl: Map; -} +export default function ScrollRestoration(): null { + const location = useLocation(); + const locationRef = useRef(location); + const previousLocationRef = useRef(location); + const visitedUrlRef = useRef(new Map()); + + useLayoutEffect(() => { + const previousLocation = previousLocationRef.current; + const locationChanged = + (location.pathname !== previousLocation.pathname || location.search !== previousLocation.search) && + location.hash === ''; + + if (locationChanged) { + visitedUrlRef.current.set(previousLocation.pathname, getScrollPage()); + } -function ScrollRestoration({ history, visitedUrl }: RouteComponentProps & Props): null { - const handlePopStateChange = (): void => { - setTimeout(() => { - const { location } = history; - const { pathname } = location; - const existingRecord = visitedUrl.get(pathname); - scrollTo(existingRecord || 0); - }, 100); - }; + locationRef.current = location; + previousLocationRef.current = location; + }, [location]); useEffect(() => { + const pendingTimeouts = new Set(); + const handlePopStateChange = (): void => { + const timeoutId = window.setTimeout(() => { + pendingTimeouts.delete(timeoutId); + const existingRecord = visitedUrlRef.current.get(locationRef.current.pathname); + scrollTo(existingRecord || 0); + }, 100); + pendingTimeouts.add(timeoutId); + }; + window.addEventListener('popstate', handlePopStateChange); window.addEventListener('pushstate', handlePopStateChange); window.addEventListener('replacestate', handlePopStateChange); + return () => { window.removeEventListener('popstate', handlePopStateChange); window.removeEventListener('pushstate', handlePopStateChange); window.removeEventListener('replacestate', handlePopStateChange); + pendingTimeouts.forEach((timeoutId) => window.clearTimeout(timeoutId)); + pendingTimeouts.clear(); }; }, []); return null; } - -const ScrollRestorationWithRouter = withRouter( - memo(ScrollRestoration, (prevProps, nextProps) => { - const { location: prevLoaction, visitedUrl } = prevProps; - const { location: nextLoaction } = nextProps; - - const key = prevLoaction.pathname; - - const locationChanged = - (nextLoaction.pathname !== prevLoaction.pathname || nextLoaction.search !== prevLoaction.search) && - nextLoaction.hash === ''; - - const scroll = getScrollPage(); - - if (locationChanged) { - visitedUrl.set(key, scroll); - } - - return false; - }) -); - -export default function Wrapper(): JSX.Element { - const visitedUrl = useRef(new Map()); - - return ; -} diff --git a/app/components/SelectBox/SelectBox.tsx b/app/components/SelectBox/SelectBox.tsx index d696941ade..ae1763eed2 100644 --- a/app/components/SelectBox/SelectBox.tsx +++ b/app/components/SelectBox/SelectBox.tsx @@ -1,11 +1,11 @@ import React from 'react'; import ReactDOM from 'react-dom'; -import { css } from 'emotion'; -import { useTheme } from 'emotion-theming'; +import { css } from '@emotion/css'; +import { useTheme } from '@emotion/react'; import { useSelect } from 'downshift'; import Loader from 'components/Loader/Loader'; -import ExpandLess from '@material-ui/icons/ExpandLess'; -import ExpandMore from '@material-ui/icons/ExpandMore'; +import ExpandLess from '@mui/icons-material/ExpandLess'; +import ExpandMore from '@mui/icons-material/ExpandMore'; import { truncateText } from 'utils/truncateText'; import { truthy } from 'utils/truthy'; import Cross from 'components/svgs/Cross'; diff --git a/app/components/SermonList/SermonList.tsx b/app/components/SermonList/SermonList.tsx index 6b7a2244b6..a649805a84 100644 --- a/app/components/SermonList/SermonList.tsx +++ b/app/components/SermonList/SermonList.tsx @@ -1,6 +1,6 @@ import React, { useState, useEffect } from 'react'; -import { css } from 'emotion'; -import { useTheme } from 'emotion-theming'; +import { css } from '@emotion/css'; +import { useTheme } from '@emotion/react'; import Loader from 'components/Loader/Loader'; import ErrorMessage500 from 'components/ErrorMessage500/ErrorMessage500'; import useFilteredSermons from 'hooks/useFilteredSermons'; @@ -26,7 +26,7 @@ export const SermonList = ({ authorId, themeId, limit }: { authorId?: string; th limit ? undefined : offset ); - const isLoading = sermonsStatus === 'loading' && offset === 0; + const isLoading = sermonsStatus === 'pending' && offset === 0; const isError = sermonsStatus === 'error'; // Reset when filters change diff --git a/app/components/SermonSmall/SermonSmall.tsx b/app/components/SermonSmall/SermonSmall.tsx index d6bf7d7660..91a53d357c 100644 --- a/app/components/SermonSmall/SermonSmall.tsx +++ b/app/components/SermonSmall/SermonSmall.tsx @@ -2,8 +2,8 @@ import React from 'react'; import ButtonBox from 'components/ButtonBox/ButtonBox'; import RightIcon from 'components/svgs/RightIcon'; import SwipeableLink from 'components/SwipeableLink/SwipeableLink'; -import { css } from 'emotion'; -import { useTheme } from 'emotion-theming'; +import { css } from '@emotion/css'; +import { useTheme } from '@emotion/react'; import { useLocation } from 'react-router-dom'; export interface Sermon { @@ -18,13 +18,7 @@ export const SermonSmall = ({ sermon, horizontal }: { sermon: Sermon; horizontal const theme = useTheme(); const location = useLocation(); return ( - + { const { toggleOpen } = useContext(DotsMenuContext); - const history = useHistory(); + const navigate = useNavigate(); return (