diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro index 2e46c3330c811..08365dce4c9e9 100644 --- a/android/app/proguard-rules.pro +++ b/android/app/proguard-rules.pro @@ -47,4 +47,7 @@ -dontwarn org.jmrtd.protocol.BACResult -dontwarn org.jmrtd.protocol.PACEResult -dontwarn org.spongycastle.jce.provider.BouncyCastleProvider --dontwarn org.slf4j.impl.StaticLoggerBinder \ No newline at end of file +-dontwarn org.slf4j.impl.StaticLoggerBinder + +# https://shopify.github.io/react-native-skia/docs/getting-started/installation/#proguard +-keep class com.shopify.reactnative.skia.** { *; } diff --git a/config/webpack/webpack.common.ts b/config/webpack/webpack.common.ts index 93bae202d1cca..5f90f9703a91a 100644 --- a/config/webpack/webpack.common.ts +++ b/config/webpack/webpack.common.ts @@ -59,6 +59,8 @@ const includeModules = [ 'expo-video', 'expo-image-manipulator', 'expo-modules-core', + 'victory-native', + '@shopify/react-native-skia', ].join('|'); const environmentToLogoSuffixMap: Record = { @@ -158,6 +160,9 @@ const getCommonConfiguration = ({file = '.env', platform = 'web'}: Environment): // Group‑IB web SDK injection file {from: 'web/snippets/gib.js', to: 'gib.js'}, + + // CanvasKit WASM files for @shopify/react-native-skia web support (uses full version) + {from: 'node_modules/canvaskit-wasm/bin/full/canvaskit.wasm'}, ], }), new webpack.EnvironmentPlugin({JEST_WORKER_ID: ''}), @@ -236,7 +241,7 @@ const getCommonConfiguration = ({file = '.env', platform = 'web'}: Environment): * You can remove something from this list if it doesn't use "react-native" as an import and it doesn't * use JSX/JS that needs to be transformed by babel. */ - exclude: [new RegExp(`node_modules/(?!(${includeModules})/).*|.native.js$`)], + exclude: [new RegExp(`node_modules/(?!(${includeModules})/).*|\\.native\\.(js|jsx|ts|tsx)$`)], }, // We are importing this worker as a string by using asset/source otherwise it will default to loading via an HTTPS request later. // This causes issues if we have gone offline before the pdfjs web worker is set up as we won't be able to load it from the server. @@ -307,6 +312,10 @@ const getCommonConfiguration = ({file = '.env', platform = 'web'}: Environment): lodash: 'lodash-es', 'react-native-config': 'react-web-config', 'react-native$': 'react-native-web', + // Use victory-native source files instead of pre-compiled dist (which uses CommonJS exports) + 'victory-native': path.resolve(dirname, '../../node_modules/victory-native/src/index.ts'), + // Required for @shopify/react-native-skia web support + 'react-native/Libraries/Image/AssetRegistry': false, // Module alias for web // https://webpack.js.org/configuration/resolve/#resolvealias '@assets': path.resolve(dirname, '../../assets'), @@ -330,6 +339,8 @@ const getCommonConfiguration = ({file = '.env', platform = 'web'}: Environment): fallback: { 'process/browser': require.resolve('process/browser'), crypto: false, + fs: false, + path: false, }, }, diff --git a/cspell.json b/cspell.json index 65d5c9e31c71e..e76bfe1b3ff60 100644 --- a/cspell.json +++ b/cspell.json @@ -820,6 +820,8 @@ "Wooo", "Splittable", "pgrep", + "skia", + "canvaskit", "Invoicify" ], "ignorePaths": [ diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 3904ba6f808f6..352247d71731c 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -32,7 +32,7 @@ PODS: - EXAV (15.1.7): - ExpoModulesCore - ReactCommon/turbomodule/core - - EXConstants (18.0.9): + - EXConstants (18.0.10): - ExpoModulesCore - EXImageLoader (5.1.0): - ExpoModulesCore @@ -173,10 +173,13 @@ PODS: - Yoga - ExpoSecureStore (14.2.4): - ExpoModulesCore + - ExpoStoreReview (9.0.9): + - ExpoModulesCore - ExpoVideo (3.0.12): - ExpoModulesCore - - ExpoStoreReview (9.0.8): + - EXTaskManager (14.0.9): - ExpoModulesCore + - UMAppLoader - fast_float (8.0.0) - FBLazyVector (0.81.4) - Firebase/Analytics (11.13.0): @@ -530,6 +533,7 @@ PODS: - React-jsinspectorcdp - React-jsitooling - React-perflogger + - React-rendererconsistency - React-runtimeexecutor - React-runtimescheduler - React-utils @@ -555,6 +559,7 @@ PODS: - React-jsinspectorcdp - React-jsitooling - React-perflogger + - React-rendererconsistency - React-runtimeexecutor - React-runtimescheduler - React-utils @@ -579,6 +584,7 @@ PODS: - React-jsinspectorcdp - React-jsitooling - React-perflogger + - React-rendererconsistency - React-runtimeexecutor - React-runtimescheduler - React-utils @@ -605,6 +611,7 @@ PODS: - React-jsinspectorcdp - React-jsitooling - React-perflogger + - React-rendererconsistency - React-runtimeexecutor - React-runtimescheduler - React-utils @@ -630,6 +637,7 @@ PODS: - React-jsinspectorcdp - React-jsitooling - React-perflogger + - React-rendererconsistency - React-runtimeexecutor - React-runtimescheduler - React-utils @@ -655,6 +663,7 @@ PODS: - React-jsinspectorcdp - React-jsitooling - React-perflogger + - React-rendererconsistency - React-runtimeexecutor - React-runtimescheduler - React-utils @@ -680,6 +689,7 @@ PODS: - React-jsinspectorcdp - React-jsitooling - React-perflogger + - React-rendererconsistency - React-runtimeexecutor - React-runtimescheduler - React-utils @@ -705,6 +715,7 @@ PODS: - React-jsinspectorcdp - React-jsitooling - React-perflogger + - React-rendererconsistency - React-runtimeexecutor - React-runtimescheduler - React-utils @@ -730,6 +741,7 @@ PODS: - React-jsinspectorcdp - React-jsitooling - React-perflogger + - React-rendererconsistency - React-runtimeexecutor - React-runtimescheduler - React-utils @@ -755,6 +767,7 @@ PODS: - React-jsinspectorcdp - React-jsitooling - React-perflogger + - React-rendererconsistency - React-runtimeexecutor - React-runtimescheduler - React-utils @@ -780,6 +793,7 @@ PODS: - React-jsinspectorcdp - React-jsitooling - React-perflogger + - React-rendererconsistency - React-runtimeexecutor - React-runtimescheduler - React-utils @@ -805,6 +819,7 @@ PODS: - React-jsinspectorcdp - React-jsitooling - React-perflogger + - React-rendererconsistency - React-runtimeexecutor - React-runtimescheduler - React-utils @@ -830,6 +845,7 @@ PODS: - React-jsinspectorcdp - React-jsitooling - React-perflogger + - React-rendererconsistency - React-runtimeexecutor - React-runtimescheduler - React-utils @@ -855,6 +871,7 @@ PODS: - React-jsinspectorcdp - React-jsitooling - React-perflogger + - React-rendererconsistency - React-runtimeexecutor - React-runtimescheduler - React-utils @@ -2784,6 +2801,36 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga + - react-native-skia (2.4.14): + - boost + - DoubleConversion + - fast_float + - fmt + - glog + - hermes-engine + - RCT-Folly + - RCT-Folly/Fabric + - RCTRequired + - RCTTypeSafety + - React + - React-callinvoker + - React-Core + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - SocketRocket + - Yoga - react-native-view-shot (4.0.0): - boost - DoubleConversion @@ -4111,6 +4158,7 @@ PODS: - SocketRocket (0.7.1) - Turf (2.8.0) - TweetNacl (1.0.2) + - UMAppLoader (6.0.8) - VisionCamera (4.7.2): - VisionCamera/Core (= 4.7.2) - VisionCamera/React (= 4.7.2) @@ -4137,8 +4185,9 @@ DEPENDENCIES: - ExpoLocation (from `../node_modules/expo-location/ios`) - ExpoModulesCore (from `../node_modules/expo-modules-core`) - ExpoSecureStore (from `../node_modules/expo-secure-store/ios`) - - ExpoVideo (from `../node_modules/expo-video/ios`) - ExpoStoreReview (from `../node_modules/expo-store-review/ios`) + - ExpoVideo (from `../node_modules/expo-video/ios`) + - EXTaskManager (from `../node_modules/expo-task-manager/ios`) - fast_float (from `../node_modules/react-native/third-party-podspecs/fast_float.podspec`) - FBLazyVector (from `../node_modules/react-native/Libraries/FBLazyVector`) - fmt (from `../node_modules/react-native/third-party-podspecs/fmt.podspec`) @@ -4205,6 +4254,7 @@ DEPENDENCIES: - react-native-plaid-link-sdk (from `../node_modules/react-native-plaid-link-sdk`) - react-native-release-profiler (from `../node_modules/react-native-release-profiler`) - react-native-safe-area-context (from `../node_modules/react-native-safe-area-context`) + - "react-native-skia (from `../node_modules/@shopify/react-native-skia`)" - react-native-view-shot (from `../node_modules/react-native-view-shot`) - "react-native-wallet (from `../node_modules/@expensify/react-native-wallet`)" - react-native-webview (from `../node_modules/react-native-webview`) @@ -4264,6 +4314,7 @@ DEPENDENCIES: - RNSVG (from `../node_modules/react-native-svg`) - RNWorklets (from `../node_modules/react-native-worklets`) - SocketRocket (~> 0.7.1) + - UMAppLoader (from `../node_modules/unimodules-app-loader/ios`) - VisionCamera (from `../node_modules/react-native-vision-camera`) - Yoga (from `../node_modules/react-native/ReactCommon/yoga`) @@ -4352,10 +4403,12 @@ EXTERNAL SOURCES: :path: "../node_modules/expo-modules-core" ExpoSecureStore: :path: "../node_modules/expo-secure-store/ios" - ExpoVideo: - :path: "../node_modules/expo-video/ios" ExpoStoreReview: :path: "../node_modules/expo-store-review/ios" + ExpoVideo: + :path: "../node_modules/expo-video/ios" + EXTaskManager: + :path: "../node_modules/expo-task-manager/ios" fast_float: :podspec: "../node_modules/react-native/third-party-podspecs/fast_float.podspec" FBLazyVector: @@ -4485,6 +4538,8 @@ EXTERNAL SOURCES: :path: "../node_modules/react-native-release-profiler" react-native-safe-area-context: :path: "../node_modules/react-native-safe-area-context" + react-native-skia: + :path: "../node_modules/@shopify/react-native-skia" react-native-view-shot: :path: "../node_modules/react-native-view-shot" react-native-wallet: @@ -4601,6 +4656,8 @@ EXTERNAL SOURCES: :path: "../node_modules/react-native-svg" RNWorklets: :path: "../node_modules/react-native-worklets" + UMAppLoader: + :path: "../node_modules/unimodules-app-loader/ios" VisionCamera: :path: "../node_modules/react-native-vision-camera" Yoga: @@ -4616,10 +4673,10 @@ SPEC CHECKSUMS: AirshipServiceExtension: 50d11b2f62c4a490d4e81a1c36f70e2ecb70a27e AppAuth: d4f13a8fe0baf391b2108511793e4b479691fb73 AppLogs: 3bc4e9b141dbf265b9464409caaa40416a9ee0e0 - boost: 7e761d76ca2ce687f7cc98e698152abd03a18f90 + boost: 659a89341ea4ab3df8259733813b52f26d8be9a5 DoubleConversion: cb417026b2400c8f53ae97020b2be961b59470cb EXAV: ae28256069c4cdde93d185c007d8f68d92902c2e - EXConstants: a95804601ee4a6aa7800645f9b070d753b1142b3 + EXConstants: fd688cef4e401dcf798a021cfb5d87c890c30ba3 EXImageLoader: 4d3d3284141f1a45006cc4d0844061c182daf7ee expensify-react-native-background-task: 03c640e1f5649692d058cba48c0a138f024a6dd3 ExpensifyNitroUtils: 86109fe1ab88351ed63ffe11b760d537c70019d7 @@ -4631,8 +4688,9 @@ SPEC CHECKSUMS: ExpoLocation: 93d7faa0c2adbd5a04686af0c1a61bc6ed3ee2f7 ExpoModulesCore: e1b5401a7af4c7dbf4fe26b535918a72c6ed8a7b ExpoSecureStore: 3f1b632d6d40bcc62b4983ef9199cd079592a50a + ExpoStoreReview: 32bb43b6fae9c8db3e33cad69996dff3785eef5f ExpoVideo: 6907c4872886dce2720d3af20782eb6ee7734110 - ExpoStoreReview: 15f19e0d6cb6e00330ba1b356485bf47ef19c39a + EXTaskManager: 6f1a66e4c8cc6df6e24c3d90928704bc3013eae5 fast_float: b32c788ed9c6a8c584d114d0047beda9664e7cc6 FBLazyVector: 941bef1c8eeabd9fe1f501e30a5220beee913886 Firebase: 3435bc66b4d494c2f22c79fd3aae4c1db6662327 @@ -4688,7 +4746,7 @@ SPEC CHECKSUMS: React: 2073376f47c71b7e9a0af7535986a77522ce1049 React-callinvoker: 751b6f2c83347a0486391c3f266f291f0f53b27e React-Codegen: 4b8b4817cea7a54b83851d4c1f91f79aa73de30a - React-Core: dff5d29973349b11dd6631c9498456d75f846d5e + React-Core: aeebd9b37ac383279f610f1e53f66b9931686a41 React-CoreModules: c0ae04452e4c5d30e06f8e94692a49107657f537 React-cxxreact: 376fd672c95dfb64ad5cc246e6a1e9edb78dec4c React-debug: d4955c86870792887ed695df6ebf0e94e39dc7e1 @@ -4727,19 +4785,20 @@ SPEC CHECKSUMS: react-native-key-command: 7538df85ed26502b2a929c0584235459b26c7a91 react-native-keyboard-controller: c4ca61f44d66c2f8987a7e67e9b78e80dc965c45 react-native-launch-arguments: d4759f7591e2766e6c5ec746b7032429edaf7058 - react-native-netinfo: cec9c4e86083cb5b6aba0e0711f563e2fbbff187 + react-native-netinfo: f94b3a0fc305e812f3f615989d99299d7110c2ae react-native-pager-view: b93d2fcd4dc06c519b8ad0ceddaf183fb5fa32e7 react-native-pdf: 6a09a9be0e7ee954ea671437483316f9a28f8572 react-native-performance: a7a65d0b0f3055c5db33e1433e4345143ef6a100 react-native-plaid-link-sdk: 425c0a3a923310fcd8489142209ff1508372a7bf react-native-release-profiler: 6c3ed1765fee48e869170e4af339c838a747eaee react-native-safe-area-context: 0a3b034bb63a5b684dd2f5fffd3c90ef6ed41ee8 + react-native-skia: 51f30133876025c83e933f4f7253479e6de5d937 react-native-view-shot: 28aca10c6c6e5331959ba4b6cb2fced572f88af3 react-native-wallet: 4e3cc1f48ca653ad4a96df8da7e6bd9c8987b3e3 react-native-webview: cdce419e8022d0ef6f07db21890631258e7a9e6e React-NativeModulesApple: 8c7eb6057b00c191a11ad5ced41826ec5a0e4d78 React-oscompat: 93b5535ea7f7dff46aaee4f78309a70979bdde9d - React-perflogger: 5536d2df3d18fe0920263466f7b46a56351c0510 + React-perflogger: e7dcbfcb796d346be7936b75740c3e27a4bb3977 React-performancetimeline: c6c9393c1a0453a51e1852e3531defe60790b36c React-RCTActionSheet: 42195ae666e6d79b4af2346770f765b7c29435b9 React-RCTAnimation: fa103ccc3503b1ed8dedca7e62e7823937748843 @@ -4754,7 +4813,7 @@ SPEC CHECKSUMS: React-RCTSettings: 71f5c7fd7b5f4e725a4e2114a4b4373d0e46048f React-RCTText: b94d4699b49285bee22b8ebf768924d607eccee3 React-RCTVibration: 6e3993c4f6c36a3899059f9a9ead560ddaf5a7d7 - React-rendererconsistency: 612d0f6603d9837bb1236d7fd5194203b35c8799 + React-rendererconsistency: bef28690433e2b4bb00c2f884b22b86e61a430f2 React-renderercss: e5c2c3b84976f7a587cde8423c671db07a6a77da React-rendererdebug: cc7a6131733605b8897754f72c0c35c79f77da9e React-RuntimeApple: 3f96102fc1ebf738d36719cdce5422a5769293fb @@ -4800,6 +4859,7 @@ SPEC CHECKSUMS: SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748 Turf: aa2ede4298009639d10db36aba1a7ebaad072a5e TweetNacl: 3abf4d1d2082b0114e7a67410e300892448951e6 + UMAppLoader: 145337733539f9af9b3dd97eeaa7f3bd97cacb23 VisionCamera: 30b358b807324c692064f78385e9a732ce1bebfe Yoga: 9b30b783a17681321b52ac507a37219d7d795ace diff --git a/jest/setup.ts b/jest/setup.ts index 6ed6fcbbbf415..c8a42be1e7247 100644 --- a/jest/setup.ts +++ b/jest/setup.ts @@ -1,6 +1,7 @@ /* eslint-disable max-classes-per-file */ import * as core from '@actions/core'; import '@shopify/flash-list/jestSetup'; +import type {ReactNode} from 'react'; import {useMemo} from 'react'; import type * as RNAppLogs from 'react-native-app-logs'; import type {ReadDirItem} from 'react-native-fs'; @@ -287,6 +288,19 @@ jest.mock('react-native-nitro-sqlite', () => ({ open: jest.fn(), })); +jest.mock('@shopify/react-native-skia', () => ({ + useFont: jest.fn(() => null), + matchFont: jest.fn(() => null), + listFontFamilies: jest.fn(() => []), +})); + +jest.mock('victory-native', () => ({ + Bar: jest.fn(() => null), + CartesianChart: jest.fn( + ({children}: {children?: (args: Record) => ReactNode}) => children?.({points: {y: []}, chartBounds: {left: 0, right: 0, top: 0, bottom: 0}}) ?? null, + ), +})); + // Provide a default global fetch mock for tests that do not explicitly set it up // This avoids ReferenceError: fetch is not defined in CI when coverage is enabled const globalWithOptionalFetch: typeof globalThis & {fetch?: unknown} = globalThis as typeof globalThis & {fetch?: unknown}; diff --git a/package-lock.json b/package-lock.json index 0ebb682fc2d59..3dc0e8f474e2b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -53,6 +53,7 @@ "@rnmapbox/maps": "10.1.44", "@sentry/react-native": "^7.6.0", "@shopify/flash-list": "2.2.0", + "@shopify/react-native-skia": "^2.4.14", "@ua/react-native-airship": "~25.0.0", "array.prototype.tosorted": "^1.1.4", "awesome-phonenumber": "^5.4.0", @@ -146,6 +147,7 @@ "react-plaid-link": "3.3.2", "react-web-config": "^1.0.0", "react-webcam": "^7.1.1", + "victory-native": "^41.20.2", "xlsx": "^0.18.5" }, "devDependencies": { @@ -13996,6 +13998,33 @@ "react-native": "*" } }, + "node_modules/@shopify/react-native-skia": { + "version": "2.4.14", + "resolved": "https://registry.npmjs.org/@shopify/react-native-skia/-/react-native-skia-2.4.14.tgz", + "integrity": "sha512-zFxjAQbfrdOxoNJoaOCZQzZliuAWXjFkrNZv2PtofG2RAUPWIxWmk2J/oOROpTwXgkmh1JLvFp3uONccTXUthQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "canvaskit-wasm": "0.40.0", + "react-reconciler": "0.31.0" + }, + "bin": { + "setup-skia-web": "scripts/setup-canvaskit.js" + }, + "peerDependencies": { + "react": ">=19.0", + "react-native": ">=0.78", + "react-native-reanimated": ">=3.19.1" + }, + "peerDependenciesMeta": { + "react-native": { + "optional": true + }, + "react-native-reanimated": { + "optional": true + } + } + }, "node_modules/@sideway/address": { "version": "4.1.5", "devOptional": true, @@ -16659,6 +16688,15 @@ "react-native": "*" } }, + "node_modules/@types/react-reconciler": { + "version": "0.28.9", + "resolved": "https://registry.npmjs.org/@types/react-reconciler/-/react-reconciler-0.28.9.tgz", + "integrity": "sha512-HHM3nxyUZ3zAylX8ZEyrDNd2XZOnQ0D5XfunJF5FLQnZbHHYq4UWvW1QfelQNXv1ICNkwYhfxjwfnqivYB6bFg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*" + } + }, "node_modules/@types/react-test-renderer": { "version": "19.1.0", "dev": true, @@ -17381,6 +17419,12 @@ "@xtuc/long": "4.2.2" } }, + "node_modules/@webgpu/types": { + "version": "0.1.21", + "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.21.tgz", + "integrity": "sha512-pUrWq3V5PiSGFLeLxoGqReTZmiiXwY3jRkIG5sLLKjyqNxrwm/04b4nw7LSmGWJcKk59XOM/YRTUwOzo4MMlow==", + "license": "BSD-3-Clause" + }, "node_modules/@webpack-cli/configtest": { "version": "2.1.1", "dev": true, @@ -19261,6 +19305,15 @@ "version": "1.2.6", "license": "MIT" }, + "node_modules/canvaskit-wasm": { + "version": "0.40.0", + "resolved": "https://registry.npmjs.org/canvaskit-wasm/-/canvaskit-wasm-0.40.0.tgz", + "integrity": "sha512-Od2o+ZmoEw9PBdN/yCGvzfu0WVqlufBPEWNG452wY7E9aT8RBE+ChpZF526doOlg7zumO4iCS+RAeht4P0Gbpw==", + "license": "BSD-3-Clause", + "dependencies": { + "@webgpu/types": "0.1.21" + } + }, "node_modules/case-sensitive-paths-webpack-plugin": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/case-sensitive-paths-webpack-plugin/-/case-sensitive-paths-webpack-plugin-2.4.0.tgz", @@ -21001,6 +21054,193 @@ "dev": true, "license": "MIT" }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/damerau-levenshtein": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", @@ -26392,6 +26632,15 @@ "node": ">= 0.4" } }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/interpret": { "version": "3.1.1", "dev": true, @@ -27163,6 +27412,18 @@ "node": ">= 0.4" } }, + "node_modules/its-fine": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/its-fine/-/its-fine-1.2.5.tgz", + "integrity": "sha512-fXtDA0X0t0eBYAGLVM5YsgJGsJ5jEmqZEPrGbzdf5awjv0xE7nqv3TVnvtUF060Tkes15DbDAKW/I48vsb6SyA==", + "license": "MIT", + "dependencies": { + "@types/react-reconciler": "^0.28.0" + }, + "peerDependencies": { + "react": ">=18.0" + } + }, "node_modules/jackspeak": { "version": "3.4.3", "license": "BlueOak-1.0.0", @@ -33305,6 +33566,12 @@ "react": ">=16.13.1" } }, + "node_modules/react-fast-compare": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz", + "integrity": "sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==", + "license": "MIT" + }, "node_modules/react-fast-pdf": { "version": "1.0.29", "license": "MIT", @@ -34456,6 +34723,27 @@ "react-dom": "^16.8.6 || 17 - 18" } }, + "node_modules/react-reconciler": { + "version": "0.31.0", + "resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.31.0.tgz", + "integrity": "sha512-7Ob7Z+URmesIsIVRjnLoDGwBEG/tVitidU0nMsqX/eeJaLY89RISO/10ERe0MqmzuKUUB1rmY+h1itMbUHg9BQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.25.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "peerDependencies": { + "react": "^19.0.0" + } + }, + "node_modules/react-reconciler/node_modules/scheduler": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.25.0.tgz", + "integrity": "sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA==", + "license": "MIT" + }, "node_modules/react-refresh": { "version": "0.14.2", "license": "MIT", @@ -37983,6 +38271,26 @@ "node": ">= 0.8" } }, + "node_modules/victory-native": { + "version": "41.20.2", + "resolved": "https://registry.npmjs.org/victory-native/-/victory-native-41.20.2.tgz", + "integrity": "sha512-gc6DPnfoAHYnaWndXfrITlBvKdLKtoq3J4muBn/Sawu528+MfByAI8LlJBboDGHE1rLBWsgiS58h67D3Ulmtqg==", + "license": "MIT", + "dependencies": { + "d3-scale": "^4.0.2", + "d3-shape": "^3.2.0", + "d3-zoom": "^3.0.0", + "its-fine": "^1.2.5", + "react-fast-compare": "^3.2.2" + }, + "peerDependencies": { + "@shopify/react-native-skia": ">=1.2.3", + "react": "*", + "react-native": "*", + "react-native-gesture-handler": ">=2.0.0", + "react-native-reanimated": ">=3.0.0" + } + }, "node_modules/vlq": { "version": "1.0.1", "license": "MIT" diff --git a/package.json b/package.json index ebab4c3582285..abe41cf79eb08 100644 --- a/package.json +++ b/package.json @@ -122,6 +122,7 @@ "@rnmapbox/maps": "10.1.44", "@sentry/react-native": "^7.6.0", "@shopify/flash-list": "2.2.0", + "@shopify/react-native-skia": "^2.4.14", "@ua/react-native-airship": "~25.0.0", "array.prototype.tosorted": "^1.1.4", "awesome-phonenumber": "^5.4.0", @@ -215,6 +216,7 @@ "react-plaid-link": "3.3.2", "react-web-config": "^1.0.0", "react-webcam": "^7.1.1", + "victory-native": "^41.20.2", "xlsx": "^0.18.5" }, "devDependencies": { diff --git a/scripts/postInstall.sh b/scripts/postInstall.sh index a5af4b0fdd451..30b66bd2c366c 100755 --- a/scripts/postInstall.sh +++ b/scripts/postInstall.sh @@ -21,5 +21,12 @@ if [[ "$IS_HYBRID_APP_REPO" == "true" && "$NEW_DOT_FLAG" == "false" ]]; then cd "$ROOT_DIR" || exit 1 fi +# Setup Skia WASM +echo -e "\n${GREEN}Setting up Skia WASM!${NC}" +npx setup-skia-web + +# Clean up web/static created by setup-skia-web +rm -rf "$ROOT_DIR/web/static" + # Apply packages using patch-package scripts/applyPatches.sh diff --git a/src/components/Charts/BarChart/BarChartContent.tsx b/src/components/Charts/BarChart/BarChartContent.tsx new file mode 100644 index 0000000000000..61e863e3294fd --- /dev/null +++ b/src/components/Charts/BarChart/BarChartContent.tsx @@ -0,0 +1,295 @@ +import {useFont} from '@shopify/react-native-skia'; +import React, {useCallback, useMemo, useState} from 'react'; +import type {LayoutChangeEvent} from 'react-native'; +import {View} from 'react-native'; +import Animated, {useSharedValue} from 'react-native-reanimated'; +import type {ChartBounds, PointsArray} from 'victory-native'; +import {Bar, CartesianChart} from 'victory-native'; +import ActivityIndicator from '@components/ActivityIndicator'; +import {getChartColor} from '@components/Charts/chartColors'; +import ChartHeader from '@components/Charts/ChartHeader'; +import ChartTooltip from '@components/Charts/ChartTooltip'; +import { + BAR_INNER_PADDING, + BAR_ROUNDED_CORNERS, + CHART_COLORS, + CHART_CONTENT_MIN_HEIGHT, + CHART_PADDING, + DEFAULT_SINGLE_BAR_COLOR_INDEX, + DOMAIN_PADDING, + DOMAIN_PADDING_SAFETY_BUFFER, + FRAME_LINE_WIDTH, + X_AXIS_LINE_WIDTH, + Y_AXIS_LABEL_OFFSET, + Y_AXIS_LINE_WIDTH, + Y_AXIS_TICK_COUNT, +} from '@components/Charts/constants'; +import fontSource from '@components/Charts/font'; +import type {HitTestArgs} from '@components/Charts/hooks'; +import {useChartInteractions, useChartLabelFormats, useChartLabelLayout} from '@components/Charts/hooks'; +import type {BarChartProps} from '@components/Charts/types'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; +import variables from '@styles/variables'; + +/** + * Calculate minimum domainPadding required to prevent bars from overflowing chart edges. + * + * The issue: victory-native calculates bar width as (1 - innerPadding) * chartWidth / barCount, + * but positions bars at indices [0, 1, ..., n-1] scaled to the chart width with domainPadding. + * For small bar counts, the default padding is insufficient and bars overflow. + */ +function calculateMinDomainPadding(chartWidth: number, barCount: number, innerPadding: number): number { + if (barCount <= 0) { + return 0; + } + const minPaddingRatio = (1 - innerPadding) / (2 * (barCount - 1 + innerPadding)); + return Math.ceil(chartWidth * minPaddingRatio * DOMAIN_PADDING_SAFETY_BUFFER); +} + +function BarChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUnitPosition = 'left', useSingleColor = false, onBarPress}: BarChartProps) { + const theme = useTheme(); + const styles = useThemeStyles(); + const {shouldUseNarrowLayout} = useResponsiveLayout(); + const font = useFont(fontSource, variables.iconSizeExtraSmall); + const [chartWidth, setChartWidth] = useState(0); + const [containerHeight, setContainerHeight] = useState(0); + + const defaultBarColor = CHART_COLORS.at(DEFAULT_SINGLE_BAR_COLOR_INDEX); + + // prepare data for display + const chartData = useMemo(() => { + return data.map((point, index) => ({ + x: index, + y: point.total, + })); + }, [data]); + + // Anchor Y-axis at zero so the baseline is always visible. + // When negative values are present, let victory-native auto-calculate the domain to avoid clipping. + const yAxisDomain = useMemo((): [number] | undefined => (data.some((point) => point.total < 0) ? undefined : [0]), [data]); + + // Handle bar press callback + const handleBarPress = useCallback( + (index: number) => { + if (index < 0 || index >= data.length) { + return; + } + const dataPoint = data.at(index); + if (dataPoint && onBarPress) { + onBarPress(dataPoint, index); + } + }, + [data, onBarPress], + ); + + const handleLayout = useCallback((event: LayoutChangeEvent) => { + const {width, height} = event.nativeEvent.layout; + setChartWidth(width); + setContainerHeight(height); + }, []); + + const {labelRotation, labelSkipInterval, truncatedLabels, maxLabelLength} = useChartLabelLayout({ + data, + font, + chartWidth, + containerHeight, + }); + + const domainPadding = useMemo(() => { + if (chartWidth === 0) { + return {left: 0, right: 0, top: DOMAIN_PADDING.top, bottom: DOMAIN_PADDING.bottom}; + } + const horizontalPadding = calculateMinDomainPadding(chartWidth, data.length, BAR_INNER_PADDING); + return {left: horizontalPadding, right: horizontalPadding + DOMAIN_PADDING.right, top: DOMAIN_PADDING.top, bottom: DOMAIN_PADDING.bottom}; + }, [chartWidth, data.length]); + + const {formatXAxisLabel, formatYAxisLabel} = useChartLabelFormats({ + data, + yAxisUnit, + yAxisUnitPosition, + labelSkipInterval, + labelRotation, + truncatedLabels, + }); + + // Store bar geometry for hit-testing (only constants, no arrays) + const barGeometry = useSharedValue({barWidth: 0, chartBottom: 0, yZero: 0}); + + const handleChartBoundsChange = useCallback( + (bounds: ChartBounds) => { + const domainWidth = bounds.right - bounds.left; + const calculatedBarWidth = ((1 - BAR_INNER_PADDING) * domainWidth) / data.length; + barGeometry.set({ + ...barGeometry.get(), + barWidth: calculatedBarWidth, + chartBottom: bounds.bottom, + }); + }, + [data.length, barGeometry], + ); + + const handleScaleChange = useCallback( + (_xScale: unknown, yScale: (value: number) => number) => { + barGeometry.set({ + ...barGeometry.get(), + yZero: yScale(0), + }); + }, + [barGeometry], + ); + + const checkIsOverBar = useCallback( + (args: HitTestArgs) => { + 'worklet'; + + const {barWidth, yZero} = barGeometry.get(); + if (barWidth === 0) { + return false; + } + const barLeft = args.targetX - barWidth / 2; + const barRight = args.targetX + barWidth / 2; + // For positive bars: targetY < yZero, bar goes from targetY (top) to yZero (bottom) + // For negative bars: targetY > yZero, bar goes from yZero (top) to targetY (bottom) + const barTop = Math.min(args.targetY, yZero); + const barBottom = Math.max(args.targetY, yZero); + + return args.cursorX >= barLeft && args.cursorX <= barRight && args.cursorY >= barTop && args.cursorY <= barBottom; + }, + [barGeometry], + ); + + const {actionsRef, customGestures, activeDataIndex, isTooltipActive, tooltipStyle} = useChartInteractions({ + handlePress: handleBarPress, + checkIsOver: checkIsOverBar, + barGeometry, + }); + + const tooltipData = useMemo(() => { + if (activeDataIndex < 0 || activeDataIndex >= data.length) { + return null; + } + const dataPoint = data.at(activeDataIndex); + if (!dataPoint) { + return null; + } + const formatted = dataPoint.total.toLocaleString(); + let formattedAmount = formatted; + if (yAxisUnit) { + // Add space for multi-character codes (e.g., "PLN 100") but not for symbols (e.g., "$100") + const separator = yAxisUnit.length > 1 ? ' ' : ''; + formattedAmount = yAxisUnitPosition === 'left' ? `${yAxisUnit}${separator}${formatted}` : `${formatted}${separator}${yAxisUnit}`; + } + const totalSum = data.reduce((sum, point) => sum + Math.abs(point.total), 0); + const percent = totalSum > 0 ? Math.round((Math.abs(dataPoint.total) / totalSum) * 100) : 0; + return { + label: dataPoint.label, + amount: formattedAmount, + percentage: percent < 1 ? '<1%' : `${percent}%`, + }; + }, [activeDataIndex, data, yAxisUnit, yAxisUnitPosition]); + + const renderBar = useCallback( + (point: PointsArray[number], chartBounds: ChartBounds, barCount: number) => { + const dataIndex = point.xValue as number; + const dataPoint = data.at(dataIndex); + const barColor = useSingleColor ? defaultBarColor : getChartColor(dataIndex); + + return ( + + ); + }, + [data, useSingleColor, defaultBarColor], + ); + + // When labels are rotated 90°, add measured label height to container + // This keeps bar area at ~250px while giving labels their needed vertical space + const dynamicChartStyle = useMemo( + () => ({ + height: CHART_CONTENT_MIN_HEIGHT + (maxLabelLength ?? 0), + }), + [maxLabelLength], + ); + + if (isLoading || !font) { + return ( + + + + ); + } + + if (data.length === 0) { + return null; + } + + return ( + + + + {chartWidth > 0 && ( + + {({points, chartBounds}) => <>{points.y.map((point) => renderBar(point, chartBounds, points.y.length))}} + + )} + {isTooltipActive && !!tooltipData && ( + + + + )} + + + ); +} + +export default BarChartContent; diff --git a/src/components/Charts/BarChart/index.native.tsx b/src/components/Charts/BarChart/index.native.tsx new file mode 100644 index 0000000000000..82396525a5b00 --- /dev/null +++ b/src/components/Charts/BarChart/index.native.tsx @@ -0,0 +1,12 @@ +import React from 'react'; +import type {BarChartProps} from '@components/Charts/types'; +import BarChartContent from './BarChartContent'; + +function BarChart(props: BarChartProps) { + // eslint-disable-next-line react/jsx-props-no-spreading + return ; +} + +BarChart.displayName = 'BarChart'; + +export default BarChart; diff --git a/src/components/Charts/BarChart/index.tsx b/src/components/Charts/BarChart/index.tsx new file mode 100644 index 0000000000000..c82a92ecbf23e --- /dev/null +++ b/src/components/Charts/BarChart/index.tsx @@ -0,0 +1,27 @@ +import {WithSkiaWeb} from '@shopify/react-native-skia/lib/module/web'; +import React from 'react'; +import {View} from 'react-native'; +import ActivityIndicator from '@components/ActivityIndicator'; +import type {BarChartProps} from '@components/Charts/types'; +import useThemeStyles from '@hooks/useThemeStyles'; + +function BarChart(props: BarChartProps) { + const styles = useThemeStyles(); + + return ( + `/${file}`}} + getComponent={() => import('./BarChartContent')} + componentProps={props} + fallback={ + + + + } + /> + ); +} + +BarChart.displayName = 'BarChart'; + +export default BarChart; diff --git a/src/components/Charts/ChartHeader.tsx b/src/components/Charts/ChartHeader.tsx new file mode 100644 index 0000000000000..6c3938d677cbf --- /dev/null +++ b/src/components/Charts/ChartHeader.tsx @@ -0,0 +1,38 @@ +import React from 'react'; +import {View} from 'react-native'; +import Icon from '@components/Icon'; +import Text from '@components/Text'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; +import variables from '@styles/variables'; +import type IconAsset from '@src/types/utils/IconAsset'; + +type ChartHeaderProps = { + title: string | undefined; + titleIcon: IconAsset | undefined; +}; + +function ChartHeader({title, titleIcon}: ChartHeaderProps) { + const theme = useTheme(); + const styles = useThemeStyles(); + + return ( + !!title && ( + + {!!titleIcon && ( + + )} + {title} + + ) + ); +} + +ChartHeader.displayName = 'ChartHeader'; + +export default ChartHeader; diff --git a/src/components/Charts/ChartTooltip.tsx b/src/components/Charts/ChartTooltip.tsx new file mode 100644 index 0000000000000..5c61feef80f70 --- /dev/null +++ b/src/components/Charts/ChartTooltip.tsx @@ -0,0 +1,49 @@ +import React from 'react'; +import {View} from 'react-native'; +import Text from '@components/Text'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; +import {TOOLTIP_POINTER_HEIGHT, TOOLTIP_POINTER_WIDTH} from './constants'; + +type ChartTooltipProps = { + /** Label text (e.g., "Airfare", "Amazon") */ + label: string; + + /** Formatted amount (e.g., "$1,820.00") */ + amount: string; + + /** Optional percentage to display (e.g., "12%") */ + percentage?: string; +}; + +function ChartTooltip({label, amount, percentage}: ChartTooltipProps) { + const theme = useTheme(); + const styles = useThemeStyles(); + + const content = percentage ? `${label} • ${amount} (${percentage})` : `${label} • ${amount}`; + + return ( + + + {content} + + + + ); +} + +ChartTooltip.displayName = 'ChartTooltip'; + +export default ChartTooltip; diff --git a/src/components/Charts/chartColors.ts b/src/components/Charts/chartColors.ts new file mode 100644 index 0000000000000..c34ea720c0409 --- /dev/null +++ b/src/components/Charts/chartColors.ts @@ -0,0 +1,39 @@ +import colors from '@styles/theme/colors'; + +/** + * Expensify Chart Color Palette. + * Sequence logic: + * 1. Row Sequence: 400, 600, 300, 500, 700 + * 2. Hue Order: Yellow, Tangerine, Pink, Green, Ice, Blue + */ +const CHART_PALETTE: string[] = (() => { + const rows = [400, 600, 300, 500, 700] as const; + const hues = ['yellow', 'tangerine', 'pink', 'green', 'ice', 'blue'] as const; + + const palette: string[] = []; + + // Generate the 30 unique combinations (5 rows × 6 hues) + for (const row of rows) { + for (const hue of hues) { + const colorKey = `${hue}${row}`; + if (colors[colorKey]) { + palette.push(colors[colorKey]); + } + } + } + + return palette; +})(); + +/** + * Gets a color from the chart palette based on index. + * Automatically loops back to the start if the index exceeds 29. + */ +function getChartColor(index: number): string { + if (CHART_PALETTE.length === 0) { + return colors.black; // Fallback + } + return CHART_PALETTE.at(index % CHART_PALETTE.length) ?? colors.black; +} + +export {CHART_PALETTE, getChartColor}; diff --git a/src/components/Charts/constants.ts b/src/components/Charts/constants.ts new file mode 100644 index 0000000000000..b0ed40872622d --- /dev/null +++ b/src/components/Charts/constants.ts @@ -0,0 +1,113 @@ +import type {Color} from '@shopify/react-native-skia'; +import type {RoundedCorners} from 'victory-native'; +import colors from '@styles/theme/colors'; + +/** + * Chart color palette from Figma design. + * Colors cycle when there are more data points than colors. + */ +const CHART_COLORS: Color[] = [colors.yellow400, colors.tangerine400, colors.pink400, colors.green400, colors.ice400]; + +/** Number of Y-axis ticks (including zero) */ +const Y_AXIS_TICK_COUNT = 5; + +/** Inner padding between bars (0.3 = 30% of bar width) */ +const BAR_INNER_PADDING = 0.3; + +/** Domain padding configuration for the chart */ +const DOMAIN_PADDING = { + left: 0, + right: 16, + top: 30, + bottom: 10, +}; + +/** Distance between Y-axis labels and the chart */ +const Y_AXIS_LABEL_OFFSET = 16; + +/** Rounded corners radius for bars */ +const BAR_CORNER_RADIUS = 8; + +/** Rounded corners configuration for bars */ +const BAR_ROUNDED_CORNERS: RoundedCorners = { + topLeft: BAR_CORNER_RADIUS, + topRight: BAR_CORNER_RADIUS, + bottomLeft: BAR_CORNER_RADIUS, + bottomRight: BAR_CORNER_RADIUS, +}; + +/** Chart padding */ +const CHART_PADDING = 5; + +/** Minimum height for the chart content area (bars, Y-axis, grid lines) */ +const CHART_CONTENT_MIN_HEIGHT = 250; + +/** Default bar color index when useSingleColor is true (ice blue) */ +const DEFAULT_SINGLE_BAR_COLOR_INDEX = 4; + +/** Safety buffer multiplier for domain padding calculation */ +const DOMAIN_PADDING_SAFETY_BUFFER = 1.1; + +/** Line width for X-axis (hidden) */ +const X_AXIS_LINE_WIDTH = 0; + +/** Line width for Y-axis grid lines */ +const Y_AXIS_LINE_WIDTH = 1; + +/** Line width for frame (hidden) */ +const FRAME_LINE_WIDTH = 0; + +/** The height of the chart tooltip pointer */ +const TOOLTIP_POINTER_HEIGHT = 4; + +/** The width of the chart tooltip pointer */ +const TOOLTIP_POINTER_WIDTH = 12; + +/** Gap between bar top and tooltip bottom */ +const TOOLTIP_BAR_GAP = 8; + +/** Rotation angle for X-axis labels - 45 degrees (in degrees) */ +const X_AXIS_LABEL_ROTATION_45 = -45; + +/** Rotation angle for X-axis labels - 90 degrees (in degrees) */ +const X_AXIS_LABEL_ROTATION_90 = -90; + +/** Sin of 45 degrees - used to calculate effective width of rotated labels */ +const SIN_45_DEGREES = Math.sin(Math.PI / 4); // ≈ 0.707 + +/** Minimum padding between labels (in pixels) */ +const LABEL_PADDING = 4; + +/** Maximum ratio of container height that X-axis labels can occupy. + * Victory allocates: fontHeight + yLabelOffset * 2 + rotateOffset. + * With fontHeight ~12px and yLabelOffset = 16, base is ~44px. + * This ratio limits total label area to prevent labels from taking too much space. */ +const X_AXIS_LABEL_MAX_HEIGHT_RATIO = 0.35; + +/** Ellipsis character for truncated labels */ +const LABEL_ELLIPSIS = '...'; + +export { + CHART_COLORS, + Y_AXIS_TICK_COUNT, + BAR_INNER_PADDING, + DOMAIN_PADDING, + Y_AXIS_LABEL_OFFSET, + BAR_ROUNDED_CORNERS, + CHART_PADDING, + CHART_CONTENT_MIN_HEIGHT, + DEFAULT_SINGLE_BAR_COLOR_INDEX, + DOMAIN_PADDING_SAFETY_BUFFER, + X_AXIS_LINE_WIDTH, + Y_AXIS_LINE_WIDTH, + FRAME_LINE_WIDTH, + TOOLTIP_POINTER_HEIGHT, + TOOLTIP_POINTER_WIDTH, + TOOLTIP_BAR_GAP, + X_AXIS_LABEL_ROTATION_45, + X_AXIS_LABEL_ROTATION_90, + SIN_45_DEGREES, + LABEL_PADDING, + X_AXIS_LABEL_MAX_HEIGHT_RATIO, + LABEL_ELLIPSIS, +}; diff --git a/src/components/Charts/font/index.native.ts b/src/components/Charts/font/index.native.ts new file mode 100644 index 0000000000000..74a40474375fd --- /dev/null +++ b/src/components/Charts/font/index.native.ts @@ -0,0 +1,5 @@ +import type {DataSourceParam} from '@shopify/react-native-skia'; + +const fontSource = require('@assets/fonts/native/ExpensifyNeue-Regular.otf') as DataSourceParam; + +export default fontSource; diff --git a/src/components/Charts/font/index.ts b/src/components/Charts/font/index.ts new file mode 100644 index 0000000000000..a14113ca8bc75 --- /dev/null +++ b/src/components/Charts/font/index.ts @@ -0,0 +1,5 @@ +import type {DataSourceParam} from '@shopify/react-native-skia'; + +const fontSource = '/fonts/ExpensifyNeue-Regular.woff' as DataSourceParam; + +export default fontSource; diff --git a/src/components/Charts/hooks/index.ts b/src/components/Charts/hooks/index.ts new file mode 100644 index 0000000000000..fc5119b0a729b --- /dev/null +++ b/src/components/Charts/hooks/index.ts @@ -0,0 +1,6 @@ +export {useChartInteractionState} from './useChartInteractionState'; +export {useChartLabelLayout} from './useChartLabelLayout'; +export {useChartInteractions} from './useChartInteractions'; +export type {HitTestArgs} from './useChartInteractions'; +export type {ChartInteractionState, ChartInteractionStateInit} from './useChartInteractionState'; +export {default as useChartLabelFormats} from './useChartLabelFormats'; diff --git a/src/components/Charts/hooks/useChartInteractionState.ts b/src/components/Charts/hooks/useChartInteractionState.ts new file mode 100644 index 0000000000000..344832485fe1f --- /dev/null +++ b/src/components/Charts/hooks/useChartInteractionState.ts @@ -0,0 +1,134 @@ +import {useMemo, useState} from 'react'; +import type {SharedValue} from 'react-native-reanimated'; +import {makeMutable, useAnimatedReaction} from 'react-native-reanimated'; +import {scheduleOnRN} from 'react-native-worklets'; + +/** + * Input field type - matches Victory Native's InputFieldType + */ +type InputFieldType = string | number | Date; + +/** + * Initial values for chart interaction state + */ +type ChartInteractionStateInit = { + x: InputFieldType; + y: Record; +}; + +/** + * Chart interaction state structure - compatible with Victory's handleTouch function + */ +type ChartInteractionState = { + /** Whether interaction (hover/press) is currently active */ + isActive: SharedValue; + /** Index of the matched data point (-1 if none) */ + matchedIndex: SharedValue; + /** X-axis value and position */ + x: { + value: SharedValue; + position: SharedValue; + }; + /** Y-axis values and positions for each y key */ + y: Record< + keyof Init['y'], + { + value: SharedValue; + position: SharedValue; + } + >; + /** Y index for stacked bar charts */ + yIndex: SharedValue; + /** Raw cursor position */ + cursor: { + x: SharedValue; + y: SharedValue; + }; +}; + +/** + * Hook to track whether interaction is active as React state + */ +function useIsInteractionActive(state: ChartInteractionState): boolean { + const [isInteractionActive, setIsInteractionActive] = useState(() => state.isActive.get()); + + useAnimatedReaction( + () => state.isActive.get(), + (val, oldVal) => { + if (val === oldVal) { + return; + } + scheduleOnRN(setIsInteractionActive, val); + }, + ); + + return isInteractionActive; +} + +/** + * Creates shared state for chart interactions (hover, tap, press). + * Compatible with Victory Native's handleTouch function exposed via actionsRef. + * + * @param initialValues - Initial x and y values matching your chart data structure + * @returns Object containing the interaction state and a boolean indicating if interaction is active + * + * @example + * ```tsx + * const { state, isActive } = useChartInteractionState({ + * x: '', + * y: { value: 0 } + * }); + * + * // Use with customGestures and actionsRef + * const hoverGesture = Gesture.Hover() + * .onUpdate((e) => { + * state.isActive.set(true); + * actionsRef.current?.handleTouch(state, e.x, e.y); + * }) + * .onEnd(() => { + * state.isActive.set(false); + * }); + * ``` + */ +function useChartInteractionState( + initialValues: Init, +): { + state: ChartInteractionState; + isActive: boolean; +} { + const keys = Object.keys(initialValues.y).join(','); + + const state = useMemo(() => { + const yState = {} as Record; position: SharedValue}>; + + for (const [key, initVal] of Object.entries(initialValues.y)) { + yState[key as keyof Init['y']] = { + value: makeMutable(initVal), + position: makeMutable(0), + }; + } + + return { + isActive: makeMutable(false), + matchedIndex: makeMutable(-1), + x: { + value: makeMutable(initialValues.x), + position: makeMutable(0), + }, + y: yState, + yIndex: makeMutable(-1), + cursor: { + x: makeMutable(0), + y: makeMutable(0), + }, + }; + // eslint-disable-next-line react-hooks/exhaustive-deps -- keys is a stable string representation of y keys + }, [keys]); + + const isActive = useIsInteractionActive(state); + + return {state, isActive}; +} + +export {useChartInteractionState}; +export type {ChartInteractionState, ChartInteractionStateInit}; diff --git a/src/components/Charts/hooks/useChartInteractions.ts b/src/components/Charts/hooks/useChartInteractions.ts new file mode 100644 index 0000000000000..e7a5422f58808 --- /dev/null +++ b/src/components/Charts/hooks/useChartInteractions.ts @@ -0,0 +1,220 @@ +import {useMemo, useRef, useState} from 'react'; +import {Gesture} from 'react-native-gesture-handler'; +import type {SharedValue} from 'react-native-reanimated'; +import {useAnimatedReaction, useAnimatedStyle, useDerivedValue} from 'react-native-reanimated'; +import {scheduleOnRN} from 'react-native-worklets'; +import {TOOLTIP_BAR_GAP} from '@components/Charts/constants'; +import {useChartInteractionState} from './useChartInteractionState'; + +/** + * Arguments passed to the checkIsOver callback for hit-testing + */ +type HitTestArgs = { + /** Current raw X position of the cursor */ + cursorX: number; + /** Current raw Y position of the cursor */ + cursorY: number; + /** Calculated X position of the matched data point */ + targetX: number; + /** Calculated Y position of the matched data point */ + targetY: number; + /** The bottom boundary of the chart area */ + chartBottom: number; +}; + +/** + * Configuration for the chart interactions hook + */ +type UseChartInteractionsProps = { + /** Callback triggered when a valid data point is tapped/clicked */ + handlePress: (index: number) => void; + /** + * Worklet function to determine if the cursor is technically "hovering" + * over a specific chart element (e.g., within a bar's width or a point's radius). + */ + checkIsOver: (args: HitTestArgs) => boolean; + /** Optional shared value containing bar dimensions used for hit-testing in bar charts */ + barGeometry?: SharedValue<{barWidth: number; chartBottom: number; yZero: number}>; +}; + +/** + * Type for Victory's actionsRef handle. + * Used to manually trigger Victory's internal touch handling logic. + */ +type CartesianActionsHandle = { + handleTouch: (state: unknown, x: number, y: number) => void; +}; + +/** + * Hook to manage complex chart interactions including hover gestures (web), + * tap gestures (mobile/web), hit-testing, and animated tooltip positioning. + * + * It synchronizes high-frequency interaction data from the UI thread to React state + * for metadata display (like tooltips) and navigation. + * + * @param props - Configuration including press handlers and hit-test logic. + * @returns An object containing refs, gestures, and state for the chart component. + * + * @example + * ```tsx + * const { actionsRef, customGestures, activeDataIndex, isTooltipActive, tooltipStyle } = useChartInteractions({ + * handlePress: (index) => console.log("Pressed index:", index), + * checkIsOver: ({ cursorX, targetX, barWidth }) => { + * 'worklet'; + * return Math.abs(cursorX - targetX) < barWidth / 2; + * }, + * barGeometry: myBarSharedValue, + * }); + * + * return ( + * + * + * {isTooltipActive && } + * + * ); + * ``` + */ +function useChartInteractions({handlePress, checkIsOver, barGeometry}: UseChartInteractionsProps) { + /** Interaction state compatible with Victory Native's internal logic */ + const {state: chartInteractionState, isActive: isTooltipActiveState} = useChartInteractionState({x: 0, y: {y: 0}}); + + /** Ref passed to CartesianChart to allow manual touch injection */ + const actionsRef = useRef(null); + + /** React state for the index of the point currently being interacted with */ + const [activeDataIndex, setActiveDataIndex] = useState(-1); + + /** React state indicating if the cursor is currently "hitting" a target based on checkIsOver */ + const [isOverTarget, setIsOverTarget] = useState(false); + + /** + * Derived value performing the hit-test on the UI thread. + * Runs whenever cursor position or matched data points change. + */ + const isCursorOverTarget = useDerivedValue(() => { + const cursorX = chartInteractionState.cursor.x.get(); + const cursorY = chartInteractionState.cursor.y.get(); + const targetX = chartInteractionState.x.position.get(); + const targetY = chartInteractionState.y.y.position.get(); + + const chartBottom = barGeometry?.get().chartBottom ?? 0; + + return checkIsOver({ + cursorX, + cursorY, + targetX, + targetY, + chartBottom, + }); + }); + + /** Syncs the matched data index from the UI thread to React state */ + useAnimatedReaction( + () => chartInteractionState.matchedIndex.get(), + (currentIndex) => { + scheduleOnRN(setActiveDataIndex, currentIndex); + }, + ); + + /** Syncs the hit-test result from the UI thread to React state */ + useAnimatedReaction( + () => isCursorOverTarget.get(), + (isOver) => { + scheduleOnRN(setIsOverTarget, isOver); + }, + ); + + /** + * Hover gesture configuration. + * Primarily used for web/desktop to track mouse movement without clicking. + */ + const hoverGesture = useMemo( + () => + Gesture.Hover() + .onBegin((e) => { + 'worklet'; + + chartInteractionState.isActive.set(true); + chartInteractionState.cursor.x.set(e.x); + chartInteractionState.cursor.y.set(e.y); + actionsRef.current?.handleTouch(chartInteractionState, e.x, e.y); + }) + .onUpdate((e) => { + 'worklet'; + + chartInteractionState.cursor.x.set(e.x); + chartInteractionState.cursor.y.set(e.y); + actionsRef.current?.handleTouch(chartInteractionState, e.x, e.y); + }) + .onEnd(() => { + 'worklet'; + + chartInteractionState.isActive.set(false); + }), + [chartInteractionState], + ); + + /** + * Tap gesture configuration. + * Handles clicks/touches and triggers handlePress if Victory matched a data point. + */ + const tapGesture = useMemo( + () => + Gesture.Tap().onEnd((e) => { + 'worklet'; + + // Update cursor position + chartInteractionState.cursor.x.set(e.x); + chartInteractionState.cursor.y.set(e.y); + + // Let Victory calculate which data point was tapped + actionsRef.current?.handleTouch(chartInteractionState, e.x, e.y); + const matchedIndex = chartInteractionState.matchedIndex.get(); + + // If Victory matched a valid data point, trigger the press handler + if (matchedIndex >= 0) { + scheduleOnRN(handlePress, matchedIndex); + } + }), + [chartInteractionState, handlePress], + ); + + /** Combined gesture object to be passed to CartesianChart's customGestures prop */ + const customGestures = useMemo(() => Gesture.Race(hoverGesture, tapGesture), [hoverGesture, tapGesture]); + + /** + * Animated style for positioning a tooltip relative to the matched data point. + * Automatically applies vertical offset and centering. + * For negative bars, positions tooltip at yZero (top of bar) instead of targetY (bottom of bar). + */ + const tooltipStyle = useAnimatedStyle(() => { + const targetY = chartInteractionState.y.y.position.get(); + const yZero = barGeometry?.get().yZero ?? targetY; + // Position tooltip at the top of the bar (min of targetY and yZero) + const barTopY = Math.min(targetY, yZero); + + return { + position: 'absolute', + left: chartInteractionState.x.position.get(), + top: barTopY - TOOLTIP_BAR_GAP, + transform: [{translateX: '-50%'}, {translateY: '-100%'}], + opacity: chartInteractionState.isActive.get() ? 1 : 0, + }; + }); + + return { + /** Ref to be passed to CartesianChart */ + actionsRef, + /** Gestures to be passed to CartesianChart */ + customGestures, + /** The currently active data index (React state) */ + activeDataIndex, + /** Whether the tooltip should currently be rendered and visible */ + isTooltipActive: isOverTarget && isTooltipActiveState, + /** Animated styles for the tooltip container */ + tooltipStyle, + }; +} + +export {useChartInteractions}; +export type {HitTestArgs}; diff --git a/src/components/Charts/hooks/useChartLabelFormats.ts b/src/components/Charts/hooks/useChartLabelFormats.ts new file mode 100644 index 0000000000000..3f6136c5d262a --- /dev/null +++ b/src/components/Charts/hooks/useChartLabelFormats.ts @@ -0,0 +1,53 @@ +import {useCallback} from 'react'; + +type ChartDataPoint = { + label: string; +}; + +type UseChartLabelFormatsProps = { + data: ChartDataPoint[]; + yAxisUnit?: string; + yAxisUnitPosition?: 'left' | 'right'; + labelSkipInterval: number; + labelRotation: number; + truncatedLabels: string[]; +}; + +export default function useChartLabelFormats({data, yAxisUnit, yAxisUnitPosition = 'left', labelSkipInterval, labelRotation, truncatedLabels}: UseChartLabelFormatsProps) { + const formatYAxisLabel = useCallback( + (value: number) => { + const formatted = value.toLocaleString(); + if (!yAxisUnit) { + return formatted; + } + // Add space for multi-character codes (e.g., "PLN 100") but not for symbols (e.g., "$100") + const separator = yAxisUnit.length > 1 ? ' ' : ''; + return yAxisUnitPosition === 'left' ? `${yAxisUnit}${separator}${formatted}` : `${formatted}${separator}${yAxisUnit}`; + }, + [yAxisUnit, yAxisUnitPosition], + ); + + const formatXAxisLabel = useCallback( + (value: number) => { + const index = Math.round(value); + + // Skip labels based on calculated interval + if (index % labelSkipInterval !== 0) { + return ''; + } + + // Use pre-truncated labels + // If rotation is vertical (-90), we usually want full labels + // because they have more space vertically. + const sourceToUse = labelRotation === -90 ? data.map((p) => p.label) : truncatedLabels; + + return sourceToUse.at(index) ?? ''; + }, + [labelSkipInterval, labelRotation, truncatedLabels, data], + ); + + return { + formatXAxisLabel, + formatYAxisLabel, + }; +} diff --git a/src/components/Charts/hooks/useChartLabelLayout.ts b/src/components/Charts/hooks/useChartLabelLayout.ts new file mode 100644 index 0000000000000..a303a6b5cc889 --- /dev/null +++ b/src/components/Charts/hooks/useChartLabelLayout.ts @@ -0,0 +1,144 @@ +import type {SkFont} from '@shopify/react-native-skia'; +import {useMemo} from 'react'; +import { + LABEL_ELLIPSIS, + LABEL_PADDING, + SIN_45_DEGREES, + X_AXIS_LABEL_MAX_HEIGHT_RATIO, + X_AXIS_LABEL_ROTATION_45, + X_AXIS_LABEL_ROTATION_90, + Y_AXIS_LABEL_OFFSET, +} from '@components/Charts/constants'; + +type ChartDataPoint = { + label: string; + [key: string]: unknown; +}; + +type LabelLayoutConfig = { + data: ChartDataPoint[]; + font: SkFont | null; + chartWidth: number; + containerHeight: number; +}; + +/** + * Measure the width of a text string using the font's glyph widths. + * Uses getGlyphWidths as measureText is not implemented on React Native Web. + */ +function measureTextWidth(text: string, font: SkFont): number { + const glyphIDs = font.getGlyphIDs(text); + const glyphWidths = font.getGlyphWidths(glyphIDs); + return glyphWidths.reduce((sum, w) => sum + w, 0); +} + +function useChartLabelLayout({data, font, chartWidth, containerHeight}: LabelLayoutConfig) { + return useMemo(() => { + if (!font || chartWidth === 0 || containerHeight === 0 || data.length === 0) { + return {labelRotation: 0, labelSkipInterval: 1, truncatedLabels: data.map((p) => p.label)}; + } + + // Get font metrics + const fontMetrics = font.getMetrics(); + const lineHeight = Math.abs(fontMetrics.descent) + Math.abs(fontMetrics.ascent); + const ellipsisWidth = measureTextWidth(LABEL_ELLIPSIS, font); + + // Calculate available dimensions + const availableWidthPerBar = chartWidth / data.length - LABEL_PADDING; + + // Measure original labels + const labelWidths = data.map((p) => measureTextWidth(p.label, font)); + const maxLabelLength = Math.max(...labelWidths); + + // Helper to truncate a label to fit a max pixel width + const truncateToWidth = (label: string, labelWidth: number, maxWidth: number): string => { + if (labelWidth <= maxWidth) { + return label; + } + const availableWidth = maxWidth - ellipsisWidth; + if (availableWidth <= 0) { + return LABEL_ELLIPSIS; + } + const ratio = availableWidth / labelWidth; + const maxChars = Math.max(1, Math.floor(label.length * ratio)); + return label.slice(0, maxChars) + LABEL_ELLIPSIS; + }; + + // === DETERMINE ROTATION (based on WIDTH constraint, monotonic: 0° → 45° → 90°) === + let rotation = 0; + if (maxLabelLength > availableWidthPerBar) { + // Labels don't fit at 0°, try 45° + const effectiveWidthAt45 = maxLabelLength * SIN_45_DEGREES; + if (effectiveWidthAt45 <= availableWidthPerBar) { + rotation = 45; + } else { + // 45° doesn't fit either, use 90° + rotation = 90; + } + } + + // === DETERMINE TRUNCATION === + // Limit label area to X_AXIS_LABEL_MAX_HEIGHT_RATIO of container height. + // + // IMPLEMENTATION NOTE: We assume Victory allocates space for X-axis labels using: + // totalHeight = fontHeight + yAxis.labelOffset * 2 + labelWidth * sin(angle) + // This formula was found in: victory-native-xl/src/cartesian/utils/transformInputData.ts + // If Victory changes this formula, these calculations will need adjustment. + // + // We calculate max labelWidth so total allocation stays within our limit. + const maxLabelHeight = containerHeight * X_AXIS_LABEL_MAX_HEIGHT_RATIO; + const victoryBaseAllocation = lineHeight + Y_AXIS_LABEL_OFFSET * 2; + const availableForRotation = Math.max(0, maxLabelHeight - victoryBaseAllocation); + + let maxAllowedLabelWidth: number; + + if (rotation === 0) { + // At 0°: no truncation, use skip interval instead (like Google Sheets) + maxAllowedLabelWidth = Infinity; + } else if (rotation === 45) { + // At 45°: labelWidth * sin(45°) <= availableForRotation + // labelWidth <= availableForRotation / sin(45°) + maxAllowedLabelWidth = availableForRotation / SIN_45_DEGREES; + } else { + // At 90°: no truncation, container expands to accommodate labels + maxAllowedLabelWidth = Infinity; + } + + // Generate truncated labels + const finalLabels = data.map((p, i) => truncateToWidth(p.label, labelWidths.at(i) ?? 0, maxAllowedLabelWidth)); + + // === CALCULATE SKIP INTERVAL === + // Calculate effective width based on rotation angle + const finalMaxWidth = Math.max(...finalLabels.map((l) => measureTextWidth(l, font))); + let effectiveWidth: number; + if (rotation === 0) { + effectiveWidth = finalMaxWidth; + } else if (rotation === 45) { + effectiveWidth = finalMaxWidth * SIN_45_DEGREES; + } else { + effectiveWidth = lineHeight; // At 90°, width is the line height + } + + // Calculate skip interval using spec formula: + // maxVisibleLabels = floor(chartWidth / (effectiveWidth + MIN_LABEL_GAP)) + // skipInterval = ceil(barCount / maxVisibleLabels) + let skipInterval = 1; + const maxVisibleLabels = Math.floor(chartWidth / (effectiveWidth + LABEL_PADDING)); + if (maxVisibleLabels > 0 && maxVisibleLabels < data.length) { + skipInterval = Math.ceil(data.length / maxVisibleLabels); + } + + // Convert rotation to negative degrees for Victory chart + let rotationValue = 0; + if (rotation === 45) { + rotationValue = X_AXIS_LABEL_ROTATION_45; + } else if (rotation === 90) { + rotationValue = X_AXIS_LABEL_ROTATION_90; + } + + return {labelRotation: rotationValue, labelSkipInterval: skipInterval, truncatedLabels: finalLabels, maxLabelLength}; + }, [font, chartWidth, containerHeight, data]); +} + +export {useChartLabelLayout}; +export type {LabelLayoutConfig}; diff --git a/src/components/Charts/index.ts b/src/components/Charts/index.ts new file mode 100644 index 0000000000000..aabb568439238 --- /dev/null +++ b/src/components/Charts/index.ts @@ -0,0 +1,6 @@ +import BarChart from './BarChart'; +import ChartHeader from './ChartHeader'; +import ChartTooltip from './ChartTooltip'; + +export {BarChart, ChartHeader, ChartTooltip}; +export type {BarChartDataPoint, BarChartProps} from './types'; diff --git a/src/components/Charts/types.ts b/src/components/Charts/types.ts new file mode 100644 index 0000000000000..e2ec4fe540726 --- /dev/null +++ b/src/components/Charts/types.ts @@ -0,0 +1,43 @@ +import type IconAsset from '@src/types/utils/IconAsset'; + +type BarChartDataPoint = { + /** Label displayed under the bar (e.g., "Amazon", "Travel", "Nov 2025") */ + label: string; + + /** Total amount (pre-formatted, e.g., dollars not cents) */ + total: number; + + /** Currency code for formatting */ + currency: string; + + /** Query string for navigation when bar is clicked (optional) */ + onClickQuery?: string; +}; + +type BarChartProps = { + /** Data points to display */ + data: BarChartDataPoint[]; + + /** Chart title (e.g., "Top Categories", "Spend by Merchant") */ + title?: string; + + /** Icon displayed next to the title */ + titleIcon?: IconAsset; + + /** Whether data is loading */ + isLoading?: boolean; + + /** Callback when a bar is pressed */ + onBarPress?: (dataPoint: BarChartDataPoint, index: number) => void; + + /** Symbol/unit for Y-axis labels (e.g., '$', '€', 'zł'). Empty string or undefined shows raw numbers. */ + yAxisUnit?: string; + + /** Position of the unit symbol relative to the value. Defaults to 'left'. */ + yAxisUnitPosition?: 'left' | 'right'; + + /** When true, all bars use the same color. When false (default), each bar uses a different color from the palette. */ + useSingleColor?: boolean; +}; + +export type {BarChartDataPoint, BarChartProps}; diff --git a/src/components/Search/SearchBarChart.tsx b/src/components/Search/SearchBarChart.tsx new file mode 100644 index 0000000000000..68d1e2f89e1a3 --- /dev/null +++ b/src/components/Search/SearchBarChart.tsx @@ -0,0 +1,93 @@ +import React, {useCallback, useMemo} from 'react'; +import {BarChart} from '@components/Charts'; +import type {BarChartDataPoint} from '@components/Charts'; +import type { + TransactionCardGroupListItemType, + TransactionCategoryGroupListItemType, + TransactionGroupListItemType, + TransactionMemberGroupListItemType, + TransactionWithdrawalIDGroupListItemType, +} from '@components/SelectionListWithSections/types'; +import {convertToFrontendAmountAsInteger} from '@libs/CurrencyUtils'; +import type IconAsset from '@src/types/utils/IconAsset'; + +type GroupedItem = TransactionMemberGroupListItemType | TransactionCardGroupListItemType | TransactionWithdrawalIDGroupListItemType | TransactionCategoryGroupListItemType; + +type SearchBarChartProps = { + /** Grouped transaction data from search results */ + data: TransactionGroupListItemType[]; + + /** Chart title */ + title: string; + + /** Chart title icon */ + titleIcon: IconAsset; + + /** Function to extract label from grouped item */ + getLabel: (item: GroupedItem) => string; + + /** Function to build filter query from grouped item */ + getFilterQuery: (item: GroupedItem) => string; + + /** Callback when a bar is pressed - receives the filter query to apply */ + onBarPress?: (filterQuery: string) => void; + + /** Whether data is loading */ + isLoading?: boolean; + + /** Currency symbol for Y-axis labels */ + yAxisUnit?: string; + + /** Position of currency symbol relative to value */ + yAxisUnitPosition?: 'left' | 'right'; +}; + +function SearchBarChart({data, title, titleIcon, getLabel, getFilterQuery, onBarPress, isLoading, yAxisUnit, yAxisUnitPosition}: SearchBarChartProps) { + // Transform grouped transaction data to BarChart format + const chartData: BarChartDataPoint[] = useMemo(() => { + return data.map((item) => { + const groupedItem = item as GroupedItem; + const currency = groupedItem.currency ?? 'USD'; + const totalInDisplayUnits = convertToFrontendAmountAsInteger(groupedItem.total ?? 0, currency); + + return { + label: getLabel(groupedItem), + total: totalInDisplayUnits, + currency, + }; + }); + }, [data, getLabel]); + + const handleBarPress = useCallback( + (dataPoint: BarChartDataPoint, index: number) => { + if (!onBarPress) { + return; + } + + const item = data.at(index); + if (!item) { + return; + } + + const filterQuery = getFilterQuery(item as GroupedItem); + onBarPress(filterQuery); + }, + [data, getFilterQuery, onBarPress], + ); + + return ( + + ); +} + +SearchBarChart.displayName = 'SearchBarChart'; + +export default SearchBarChart; diff --git a/src/components/Search/SearchChartView.tsx b/src/components/Search/SearchChartView.tsx new file mode 100644 index 0000000000000..1979dad7fb6cb --- /dev/null +++ b/src/components/Search/SearchChartView.tsx @@ -0,0 +1,225 @@ +import React, {useCallback, useMemo} from 'react'; +import type {NativeScrollEvent, NativeSyntheticEvent} from 'react-native'; +import {View} from 'react-native'; +import Animated from 'react-native-reanimated'; +import type { + TransactionCardGroupListItemType, + TransactionCategoryGroupListItemType, + TransactionGroupListItemType, + TransactionMemberGroupListItemType, + TransactionMerchantGroupListItemType, + TransactionMonthGroupListItemType, + TransactionQuarterGroupListItemType, + TransactionTagGroupListItemType, + TransactionWeekGroupListItemType, + TransactionWithdrawalIDGroupListItemType, + TransactionYearGroupListItemType, +} from '@components/SelectionListWithSections/types'; +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useThemeStyles from '@hooks/useThemeStyles'; +import {getCurrencyDisplayInfoForCharts} from '@libs/CurrencyUtils'; +import DateUtils from '@libs/DateUtils'; +import Log from '@libs/Log'; +import Navigation from '@libs/Navigation/Navigation'; +import {buildSearchQueryJSON, buildSearchQueryString} from '@libs/SearchQueryUtils'; +import CONST from '@src/CONST'; +import ROUTES from '@src/ROUTES'; +import SearchBarChart from './SearchBarChart'; +import type {ChartView, SearchGroupBy, SearchQueryJSON} from './types'; + +type GroupedItem = + | TransactionMemberGroupListItemType + | TransactionCardGroupListItemType + | TransactionWithdrawalIDGroupListItemType + | TransactionCategoryGroupListItemType + | TransactionMerchantGroupListItemType + | TransactionTagGroupListItemType + | TransactionMonthGroupListItemType + | TransactionWeekGroupListItemType + | TransactionYearGroupListItemType + | TransactionQuarterGroupListItemType; + +type ChartGroupByConfig = { + titleIconName: 'Users' | 'CreditCard' | 'Send' | 'Folder' | 'Basket' | 'Tag' | 'Calendar'; + getLabel: (item: GroupedItem) => string; + getFilterQuery: (item: GroupedItem) => string; +}; + +/** + * Chart-specific configuration for each groupBy type - defines how to extract label and build filter query + * for displaying grouped transaction data in charts. + */ +const CHART_GROUP_BY_CONFIG: Record = { + [CONST.SEARCH.GROUP_BY.FROM]: { + titleIconName: 'Users', + getLabel: (item: GroupedItem) => (item as TransactionMemberGroupListItemType).formattedFrom ?? '', + getFilterQuery: (item: GroupedItem) => `from:${(item as TransactionMemberGroupListItemType).accountID}`, + }, + [CONST.SEARCH.GROUP_BY.CARD]: { + titleIconName: 'CreditCard', + getLabel: (item: GroupedItem) => (item as TransactionCardGroupListItemType).formattedCardName ?? '', + getFilterQuery: (item: GroupedItem) => `cardID:${(item as TransactionCardGroupListItemType).cardID}`, + }, + [CONST.SEARCH.GROUP_BY.WITHDRAWAL_ID]: { + titleIconName: 'Send', + // eslint-disable-next-line rulesdir/no-default-id-values -- formattedWithdrawalID is a display label, not an Onyx ID + getLabel: (item: GroupedItem) => (item as TransactionWithdrawalIDGroupListItemType).formattedWithdrawalID ?? '', + getFilterQuery: (item: GroupedItem) => `withdrawalID:${(item as TransactionWithdrawalIDGroupListItemType).entryID}`, + }, + [CONST.SEARCH.GROUP_BY.CATEGORY]: { + titleIconName: 'Folder', + getLabel: (item: GroupedItem) => (item as TransactionCategoryGroupListItemType).formattedCategory ?? '', + getFilterQuery: (item: GroupedItem) => `category:"${(item as TransactionCategoryGroupListItemType).category}"`, + }, + [CONST.SEARCH.GROUP_BY.MERCHANT]: { + titleIconName: 'Basket', + getLabel: (item: GroupedItem) => (item as TransactionMerchantGroupListItemType).formattedMerchant ?? '', + getFilterQuery: (item: GroupedItem) => `merchant:"${(item as TransactionMerchantGroupListItemType).merchant}"`, + }, + [CONST.SEARCH.GROUP_BY.TAG]: { + titleIconName: 'Tag', + getLabel: (item: GroupedItem) => (item as TransactionTagGroupListItemType).formattedTag ?? '', + getFilterQuery: (item: GroupedItem) => `tag:"${(item as TransactionTagGroupListItemType).tag}"`, + }, + [CONST.SEARCH.GROUP_BY.MONTH]: { + titleIconName: 'Calendar', + getLabel: (item: GroupedItem) => (item as TransactionMonthGroupListItemType).formattedMonth ?? '', + getFilterQuery: (item: GroupedItem) => { + const monthItem = item as TransactionMonthGroupListItemType; + const {start, end} = DateUtils.getMonthDateRange(monthItem.year, monthItem.month); + return `date>=${start} date<=${end}`; + }, + }, + [CONST.SEARCH.GROUP_BY.WEEK]: { + titleIconName: 'Calendar', + getLabel: (item: GroupedItem) => (item as TransactionWeekGroupListItemType).formattedWeek ?? '', + getFilterQuery: (item: GroupedItem) => { + const weekItem = item as TransactionWeekGroupListItemType; + const {start, end} = DateUtils.getWeekDateRange(weekItem.week); + return `date>=${start} date<=${end}`; + }, + }, + [CONST.SEARCH.GROUP_BY.YEAR]: { + titleIconName: 'Calendar', + getLabel: (item: GroupedItem) => (item as TransactionYearGroupListItemType).formattedYear ?? '', + getFilterQuery: (item: GroupedItem) => { + const yearItem = item as TransactionYearGroupListItemType; + const {start, end} = DateUtils.getYearDateRange(yearItem.year); + return `date>=${start} date<=${end}`; + }, + }, + [CONST.SEARCH.GROUP_BY.QUARTER]: { + titleIconName: 'Calendar', + getLabel: (item: GroupedItem) => (item as TransactionQuarterGroupListItemType).formattedQuarter ?? '', + getFilterQuery: (item: GroupedItem) => { + const quarterItem = item as TransactionQuarterGroupListItemType; + const {start, end} = DateUtils.getQuarterDateRange(quarterItem.year, quarterItem.quarter); + return `date>=${start} date<=${end}`; + }, + }, +}; + +type SearchChartViewProps = { + /** The current search query JSON */ + queryJSON: SearchQueryJSON; + + /** The view type (bar, etc.) */ + view: Exclude; + + /** The groupBy parameter */ + groupBy: SearchGroupBy; + + /** Grouped transaction data from search results */ + data: TransactionGroupListItemType[]; + + /** Whether data is loading */ + isLoading?: boolean; + + /** Scroll handler for hiding the top bar on mobile */ + onScroll?: (event: NativeSyntheticEvent) => void; +}; + +/** + * Map of chart view types to their corresponding chart components. + */ +const CHART_VIEW_TO_COMPONENT: Record, typeof SearchBarChart> = { + [CONST.SEARCH.VIEW.BAR]: SearchBarChart, +}; + +/** + * Layer 3 component - dispatches to the appropriate chart type based on view parameter + * and handles navigation/drill-down logic + */ +function SearchChartView({queryJSON, view, groupBy, data, isLoading, onScroll}: SearchChartViewProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const {shouldUseNarrowLayout} = useResponsiveLayout(); + const icons = useMemoizedLazyExpensifyIcons(['Users', 'CreditCard', 'Send', 'Folder', 'Basket', 'Tag', 'Calendar'] as const); + const {titleIconName, getLabel, getFilterQuery} = CHART_GROUP_BY_CONFIG[groupBy]; + const titleIcon = icons[titleIconName]; + const title = translate(`search.chartTitles.${groupBy}`); + const ChartComponent = CHART_VIEW_TO_COMPONENT[view]; + + const handleBarPress = useCallback( + (filterQuery: string) => { + // Build new query string from current query + filter, then parse it + const currentQueryString = buildSearchQueryString(queryJSON); + const newQueryJSON = buildSearchQueryJSON(`${currentQueryString} ${filterQuery}`); + + if (!newQueryJSON) { + Log.alert('[SearchChartView] Failed to build search query JSON from filter query'); + return; + } + + // Modify the query object directly: remove groupBy and view to show table + newQueryJSON.groupBy = undefined; + newQueryJSON.view = CONST.SEARCH.VIEW.TABLE; + + // Build the final query string and navigate + const newQueryString = buildSearchQueryString(newQueryJSON); + Navigation.navigate(ROUTES.SEARCH_ROOT.getRoute({query: newQueryString})); + }, + [queryJSON], + ); + + // Get currency symbol and position from first data item + const {yAxisUnit, yAxisUnitPosition} = useMemo((): {yAxisUnit: string; yAxisUnitPosition: 'left' | 'right'} => { + const firstItem = data.at(0) as GroupedItem | undefined; + const currency = firstItem?.currency ?? 'USD'; + const {symbol, position} = getCurrencyDisplayInfoForCharts(currency); + + return { + yAxisUnit: symbol, + yAxisUnitPosition: position, + }; + }, [data]); + + return ( + + + + + + ); +} + +SearchChartView.displayName = 'SearchChartView'; + +export default SearchChartView; diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index d98ad5ebe755c..4b1c403c16330 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -84,6 +84,7 @@ import type {OutstandingReportsByPolicyIDDerivedValue, Transaction} from '@src/t import type SearchResults from '@src/types/onyx/SearchResults'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import arraysEqual from '@src/utils/arraysEqual'; +import SearchChartView from './SearchChartView'; import {useSearchContext} from './SearchContext'; import SearchList from './SearchList'; import {SearchScopeProvider} from './SearchScopeProvider'; @@ -214,7 +215,7 @@ function Search({ searchRequestResponseStatusCode, onDEWModalOpen, }: SearchProps) { - const {type, status, sortBy, sortOrder, hash, similarSearchHash, groupBy} = queryJSON; + const {type, status, sortBy, sortOrder, hash, similarSearchHash, groupBy, view} = queryJSON; const {isOffline} = useNetwork(); const prevIsOffline = usePrevious(isOffline); @@ -946,8 +947,7 @@ function Search({ return; } const newFlatFilters = queryJSON.flatFilters.filter((filter) => filter.key !== CONST.SEARCH.SYNTAX_FILTER_KEYS.DATE); - const yearStart = `${yearGroupItem.year}-01-01`; - const yearEnd = `${yearGroupItem.year}-12-31`; + const {start: yearStart, end: yearEnd} = DateUtils.getYearDateRange(yearGroupItem.year); newFlatFilters.push({ key: CONST.SEARCH.SYNTAX_FILTER_KEYS.DATE, filters: [ @@ -1305,6 +1305,24 @@ function Search({ const shouldShowTableHeader = isLargeScreenWidth && !isChat; const tableHeaderVisible = canSelectMultiple || shouldShowTableHeader; + // Other charts are not implemented yet + const shouldShowChartView = view === CONST.SEARCH.VIEW.BAR && !!validGroupBy; + + if (shouldShowChartView) { + return ( + + + + ); + } + return ( diff --git a/src/components/Search/types.ts b/src/components/Search/types.ts index 9fc8ad611c01e..b8b454259b9d0 100644 --- a/src/components/Search/types.ts +++ b/src/components/Search/types.ts @@ -115,6 +115,8 @@ type SingularSearchStatus = ExpenseSearchStatus | ExpenseReportSearchStatus | In type SearchStatus = SingularSearchStatus | SingularSearchStatus[]; type SearchGroupBy = ValueOf; type SearchView = ValueOf; +// LineChart and PieChart are not implemented so we exclude them here to prevent TypeScript errors in `SearchChartView.tsx`. +type ChartView = Exclude; type TableColumnSize = ValueOf; type SearchDatePreset = ValueOf; type SearchWithdrawalType = ValueOf; @@ -339,6 +341,7 @@ export type { TableColumnSize, SearchGroupBy, SearchView, + ChartView, SingularSearchStatus, SearchDatePreset, SearchWithdrawalType, diff --git a/src/languages/de.ts b/src/languages/de.ts index 091c680f5f9c0..fad434df4b782 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -7072,6 +7072,18 @@ Fordere Spesendetails wie Belege und Beschreibungen an, lege Limits und Standard allMatchingItemsSelected: 'Alle passenden Elemente ausgewählt', }, topSpenders: 'Top-Ausgaben', + chartTitles: { + [CONST.SEARCH.GROUP_BY.FROM]: 'Von', + [CONST.SEARCH.GROUP_BY.CARD]: 'Karten', + [CONST.SEARCH.GROUP_BY.WITHDRAWAL_ID]: 'Exporte', + [CONST.SEARCH.GROUP_BY.CATEGORY]: 'Kategorien', + [CONST.SEARCH.GROUP_BY.MERCHANT]: 'Händler', + [CONST.SEARCH.GROUP_BY.TAG]: 'Tags', + [CONST.SEARCH.GROUP_BY.MONTH]: 'Monate', + [CONST.SEARCH.GROUP_BY.WEEK]: 'Wochen', + [CONST.SEARCH.GROUP_BY.YEAR]: 'Jahre', + [CONST.SEARCH.GROUP_BY.QUARTER]: 'Quartale', + }, }, genericErrorPage: { title: 'Oh je, etwas ist schiefgelaufen!', diff --git a/src/languages/en.ts b/src/languages/en.ts index 250209d2f5e9e..7468a93e61638 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -6958,6 +6958,18 @@ const translations = { }, has: 'Has', groupBy: 'Group by', + chartTitles: { + [CONST.SEARCH.GROUP_BY.FROM]: 'From', + [CONST.SEARCH.GROUP_BY.CARD]: 'Cards', + [CONST.SEARCH.GROUP_BY.WITHDRAWAL_ID]: 'Exports', + [CONST.SEARCH.GROUP_BY.CATEGORY]: 'Categories', + [CONST.SEARCH.GROUP_BY.MERCHANT]: 'Merchants', + [CONST.SEARCH.GROUP_BY.TAG]: 'Tags', + [CONST.SEARCH.GROUP_BY.MONTH]: 'Months', + [CONST.SEARCH.GROUP_BY.WEEK]: 'Weeks', + [CONST.SEARCH.GROUP_BY.YEAR]: 'Years', + [CONST.SEARCH.GROUP_BY.QUARTER]: 'Quarters', + }, moneyRequestReport: { emptyStateTitle: 'This report has no expenses.', accessPlaceHolder: 'Open for details', diff --git a/src/languages/es.ts b/src/languages/es.ts index 2582e2a32495d..e32bce725497e 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -6711,6 +6711,18 @@ ${amount} para ${merchant} - ${date}`, }, has: 'Tiene', groupBy: 'Agrupar por', + chartTitles: { + [CONST.SEARCH.GROUP_BY.FROM]: 'De', + [CONST.SEARCH.GROUP_BY.CARD]: 'Tarjetas', + [CONST.SEARCH.GROUP_BY.WITHDRAWAL_ID]: 'Exportaciones', + [CONST.SEARCH.GROUP_BY.CATEGORY]: 'Categorías', + [CONST.SEARCH.GROUP_BY.MERCHANT]: 'Comerciantes', + [CONST.SEARCH.GROUP_BY.TAG]: 'Etiquetas', + [CONST.SEARCH.GROUP_BY.MONTH]: 'Meses', + [CONST.SEARCH.GROUP_BY.WEEK]: 'Semanas', + [CONST.SEARCH.GROUP_BY.YEAR]: 'Años', + [CONST.SEARCH.GROUP_BY.QUARTER]: 'Trimestres', + }, moneyRequestReport: { emptyStateTitle: 'Este informe no tiene gastos.', accessPlaceHolder: 'Abrir para ver detalles', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 4bb85175ea0e0..928ef538ad485 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -7084,6 +7084,18 @@ Exigez des informations de dépense comme les reçus et les descriptions, défin allMatchingItemsSelected: 'Tous les éléments correspondants sont sélectionnés', }, topSpenders: 'Plus gros dépensiers', + chartTitles: { + [CONST.SEARCH.GROUP_BY.FROM]: 'De', + [CONST.SEARCH.GROUP_BY.CARD]: 'Cartes', + [CONST.SEARCH.GROUP_BY.WITHDRAWAL_ID]: 'Exports', + [CONST.SEARCH.GROUP_BY.CATEGORY]: 'Catégories', + [CONST.SEARCH.GROUP_BY.MERCHANT]: 'Commerçants', + [CONST.SEARCH.GROUP_BY.TAG]: 'Étiquettes', + [CONST.SEARCH.GROUP_BY.MONTH]: 'Mois', + [CONST.SEARCH.GROUP_BY.WEEK]: 'Semaines', + [CONST.SEARCH.GROUP_BY.YEAR]: 'Années', + [CONST.SEARCH.GROUP_BY.QUARTER]: 'Trimestres', + }, }, genericErrorPage: { title: 'Oh oh, quelque chose s’est mal passé !', diff --git a/src/languages/it.ts b/src/languages/it.ts index 4ae1db68d9d21..ee9274d38f2ae 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -7061,6 +7061,18 @@ Richiedi dettagli di spesa come ricevute e descrizioni, imposta limiti e valori allMatchingItemsSelected: 'Tutti gli elementi corrispondenti selezionati', }, topSpenders: 'Maggiori spenditori', + chartTitles: { + [CONST.SEARCH.GROUP_BY.FROM]: 'Da', + [CONST.SEARCH.GROUP_BY.CARD]: 'Carte', + [CONST.SEARCH.GROUP_BY.WITHDRAWAL_ID]: 'Esportazioni', + [CONST.SEARCH.GROUP_BY.CATEGORY]: 'Categorie', + [CONST.SEARCH.GROUP_BY.MERCHANT]: 'Commercianti', + [CONST.SEARCH.GROUP_BY.TAG]: 'Tag', + [CONST.SEARCH.GROUP_BY.MONTH]: 'Mesi', + [CONST.SEARCH.GROUP_BY.WEEK]: 'Settimane', + [CONST.SEARCH.GROUP_BY.YEAR]: 'Anni', + [CONST.SEARCH.GROUP_BY.QUARTER]: 'Trimestri', + }, }, genericErrorPage: { title: 'Uh-oh, qualcosa è andato storto!', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index a5cc4e7a78aed..07340a4c5db4c 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -7000,6 +7000,18 @@ ${reportName} allMatchingItemsSelected: '一致する項目をすべて選択済み', }, topSpenders: 'トップ支出者', + chartTitles: { + [CONST.SEARCH.GROUP_BY.FROM]: '差出人', + [CONST.SEARCH.GROUP_BY.CARD]: 'カード', + [CONST.SEARCH.GROUP_BY.WITHDRAWAL_ID]: 'エクスポート', + [CONST.SEARCH.GROUP_BY.CATEGORY]: 'カテゴリー', + [CONST.SEARCH.GROUP_BY.MERCHANT]: '加盟店', + [CONST.SEARCH.GROUP_BY.TAG]: 'タグ', + [CONST.SEARCH.GROUP_BY.MONTH]: '月', + [CONST.SEARCH.GROUP_BY.WEEK]: '週', + [CONST.SEARCH.GROUP_BY.YEAR]: '年', + [CONST.SEARCH.GROUP_BY.QUARTER]: '四半期', + }, }, genericErrorPage: { title: 'おっと、問題が発生しました!', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index e9921e514073d..7b3d0e61c1ccc 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -7044,6 +7044,18 @@ Vraag verplichte uitgavedetails zoals bonnetjes en beschrijvingen, stel limieten allMatchingItemsSelected: 'Alle overeenkomende items geselecteerd', }, topSpenders: 'Grootste uitgaven', + chartTitles: { + [CONST.SEARCH.GROUP_BY.FROM]: 'Van', + [CONST.SEARCH.GROUP_BY.CARD]: 'Kaarten', + [CONST.SEARCH.GROUP_BY.WITHDRAWAL_ID]: 'Exporten', + [CONST.SEARCH.GROUP_BY.CATEGORY]: 'Categorieën', + [CONST.SEARCH.GROUP_BY.MERCHANT]: 'Handelaars', + [CONST.SEARCH.GROUP_BY.TAG]: 'Tags', + [CONST.SEARCH.GROUP_BY.MONTH]: 'Maanden', + [CONST.SEARCH.GROUP_BY.WEEK]: 'Weken', + [CONST.SEARCH.GROUP_BY.YEAR]: 'Jaren', + [CONST.SEARCH.GROUP_BY.QUARTER]: 'Kwartalen', + }, }, genericErrorPage: { title: 'O jee, er is iets misgegaan!', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 59749b8e66374..e24548de4d531 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -7032,6 +7032,18 @@ Wymagaj szczegółów wydatków, takich jak paragony i opisy, ustawiaj limity i allMatchingItemsSelected: 'Wybrano wszystkie pasujące elementy', }, topSpenders: 'Najwięksi wydający', + chartTitles: { + [CONST.SEARCH.GROUP_BY.FROM]: 'Od', + [CONST.SEARCH.GROUP_BY.CARD]: 'Karty', + [CONST.SEARCH.GROUP_BY.WITHDRAWAL_ID]: 'Eksporty', + [CONST.SEARCH.GROUP_BY.CATEGORY]: 'Kategorie', + [CONST.SEARCH.GROUP_BY.MERCHANT]: 'Sprzedawcy', + [CONST.SEARCH.GROUP_BY.TAG]: 'Tagi', + [CONST.SEARCH.GROUP_BY.MONTH]: 'Miesiące', + [CONST.SEARCH.GROUP_BY.WEEK]: 'Tygodnie', + [CONST.SEARCH.GROUP_BY.YEAR]: 'Lata', + [CONST.SEARCH.GROUP_BY.QUARTER]: 'Kwartały', + }, }, genericErrorPage: { title: 'Ups, coś poszło nie tak!', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index addb693fbea07..b97d5bbcead57 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -7033,6 +7033,18 @@ Exija detalhes de despesas como recibos e descrições, defina limites e padrõe allMatchingItemsSelected: 'Todos os itens correspondentes selecionados', }, topSpenders: 'Maiores gastadores', + chartTitles: { + [CONST.SEARCH.GROUP_BY.FROM]: 'De', + [CONST.SEARCH.GROUP_BY.CARD]: 'Cartões', + [CONST.SEARCH.GROUP_BY.WITHDRAWAL_ID]: 'Exportações', + [CONST.SEARCH.GROUP_BY.CATEGORY]: 'Categorias', + [CONST.SEARCH.GROUP_BY.MERCHANT]: 'Comerciantes', + [CONST.SEARCH.GROUP_BY.TAG]: 'Tags', + [CONST.SEARCH.GROUP_BY.MONTH]: 'Meses', + [CONST.SEARCH.GROUP_BY.WEEK]: 'Semanas', + [CONST.SEARCH.GROUP_BY.YEAR]: 'Anos', + [CONST.SEARCH.GROUP_BY.QUARTER]: 'Trimestres', + }, }, genericErrorPage: { title: 'Opa, algo deu errado!', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 4736dd21e3e9d..03d8b25ac7a62 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -6880,6 +6880,18 @@ ${reportName} allMatchingItemsSelected: '已选择所有匹配的项目', }, topSpenders: '最高支出者', + chartTitles: { + [CONST.SEARCH.GROUP_BY.FROM]: '来自', + [CONST.SEARCH.GROUP_BY.CARD]: '卡片', + [CONST.SEARCH.GROUP_BY.WITHDRAWAL_ID]: '导出', + [CONST.SEARCH.GROUP_BY.CATEGORY]: '类别', + [CONST.SEARCH.GROUP_BY.MERCHANT]: '商家', + [CONST.SEARCH.GROUP_BY.TAG]: '标签', + [CONST.SEARCH.GROUP_BY.MONTH]: '月份', + [CONST.SEARCH.GROUP_BY.WEEK]: '周', + [CONST.SEARCH.GROUP_BY.YEAR]: '年', + [CONST.SEARCH.GROUP_BY.QUARTER]: '季度', + }, }, genericErrorPage: { title: '哎呀,出错了!', diff --git a/src/libs/CurrencyUtils.ts b/src/libs/CurrencyUtils.ts index 504a6edd3b255..c8660f216dd8f 100644 --- a/src/libs/CurrencyUtils.ts +++ b/src/libs/CurrencyUtils.ts @@ -212,12 +212,41 @@ function getCurrencyKeyByCountryCode(currencies?: CurrencyList, countryCode?: st return CONST.CURRENCY.USD; } +/** + * Get currency display information for chart labels and tooltips. + * + * Uses Intl.NumberFormat to determine the appropriate currency symbol and its position + * relative to the value based on the user's locale. For example: + * - USD in en-US: symbol "$", position "left" → "$100" + * - PLN in pl-PL: symbol "zł", position "right" → "100 zł" + * - EUR in de-DE: symbol "€", position "right" → "100 €" + * + * The function formats a zero value and extracts the currency part from the formatted parts. + * Position is determined by comparing the index of the currency part to the integer part. + * + * @param currencyCode - ISO 4217 currency code (e.g., "USD", "PLN", "EUR") + * @returns Object with symbol (e.g., "$", "zł", "PLN") and position ("left" or "right") + */ +function getCurrencyDisplayInfoForCharts(currencyCode: string): {symbol: string; position: 'left' | 'right'} { + const locale = IntlStore.getCurrentLocale(); + const parts = formatToParts(locale, 0, {style: 'currency', currency: currencyCode}); + + const currencyIndex = parts.findIndex((p) => p.type === 'currency'); + const integerIndex = parts.findIndex((p) => p.type === 'integer'); + + return { + symbol: parts.find((p) => p.type === 'currency')?.value ?? currencyCode, + position: currencyIndex < integerIndex ? 'left' : 'right', + }; +} + export { getCurrencyDecimals, getCurrencyUnit, getLocalizedCurrencySymbol, getCurrencySymbol, getCurrencyKeyByCountryCode, + getCurrencyDisplayInfoForCharts, convertToBackendAmount, convertToFrontendAmountAsInteger, convertToFrontendAmountAsString, diff --git a/src/libs/DateUtils.ts b/src/libs/DateUtils.ts index 0c60ffd1daaf6..f85125f1a69d9 100644 --- a/src/libs/DateUtils.ts +++ b/src/libs/DateUtils.ts @@ -973,6 +973,13 @@ function getFormattedDateRangeForSearch(startDate: string, endDate: string): str return `${format(start, 'MMM d, yyyy')} - ${format(end, 'MMM d, yyyy')}`; } +function getYearDateRange(year: number): {start: string; end: string} { + return { + start: `${year}-01-01`, + end: `${year}-12-31`, + }; +} + function getQuarterDateRange(year: number, quarter: number): {start: string; end: string} { const startMonth = (quarter - 1) * 3 + 1; const endMonth = quarter * 3; @@ -1057,6 +1064,7 @@ const DateUtils = { getWeekDateRange, isDateStringInMonth, getFormattedDateRangeForSearch, + getYearDateRange, getQuarterDateRange, getFormattedQuarterForSearch, }; diff --git a/src/libs/SearchQueryUtils.ts b/src/libs/SearchQueryUtils.ts index 1fb072fc2bd38..0f05ace1ae863 100644 --- a/src/libs/SearchQueryUtils.ts +++ b/src/libs/SearchQueryUtils.ts @@ -442,6 +442,11 @@ function buildSearchQueryJSON(query: SearchQueryString, rawQuery?: SearchQuerySt result.policyID = [result.policyID]; } + // Default groupBy to category when a chart view is specified without an explicit groupBy + if (result.view !== CONST.SEARCH.VIEW.TABLE && !result.groupBy) { + result.groupBy = CONST.SEARCH.GROUP_BY.CATEGORY; + } + // Normalize limit before computing hashes to ensure invalid values don't affect hash if (result.limit !== undefined) { const num = Number(result.limit); diff --git a/src/styles/index.ts b/src/styles/index.ts index f848059302af1..492feae7ff2ff 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -5773,6 +5773,45 @@ const staticStyles = (theme: ThemeColors) => paymentMethodErrorRow: { paddingHorizontal: variables.iconSizeMenuItem + variables.iconSizeNormal / 2, }, + chartHeader: { + flexDirection: 'row', + alignItems: 'center', + gap: variables.componentBorderRadius, + marginBottom: variables.sectionMargin, + }, + chartTitle: { + ...FontUtils.fontFamily.platform.EXP_NEUE_BOLD, + fontSize: variables.fontSizeNormal, + lineHeight: variables.fontSizeNormalHeight, + color: theme.text, + }, + chartTooltipWrapper: { + alignItems: 'center', + }, + chartTooltipBox: { + backgroundColor: theme.heading, + borderRadius: variables.componentBorderRadiusSmall, + paddingVertical: 4, + paddingHorizontal: 8, + }, + chartTooltipText: { + color: theme.textReversed, + fontSize: variables.fontSizeSmall, + lineHeight: variables.lineHeightSmall, + whiteSpace: 'nowrap', + }, + chartTooltipPointer: { + width: 0, + height: 0, + backgroundColor: theme.transparent, + borderStyle: 'solid', + }, + barChartContainer: { + borderRadius: variables.componentBorderRadiusLarge, + }, + barChartChartContainer: { + minHeight: 250, + }, discoverSectionImage: { width: '100%', height: undefined, diff --git a/tests/unit/Search/SearchQueryUtilsTest.ts b/tests/unit/Search/SearchQueryUtilsTest.ts index c3ccf1c4d2c80..afa8606d0b615 100644 --- a/tests/unit/Search/SearchQueryUtilsTest.ts +++ b/tests/unit/Search/SearchQueryUtilsTest.ts @@ -100,7 +100,7 @@ describe('SearchQueryUtils', () => { const result = getQueryWithUpdatedValues(userQuery); - expect(result).toEqual(`${defaultQuery} view:bar from:12345`); + expect(result).toEqual(`${defaultQuery} view:bar groupBy:category from:12345`); }); test('returns query with view:line', () => { @@ -108,7 +108,7 @@ describe('SearchQueryUtils', () => { const result = getQueryWithUpdatedValues(userQuery); - expect(result).toEqual(`${defaultQuery} view:line category:travel`); + expect(result).toEqual(`${defaultQuery} view:line groupBy:category category:travel`); }); test('returns query with view:pie', () => { @@ -116,7 +116,7 @@ describe('SearchQueryUtils', () => { const result = getQueryWithUpdatedValues(userQuery); - expect(result).toEqual(`${defaultQuery} view:pie merchant:Amazon`); + expect(result).toEqual(`${defaultQuery} view:pie groupBy:category merchant:Amazon`); }); test('deduplicates conflicting type filters keeping the last occurrence', () => {