diff --git a/.eslintrc.js b/.eslintrc.js index 810c0ab..c0379a1 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -6,4 +6,59 @@ module.exports = { 'react-native/sort-styles': 0, 'react-native/no-inline-styles': 1, }, + overrides: [ + { + files: ['*.ts', '*.tsx'], + parser: '@typescript-eslint/parser', + plugins: ['@typescript-eslint'], + parserOptions: { + project: './tsconfig.json', + tsconfigRootDir: __dirname, + }, + extends: [ + 'plugin:@typescript-eslint/recommended', + 'plugin:@typescript-eslint/stylistic', + ], + rules: { + '@typescript-eslint/no-floating-promises': 'warn', + '@typescript-eslint/no-misused-promises': 'warn', + '@typescript-eslint/await-thenable': 'warn', + '@typescript-eslint/strict-boolean-expressions': [ + 'warn', + { + allowNullableBoolean: true, + allowNullableString: true, + allowNullableNumber: true, + allowNullableObject: true, + }, + ], + // `void expr` (including as a concise arrow body, e.g. + // `onPress={() => void save()}`) is the idiomatic way to satisfy + // no-floating-promises for intentionally unawaited promises, which + // conflicts with the base config's blanket ban on `void`. + 'no-void': 'off', + }, + }, + { + // react-navigation's ParamList generics require an object-literal + // `type` alias (not `interface`) to satisfy their implicit index + // signature constraint — https://reactnavigation.org/docs/typescript + files: ['src/navigation/*.ts', 'src/navigation/*.tsx'], + rules: { + '@typescript-eslint/consistent-type-definitions': 'off', + }, + }, + { + // @react-native-firebase/messaging is an optional peer dependency + // (see .env.example): a static `import` would be resolved by Metro at + // bundle time and fail hard when the package or native config is + // absent, whereas a synchronous `require()` inside try/catch can be + // caught and swapped for a no-op adapter, and is intercepted cleanly + // by jest.mock() in tests. + files: ['src/services/firebaseMessaging.ts'], + rules: { + '@typescript-eslint/no-var-requires': 'off', + }, + }, + ], }; diff --git a/App.tsx b/App.tsx index cfa48e2..1ff87c7 100644 --- a/App.tsx +++ b/App.tsx @@ -16,16 +16,16 @@ function AppSync() { useEffect(() => { const sub = AppState.addEventListener('change', (state: AppStateStatus) => { if (state === 'active') { - syncPendingProofs(); + void syncPendingProofs(); } }); return () => sub.remove(); }, [syncPendingProofs]); useEffect(() => { - registerForPushNotifications().then(token => { + void registerForPushNotifications().then(token => { if (token) { - sendTokenToServer(token); + void sendTokenToServer(token); } }); }, []); diff --git a/package-lock.json b/package-lock.json index 0d31a2a..a1407a8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -42,11 +42,14 @@ "@types/react": "^18.2.0", "@types/react-test-renderer": "^19.1.0", "@types/urijs": "1.19.26", + "@typescript-eslint/eslint-plugin": "^6.21.0", + "@typescript-eslint/parser": "^6.21.0", "babel-plugin-module-resolver": "^5.0.0", "eslint": "^8.19.0", "eslint-plugin-prettier": "^5.5.6", "jest": "^29.7.0", "metro-react-native-babel-preset": "^0.77.0", + "prettier": "^3.9.6", "react-test-renderer": "^18.2.0", "typescript": "5.3.3" } @@ -3839,31 +3842,34 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "5.62.0", + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.21.0.tgz", + "integrity": "sha512-oy9+hTPCUFpngkEZUSzbf9MxI65wbKFoQYsgPdILTfbUldp5ovUuphZVe4i30emU9M/kP+T64Di0mxl7dSw3MA==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/regexpp": "^4.4.0", - "@typescript-eslint/scope-manager": "5.62.0", - "@typescript-eslint/type-utils": "5.62.0", - "@typescript-eslint/utils": "5.62.0", + "@eslint-community/regexpp": "^4.5.1", + "@typescript-eslint/scope-manager": "6.21.0", + "@typescript-eslint/type-utils": "6.21.0", + "@typescript-eslint/utils": "6.21.0", + "@typescript-eslint/visitor-keys": "6.21.0", "debug": "^4.3.4", "graphemer": "^1.4.0", - "ignore": "^5.2.0", - "natural-compare-lite": "^1.4.0", - "semver": "^7.3.7", - "tsutils": "^3.21.0" + "ignore": "^5.2.4", + "natural-compare": "^1.4.0", + "semver": "^7.5.4", + "ts-api-utils": "^1.0.1" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^16.0.0 || >=18.0.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^5.0.0", - "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0" + "@typescript-eslint/parser": "^6.0.0 || ^6.0.0-alpha", + "eslint": "^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "typescript": { @@ -3871,6 +3877,150 @@ } } }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/scope-manager": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-6.21.0.tgz", + "integrity": "sha512-OwLUIWZJry80O99zvqXVEioyniJMa+d2GrqpUTqi5/v5D5rOrppJVBPa0yKCblcigC0/aYAzxxqQ1B+DS2RYsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "6.21.0", + "@typescript-eslint/visitor-keys": "6.21.0" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.21.0.tgz", + "integrity": "sha512-1kFmZ1rOm5epu9NZEZm1kckCDGj5UJEf7P1kliH4LKu/RkwpsfqqGmY2OOcUs18lSlQBKLDYBOGxRVtrMN5lpg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/typescript-estree": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.21.0.tgz", + "integrity": "sha512-6npJTkZcO+y2/kr+z0hc4HwNfrrP4kNYh57ek7yCNlrBjWQ1Y0OS7jiZTkgumrvkX5HkEKXFZkkdFNkaW2wmUQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@typescript-eslint/types": "6.21.0", + "@typescript-eslint/visitor-keys": "6.21.0", + "debug": "^4.3.4", + "globby": "^11.1.0", + "is-glob": "^4.0.3", + "minimatch": "9.0.3", + "semver": "^7.5.4", + "ts-api-utils": "^1.0.1" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/utils": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-6.21.0.tgz", + "integrity": "sha512-NfWVaC8HP9T8cbKQxHcsJBY5YE1O33+jpMwN45qzWWaPDZgLIbo12toGMWnmhvCpd3sIxkpDw3Wv1B3dYrbDQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.4.0", + "@types/json-schema": "^7.0.12", + "@types/semver": "^7.5.0", + "@typescript-eslint/scope-manager": "6.21.0", + "@typescript-eslint/types": "6.21.0", + "@typescript-eslint/typescript-estree": "6.21.0", + "semver": "^7.5.4" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^7.0.0 || ^8.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/visitor-keys": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.21.0.tgz", + "integrity": "sha512-JJtkDduxLi9bivAB+cYOVMtbkqdPOhZ+ZI5LC47MIRrDV4Yn2o+ZnW10Nkmr28xRpSpdJ6Sm42Hjf2+REYXm0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "6.21.0", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/brace-expansion": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/minimatch": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", + "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/@typescript-eslint/eslint-plugin/node_modules/semver": { "version": "7.8.1", "dev": true, @@ -3883,24 +4033,27 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "5.62.0", + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.21.0.tgz", + "integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==", "dev": true, "license": "BSD-2-Clause", "dependencies": { - "@typescript-eslint/scope-manager": "5.62.0", - "@typescript-eslint/types": "5.62.0", - "@typescript-eslint/typescript-estree": "5.62.0", + "@typescript-eslint/scope-manager": "6.21.0", + "@typescript-eslint/types": "6.21.0", + "@typescript-eslint/typescript-estree": "6.21.0", + "@typescript-eslint/visitor-keys": "6.21.0", "debug": "^4.3.4" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^16.0.0 || >=18.0.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0" + "eslint": "^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "typescript": { @@ -3908,6 +4061,137 @@ } } }, + "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/scope-manager": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-6.21.0.tgz", + "integrity": "sha512-OwLUIWZJry80O99zvqXVEioyniJMa+d2GrqpUTqi5/v5D5rOrppJVBPa0yKCblcigC0/aYAzxxqQ1B+DS2RYsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "6.21.0", + "@typescript-eslint/visitor-keys": "6.21.0" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.21.0.tgz", + "integrity": "sha512-1kFmZ1rOm5epu9NZEZm1kckCDGj5UJEf7P1kliH4LKu/RkwpsfqqGmY2OOcUs18lSlQBKLDYBOGxRVtrMN5lpg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/typescript-estree": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.21.0.tgz", + "integrity": "sha512-6npJTkZcO+y2/kr+z0hc4HwNfrrP4kNYh57ek7yCNlrBjWQ1Y0OS7jiZTkgumrvkX5HkEKXFZkkdFNkaW2wmUQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@typescript-eslint/types": "6.21.0", + "@typescript-eslint/visitor-keys": "6.21.0", + "debug": "^4.3.4", + "globby": "^11.1.0", + "is-glob": "^4.0.3", + "minimatch": "9.0.3", + "semver": "^7.5.4", + "ts-api-utils": "^1.0.1" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/visitor-keys": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.21.0.tgz", + "integrity": "sha512-JJtkDduxLi9bivAB+cYOVMtbkqdPOhZ+ZI5LC47MIRrDV4Yn2o+ZnW10Nkmr28xRpSpdJ6Sm42Hjf2+REYXm0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "6.21.0", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/brace-expansion": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/minimatch": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", + "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/@typescript-eslint/scope-manager": { "version": "5.62.0", "dev": true, @@ -3925,24 +4209,87 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "5.62.0", + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-6.21.0.tgz", + "integrity": "sha512-rZQI7wHfao8qMX3Rd3xqeYSMCL3SoiSQLBATSiVKARdFGCYSRvmViieZjqc58jKgs8Y8i9YvVVhRbHSTA4VBag==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/typescript-estree": "5.62.0", - "@typescript-eslint/utils": "5.62.0", + "@typescript-eslint/typescript-estree": "6.21.0", + "@typescript-eslint/utils": "6.21.0", "debug": "^4.3.4", - "tsutils": "^3.21.0" + "ts-api-utils": "^1.0.1" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^16.0.0 || >=18.0.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "*" + "eslint": "^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/scope-manager": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-6.21.0.tgz", + "integrity": "sha512-OwLUIWZJry80O99zvqXVEioyniJMa+d2GrqpUTqi5/v5D5rOrppJVBPa0yKCblcigC0/aYAzxxqQ1B+DS2RYsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "6.21.0", + "@typescript-eslint/visitor-keys": "6.21.0" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.21.0.tgz", + "integrity": "sha512-1kFmZ1rOm5epu9NZEZm1kckCDGj5UJEf7P1kliH4LKu/RkwpsfqqGmY2OOcUs18lSlQBKLDYBOGxRVtrMN5lpg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/typescript-estree": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.21.0.tgz", + "integrity": "sha512-6npJTkZcO+y2/kr+z0hc4HwNfrrP4kNYh57ek7yCNlrBjWQ1Y0OS7jiZTkgumrvkX5HkEKXFZkkdFNkaW2wmUQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@typescript-eslint/types": "6.21.0", + "@typescript-eslint/visitor-keys": "6.21.0", + "debug": "^4.3.4", + "globby": "^11.1.0", + "is-glob": "^4.0.3", + "minimatch": "9.0.3", + "semver": "^7.5.4", + "ts-api-utils": "^1.0.1" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" }, "peerDependenciesMeta": { "typescript": { @@ -3950,6 +4297,102 @@ } } }, + "node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/utils": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-6.21.0.tgz", + "integrity": "sha512-NfWVaC8HP9T8cbKQxHcsJBY5YE1O33+jpMwN45qzWWaPDZgLIbo12toGMWnmhvCpd3sIxkpDw3Wv1B3dYrbDQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.4.0", + "@types/json-schema": "^7.0.12", + "@types/semver": "^7.5.0", + "@typescript-eslint/scope-manager": "6.21.0", + "@typescript-eslint/types": "6.21.0", + "@typescript-eslint/typescript-estree": "6.21.0", + "semver": "^7.5.4" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^7.0.0 || ^8.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/visitor-keys": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.21.0.tgz", + "integrity": "sha512-JJtkDduxLi9bivAB+cYOVMtbkqdPOhZ+ZI5LC47MIRrDV4Yn2o+ZnW10Nkmr28xRpSpdJ6Sm42Hjf2+REYXm0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "6.21.0", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/brace-expansion": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/minimatch": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", + "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/@typescript-eslint/types": { "version": "5.62.0", "dev": true, @@ -9844,11 +10287,6 @@ "dev": true, "license": "MIT" }, - "node_modules/natural-compare-lite": { - "version": "1.4.0", - "dev": true, - "license": "MIT" - }, "node_modules/negotiator": { "version": "0.6.3", "license": "MIT", @@ -10769,10 +11207,11 @@ } }, "node_modules/prettier": { - "version": "3.8.3", + "version": "3.9.6", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz", + "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==", "dev": true, "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -12817,6 +13256,19 @@ "version": "0.0.3", "license": "MIT" }, + "node_modules/ts-api-utils": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.3.tgz", + "integrity": "sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "typescript": ">=4.2.0" + } + }, "node_modules/ts-interface-checker": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", diff --git a/package.json b/package.json index 4f0ef84..ff47b00 100644 --- a/package.json +++ b/package.json @@ -46,11 +46,14 @@ "@types/react": "^18.2.0", "@types/react-test-renderer": "^19.1.0", "@types/urijs": "1.19.26", + "@typescript-eslint/eslint-plugin": "^6.21.0", + "@typescript-eslint/parser": "^6.21.0", "babel-plugin-module-resolver": "^5.0.0", "eslint": "^8.19.0", "eslint-plugin-prettier": "^5.5.6", "jest": "^29.7.0", "metro-react-native-babel-preset": "^0.77.0", + "prettier": "^3.9.6", "react-test-renderer": "^18.2.0", "typescript": "5.3.3" }, @@ -63,5 +66,9 @@ "transformIgnorePatterns": [ "node_modules/(?!((jest-)?react-native|@react-native(-community)?|@react-navigation|react-native-maps)/)" ] + }, + "overrides": { + "@typescript-eslint/eslint-plugin": "^6.21.0", + "@typescript-eslint/parser": "^6.21.0" } } diff --git a/src/__tests__/MapScreen.test.tsx b/src/__tests__/MapScreen.test.tsx index 82d1772..ffe273d 100644 --- a/src/__tests__/MapScreen.test.tsx +++ b/src/__tests__/MapScreen.test.tsx @@ -9,28 +9,43 @@ * 5. callout press on a single-task marker navigates to TaskDetail */ -import React from 'react'; +import React, { + forwardRef as mockForwardRef, + useImperativeHandle as mockUseImperativeHandle, +} from 'react'; +import { View as MockView } from 'react-native'; import renderer, { act } from 'react-test-renderer'; // ─── Mocks ──────────────────────────────────────────────────────────────────── +interface MockMapViewRef { + animateToRegion: (region: unknown, duration?: number) => void; +} + +interface MockMarkerProps { + children?: React.ReactNode; + testID?: string; + title?: string; + description?: string; + onCalloutPress?: () => void; +} + // react-native-maps has native code; swap every export for a plain View/mock. +// (`mock`-prefixed imports rather than plain ones: jest.mock factories may +// only reference out-of-scope variables whose name starts with "mock".) jest.mock('react-native-maps', () => { - const MockReact = require('react'); - const { View } = require('react-native'); - - const MapView = MockReact.forwardRef( - ( - props: { children?: React.ReactNode; testID?: string }, - ref: React.Ref, - ) => { - // Expose animateToRegion on the ref so the component doesn't crash - MockReact.useImperativeHandle(ref, () => ({ - animateToRegion: jest.fn(), - })); - return {props.children}; - }, - ); + const MapView = mockForwardRef< + MockMapViewRef, + { children?: React.ReactNode; testID?: string } + >((props, ref) => { + // Expose animateToRegion on the ref so the component doesn't crash + mockUseImperativeHandle(ref, () => ({ + animateToRegion: jest.fn(), + })); + return ( + {props.children} + ); + }); const Marker = ({ children, @@ -38,11 +53,11 @@ jest.mock('react-native-maps', () => { title, description, onCalloutPress, - }: any) => ( - + }: MockMarkerProps) => ( + {children} {/* Simulate a "callout press" button so tests can trigger it */} - { return false; }} /> - {title} - {description} - + {title} + {description} + ); - const Circle = ({ testID }: any) => ; + const Circle = ({ testID }: { testID?: string }) => ( + + ); return { __esModule: true, @@ -136,7 +153,7 @@ describe('MapScreen — no location', () => { it('renders the map view', () => { let tree: renderer.ReactTestRenderer; - act(() => { + void act(() => { tree = renderer.create(); }); const json = tree!.toJSON(); @@ -145,7 +162,7 @@ describe('MapScreen — no location', () => { it('shows the no-location banner', () => { let tree: renderer.ReactTestRenderer; - act(() => { + void act(() => { tree = renderer.create(); }); const json = JSON.stringify(tree!.toJSON()); @@ -154,7 +171,7 @@ describe('MapScreen — no location', () => { it('does NOT render a radius circle', () => { let tree: renderer.ReactTestRenderer; - act(() => { + void act(() => { tree = renderer.create(); }); const json = JSON.stringify(tree!.toJSON()); @@ -178,7 +195,7 @@ describe('MapScreen — with location and tasks', () => { it('renders a radius circle matching the active filter', () => { defaultFeed([]); let tree: renderer.ReactTestRenderer; - act(() => { + void act(() => { tree = renderer.create(); }); const json = JSON.stringify(tree!.toJSON()); @@ -189,7 +206,7 @@ describe('MapScreen — with location and tasks', () => { const tasks = [makeTask('a', 51.5, -0.1), makeTask('b', 51.6, -0.2)]; defaultFeed(tasks); let tree: renderer.ReactTestRenderer; - act(() => { + void act(() => { tree = renderer.create(); }); const json = JSON.stringify(tree!.toJSON()); @@ -200,7 +217,7 @@ describe('MapScreen — with location and tasks', () => { it('does NOT show the no-location banner', () => { defaultFeed([]); let tree: renderer.ReactTestRenderer; - act(() => { + void act(() => { tree = renderer.create(); }); const json = JSON.stringify(tree!.toJSON()); @@ -214,7 +231,7 @@ describe('MapScreen — with location and tasks', () => { ); defaultFeed(clusteredTasks); let tree: renderer.ReactTestRenderer; - act(() => { + void act(() => { tree = renderer.create(); }); const json = JSON.stringify(tree!.toJSON()); @@ -228,7 +245,7 @@ describe('MapScreen — with location and tasks', () => { const task = makeTask('task-1', 51.5, -0.1); defaultFeed([task]); let tree: renderer.ReactTestRenderer; - act(() => { + void act(() => { tree = renderer.create(); }); @@ -237,7 +254,7 @@ describe('MapScreen — with location and tasks', () => { n => n.props.testID === 'marker-task-1', ); expect(markerNode.length).toBeGreaterThan(0); - act(() => { + void act(() => { markerNode[0]!.props.onCalloutPress?.(); }); diff --git a/src/__tests__/MapScreenPerformance.test.tsx b/src/__tests__/MapScreenPerformance.test.tsx index dd9809d..ef662a4 100644 --- a/src/__tests__/MapScreenPerformance.test.tsx +++ b/src/__tests__/MapScreenPerformance.test.tsx @@ -1,25 +1,38 @@ -import React from 'react'; +import React, { + forwardRef as mockForwardRef, + useImperativeHandle as mockUseImperativeHandle, +} from 'react'; +import { View as MockView } from 'react-native'; import renderer, { act } from 'react-test-renderer'; +interface MockMapViewRef { + animateToRegion: (region: unknown, duration?: number) => void; +} + +interface MockMarkerProps { + children?: React.ReactNode; + testID?: string; + title?: string; + description?: string; + onCalloutPress?: () => void; +} + jest.mock('react-native-maps', () => { - const MockReact = require('react'); - const { View } = require('react-native'); - - const MapView = MockReact.forwardRef( - ( - props: { - children?: React.ReactNode; - testID?: string; - onRegionChangeComplete?: (region: any) => void; - }, - ref: React.Ref, - ) => { - MockReact.useImperativeHandle(ref, () => ({ - animateToRegion: jest.fn(), - })); - return {props.children}; - }, - ); + const MapView = mockForwardRef< + MockMapViewRef, + { + children?: React.ReactNode; + testID?: string; + onRegionChangeComplete?: (region: unknown) => void; + } + >((props, ref) => { + mockUseImperativeHandle(ref, () => ({ + animateToRegion: jest.fn(), + })); + return ( + {props.children} + ); + }); const Marker = ({ children, @@ -27,10 +40,10 @@ jest.mock('react-native-maps', () => { title, description, onCalloutPress, - }: any) => ( - + }: MockMarkerProps) => ( + {children} - { return false; }} /> - {title} - {description} - + {title} + {description} + ); - const Circle = ({ testID }: any) => ; + const Circle = ({ testID }: { testID?: string }) => ( + + ); return { __esModule: true, diff --git a/src/__tests__/NetworkStatusContext.test.tsx b/src/__tests__/NetworkStatusContext.test.tsx index e13de9e..bda8ba1 100644 --- a/src/__tests__/NetworkStatusContext.test.tsx +++ b/src/__tests__/NetworkStatusContext.test.tsx @@ -6,9 +6,14 @@ import NetInfo from '@react-native-community/netinfo'; import { NetworkStatusProvider, useNetworkStatus, + NetworkStatus, } from '../hooks/useNetworkStatus'; -function HookConsumer({ onStatus }: { onStatus: (status: any) => void }) { +function HookConsumer({ + onStatus, +}: { + onStatus: (status: NetworkStatus) => void; +}) { const status = useNetworkStatus(); React.useEffect(() => { onStatus(status); @@ -28,9 +33,9 @@ describe('NetworkStatusProvider and Context', () => { await act(async () => { renderer.create( - {}} /> - {}} /> - {}} /> + undefined} /> + undefined} /> + undefined} /> , ); }); @@ -42,14 +47,14 @@ describe('NetworkStatusProvider and Context', () => { it('propagates state changes to all components in the context tree', async () => { (NetInfo.fetch as jest.Mock).mockResolvedValue({ isConnected: true }); - let listener: (state: any) => void = () => {}; + let listener: (state: { isConnected: boolean }) => void = () => undefined; (NetInfo.addEventListener as jest.Mock).mockImplementation(cb => { listener = cb; return jest.fn(); }); - let status1: any; - let status2: any; + let status1!: NetworkStatus; + let status2!: NetworkStatus; await act(async () => { renderer.create( @@ -63,7 +68,7 @@ describe('NetworkStatusProvider and Context', () => { expect(status1.isConnected).toBe(true); expect(status2.isConnected).toBe(true); - act(() => { + void act(() => { listener({ isConnected: false }); }); @@ -72,16 +77,17 @@ describe('NetworkStatusProvider and Context', () => { }); it('handles initialisation state correctly during NetInfo.fetch delay', async () => { - let resolveFetch: (value: any) => void = () => {}; + let resolveFetch: (value: { isConnected: boolean }) => void = () => + undefined; (NetInfo.fetch as jest.Mock).mockReturnValue( new Promise(resolve => { resolveFetch = resolve; }), ); - let status: any; + let status!: NetworkStatus; - act(() => { + void act(() => { renderer.create( (status = s)} /> diff --git a/src/__tests__/OnboardingScreen.test.tsx b/src/__tests__/OnboardingScreen.test.tsx index 0ef127e..e8363fe 100644 --- a/src/__tests__/OnboardingScreen.test.tsx +++ b/src/__tests__/OnboardingScreen.test.tsx @@ -34,25 +34,25 @@ const createInAppWallet = jest.fn(); const importWallet = jest.fn(); const authenticate = jest.fn(); -type WalletHookState = { +interface WalletHookState { connectFreighter: jest.Mock; connectLobstr: jest.Mock; createInAppWallet: jest.Mock; importWallet: jest.Mock; isConnecting: boolean; error: string | null; -}; +} -type AuthHookState = { +interface AuthHookState { authenticate: jest.Mock; isAuthenticating: boolean; error: string | null; -}; +} -type WalletStoreState = { +interface WalletStoreState { publicKey: string | null; isConnected: boolean; -}; +} let walletHookState: WalletHookState; let authHookState: AuthHookState; @@ -126,7 +126,7 @@ describe('OnboardingScreen', () => { authenticate.mockResolvedValue(undefined); createInAppWallet.mockResolvedValue(undefined); importWallet.mockResolvedValue(undefined); - alertSpy = jest.spyOn(Alert, 'alert').mockImplementation(() => {}); + alertSpy = jest.spyOn(Alert, 'alert').mockImplementation(() => undefined); }); afterEach(() => { diff --git a/src/__tests__/PendingProofsBanner.test.tsx b/src/__tests__/PendingProofsBanner.test.tsx index 32ff3c9..a484804 100644 --- a/src/__tests__/PendingProofsBanner.test.tsx +++ b/src/__tests__/PendingProofsBanner.test.tsx @@ -31,7 +31,8 @@ describe('PendingProofsBanner', () => { }); it('disables Retry and shows a neutral message before initialisation completes', async () => { - let resolveFetch: (value: any) => void = () => {}; + let resolveFetch: (value: { isConnected: boolean }) => void = () => + undefined; (NetInfo.fetch as jest.Mock).mockReturnValue( new Promise(resolve => { resolveFetch = resolve; @@ -39,7 +40,7 @@ describe('PendingProofsBanner', () => { ); let tree: renderer.ReactTestRenderer; - act(() => { + void act(() => { tree = renderer.create( , ); diff --git a/src/__tests__/__mocks__/rn-modules.ts b/src/__tests__/__mocks__/rn-modules.ts index 793208f..e09d989 100644 --- a/src/__tests__/__mocks__/rn-modules.ts +++ b/src/__tests__/__mocks__/rn-modules.ts @@ -62,7 +62,7 @@ jest.mock( ), getGenericPassword: jest.fn(async (opts?: { service?: string }) => { const key = opts?.service || 'default'; - return store[key] || false; + return key in store ? store[key] : false; }), resetGenericPassword: jest.fn(async (opts?: { service?: string }) => { const key = opts?.service || 'default'; diff --git a/src/__tests__/__mocks__/setup.ts b/src/__tests__/__mocks__/setup.ts index 5296274..018ee55 100644 --- a/src/__tests__/__mocks__/setup.ts +++ b/src/__tests__/__mocks__/setup.ts @@ -14,24 +14,36 @@ jest.mock('react-native-mmkv', () => { }; }); +type ZustandSet = (...args: unknown[]) => void; +type ZustandGet = (...args: unknown[]) => unknown; +type ZustandStateCreator = ( + set: ZustandSet, + get: ZustandGet, + api: unknown, +) => unknown; +interface ZustandPersistOptions { + name?: string; + storage?: { getItem: (name: string) => string | null }; +} + jest.mock('zustand/middleware', () => { // simple in-memory JSON storage adapter for tests return { - persist: (config: any, options: any) => { + persist: (config: ZustandStateCreator, options?: ZustandPersistOptions) => { // options may include name and storage const storage = options?.storage; const name = options?.name || 'zustand-test'; // create a wrapped config that uses the provided set/get - return (set: any, get: any, api: any) => { + return (set: ZustandSet, get: ZustandGet, api: unknown) => { // persist storage helpers are intentionally unused in tests // (storage is accessed directly via createJSONStorage mock below) // if storage has existing data, try to hydrate by calling set with parsed JSON - const existing = storage.getItem(name); + const existing = storage?.getItem(name); if (existing) { try { - const parsed = JSON.parse(existing); + const parsed: unknown = JSON.parse(existing); // apply initial state by calling set set(() => parsed); } catch {} diff --git a/src/__tests__/authRefresh.test.ts b/src/__tests__/authRefresh.test.ts index cc7d783..3b75ece 100644 --- a/src/__tests__/authRefresh.test.ts +++ b/src/__tests__/authRefresh.test.ts @@ -43,7 +43,23 @@ function makeJwt(exp: number): string { return `${header}.${payload}.signature`; } -function successResponse(data: unknown, config: unknown) { +interface MockAxiosConfig { + url?: string; + headers?: Record; + [key: string]: unknown; +} + +interface MockAxiosError extends Error { + config?: MockAxiosConfig; + response?: { + status: number; + statusText: string; + data: unknown; + headers: Record; + }; +} + +function successResponse(data: unknown, config: MockAxiosConfig) { return { data, status: 200, @@ -53,8 +69,10 @@ function successResponse(data: unknown, config: unknown) { }; } -function unauthorizedError(config: unknown) { - const error: any = new Error('Request failed with status code 401'); +function unauthorizedError(config: MockAxiosConfig) { + const error: MockAxiosError = new Error( + 'Request failed with status code 401', + ); error.config = config; error.response = { status: 401, @@ -66,7 +84,7 @@ function unauthorizedError(config: unknown) { } function mockAuthEndpoints(adapter: jest.Mock): void { - adapter.mockImplementation(async (config: any) => { + adapter.mockImplementation(async (config: MockAxiosConfig) => { if (config.url === '/auth/challenge') { return successResponse({ challenge: 'challenge-xdr' }, config); } @@ -78,7 +96,7 @@ function mockAuthEndpoints(adapter: jest.Mock): void { } const adapter = jest.fn(); -api.defaults.adapter = adapter as any; +api.defaults.adapter = adapter as unknown as typeof api.defaults.adapter; beforeEach(() => { useUserStore.setState({ profile: null, token: null, tokenExpiresAt: null }); @@ -160,9 +178,9 @@ describe('request interceptor', () => { .getState() .setToken(makeJwt(Math.floor(Date.now() / 1000) - 60)); - adapter.mockImplementationOnce(async (config: any) => { + adapter.mockImplementationOnce(async (config: MockAxiosConfig) => { if (config.url === '/tasks') { - expect(config.headers.Authorization).toBe(`Bearer ${NEW_TOKEN}`); + expect(config.headers?.Authorization).toBe(`Bearer ${NEW_TOKEN}`); } return successResponse({ tasks: [] }, config); }); @@ -181,9 +199,11 @@ describe('response interceptor', () => { useWalletStore.getState().connect('GCKEY'); useUserStore.getState().setToken(makeJwt(EXP)); mockedGetInAppSecret.mockReturnValue(null); // no signing key => refresh fails - const alertSpy = jest.spyOn(Alert, 'alert').mockImplementation(() => {}); + const alertSpy = jest + .spyOn(Alert, 'alert') + .mockImplementation(() => undefined); - adapter.mockImplementation(async (config: any) => { + adapter.mockImplementation(async (config: MockAxiosConfig) => { if (config.url === '/auth/challenge') { return successResponse({ challenge: 'challenge-xdr' }, config); } @@ -208,7 +228,7 @@ describe('response interceptor', () => { mockedSignChallengeXDR.mockReturnValue('signed-xdr'); let taskRequests = 0; - adapter.mockImplementation(async (config: any) => { + adapter.mockImplementation(async (config: MockAxiosConfig) => { if (config.url === '/auth/challenge') { return successResponse({ challenge: 'challenge-xdr' }, config); } @@ -223,13 +243,13 @@ describe('response interceptor', () => { if (taskRequests === 1) { return unauthorizedError(config); } - expect(config.headers.Authorization).toBe(`Bearer ${NEW_TOKEN}`); + expect(config.headers?.Authorization).toBe(`Bearer ${NEW_TOKEN}`); return successResponse({ tasks: [{ id: 't1' }] }, config); } return successResponse({}, config); }); - const result: any = await api.get('/tasks'); + const result = await api.get<{ tasks: { id: string }[] }>('/tasks'); expect(result.data.tasks).toEqual([{ id: 't1' }]); expect(taskRequests).toBe(2); diff --git a/src/__tests__/notifications.test.ts b/src/__tests__/notifications.test.ts index 092843c..6f73fc8 100644 --- a/src/__tests__/notifications.test.ts +++ b/src/__tests__/notifications.test.ts @@ -68,7 +68,7 @@ jest.mock('react-native', () => { }; const Platform = { OS: 'android' as const, - select: (obj: any) => obj.android ?? obj.default, + select: (obj: Record) => obj.android ?? obj.default, }; return { __esModule: true, @@ -135,7 +135,7 @@ function resetPrefs() { usePrefsStore.setState({ notificationPrefs: defaults, quietHours: { from: '00:00', to: '00:00' }, - } as any); + }); } function resetActivity() { @@ -228,7 +228,7 @@ describe('listenForTokenRefresh', () => { listenForTokenRefresh(t => received.push(t)); - if (capturedHandler) { + if (capturedHandler != null) { (capturedHandler as (token: string) => void)('rotated-token-xyz'); } expect(received).toContain('rotated-token-xyz'); diff --git a/src/__tests__/proofQueue.test.ts b/src/__tests__/proofQueue.test.ts index fc0cc10..dcf9bc8 100644 --- a/src/__tests__/proofQueue.test.ts +++ b/src/__tests__/proofQueue.test.ts @@ -1,4 +1,5 @@ import './__mocks__/setup'; +import { MMKV } from 'react-native-mmkv'; import { loadQueue, saveQueue, @@ -114,7 +115,6 @@ describe('proofQueue', () => { }); it('returns an empty array when storage is corrupted', () => { - const { MMKV } = require('react-native-mmkv'); const instance = new MMKV(); instance.set('pending_proofs', 'not-json{'); expect(loadQueue()).toEqual([]); diff --git a/src/__tests__/smoke.test.ts b/src/__tests__/smoke.test.ts index 7bbdba6..df4493c 100644 --- a/src/__tests__/smoke.test.ts +++ b/src/__tests__/smoke.test.ts @@ -1,10 +1,11 @@ +import * as validation from '../utils/validation'; + describe('test infrastructure', () => { it('jest is functional', () => { expect(true).toBe(true); }); it('can import from the project', () => { - const validation = require('../utils/validation'); expect(validation).toBeDefined(); }); }); diff --git a/src/__tests__/stellarPayment.test.ts b/src/__tests__/stellarPayment.test.ts index eccd12d..a0b44fe 100644 --- a/src/__tests__/stellarPayment.test.ts +++ b/src/__tests__/stellarPayment.test.ts @@ -4,6 +4,7 @@ import { Networks, Account, TransactionBuilder, + Operation, } from '@stellar/stellar-sdk'; import { buildPaymentXDR, @@ -31,7 +32,7 @@ describe('buildPaymentXDR', () => { expect(source).toBe(kp.publicKey()); if ('operations' in parsed) { expect(parsed.operations).toHaveLength(1); - const op = parsed.operations[0] as any; + const op = parsed.operations[0] as Operation.Payment; expect(op.type).toBe('payment'); expect(op.destination).toBe(dest); expect(parseFloat(op.amount)).toBe(10.5); @@ -53,7 +54,7 @@ describe('buildPaymentXDR', () => { ); const parsed = TransactionBuilder.fromXDR(xdr, Networks.TESTNET); - const op = parsed.operations[0] as any; + const op = parsed.operations[0] as Operation.Payment; expect(op.asset.code).toBe('ECO'); expect(op.asset.issuer).toBe(kp.publicKey()); }); @@ -75,7 +76,7 @@ describe('buildPaymentXDR', () => { ); const parsed = TransactionBuilder.fromXDR(xdr, Networks.TESTNET); - const op = parsed.operations[0] as any; + const op = parsed.operations[0] as Operation.Payment; expect(op.asset.code).toBe('USDC'); expect(op.asset.issuer).toBe(usdcIssuer); expect(parseFloat(op.amount)).toBe(10); diff --git a/src/__tests__/submitScreen.test.tsx b/src/__tests__/submitScreen.test.tsx index eaebf08..5450218 100644 --- a/src/__tests__/submitScreen.test.tsx +++ b/src/__tests__/submitScreen.test.tsx @@ -1,5 +1,5 @@ import './__mocks__/setup'; -import React from 'react'; +import React, { useEffect as mockUseEffect } from 'react'; import renderer, { act } from 'react-test-renderer'; import { TouchableOpacity, Text } from 'react-native'; import SubmitScreen from '../screens/SubmitScreen'; @@ -11,7 +11,9 @@ const mockNavigate = jest.fn(); jest.mock('@react-navigation/native', () => ({ useNavigation: () => ({ navigate: mockNavigate }), // Run the focus effect like a normal effect for testing purposes. - useFocusEffect: (cb: () => void) => require('react').useEffect(cb, [cb]), + // (`mockUseEffect` rather than a plain import: jest.mock factories may + // only reference out-of-scope variables whose name starts with "mock".) + useFocusEffect: (cb: () => void) => mockUseEffect(cb, [cb]), })); const task: Task = { @@ -49,7 +51,7 @@ describe('SubmitScreen', () => { it('navigates to Tasks when "Browse Tasks" is pressed', () => { tree = renderer.create(); const button = tree.root.findAllByType(TouchableOpacity)[0]; - act(() => { + void act(() => { button?.props.onPress(); }); expect(mockNavigate).toHaveBeenCalledWith('Tasks'); @@ -57,7 +59,7 @@ describe('SubmitScreen', () => { it('auto-redirects to SubmitProof on focus when a fresh task is selected', () => { useTaskStore.getState().selectTask(task); - act(() => { + void act(() => { tree = renderer.create(); }); expect(mockNavigate).toHaveBeenCalledWith('SubmitProof', { diff --git a/src/__tests__/useAuth.stats.test.ts b/src/__tests__/useAuth.stats.test.ts index 6d64938..c7ebfbf 100644 --- a/src/__tests__/useAuth.stats.test.ts +++ b/src/__tests__/useAuth.stats.test.ts @@ -37,8 +37,14 @@ function successResponse(data: unknown, config: unknown) { return { data, status: 200, statusText: 'OK', headers: {}, config }; } +interface MockAxiosConfig { + url?: string; + headers?: Record; + [key: string]: unknown; +} + const adapter = jest.fn(); -api.defaults.adapter = adapter as any; +api.defaults.adapter = adapter as unknown as typeof api.defaults.adapter; function mockEndpoints({ loginUser = { id: 'u1', name: 'Ada' }, @@ -49,7 +55,7 @@ function mockEndpoints({ meResponse?: unknown; meFails?: boolean; } = {}) { - adapter.mockImplementation(async (config: any) => { + adapter.mockImplementation(async (config: MockAxiosConfig) => { if (config.url === '/auth/challenge') { return successResponse({ challenge: 'challenge-xdr' }, config); } @@ -90,7 +96,7 @@ describe('useAuth stat handling', () => { balance: null, ecoBalance: null, walletType: null, - } as any); + }); useWalletStore.getState().connect('GCKEY'); mockedGetInAppSecret.mockReturnValue('Ssecret123'); mockedSignChallengeXDR.mockReturnValue('signed-xdr'); diff --git a/src/__tests__/useLocation.test.tsx b/src/__tests__/useLocation.test.tsx index 812d6bc..837eb7f 100644 --- a/src/__tests__/useLocation.test.tsx +++ b/src/__tests__/useLocation.test.tsx @@ -6,6 +6,28 @@ import renderer, { act } from 'react-test-renderer'; import Geolocation from '@react-native-community/geolocation'; import { useLocation } from '../hooks/useLocation'; +type GeolocationPosition = Parameters< + Parameters[0] +>[0]; + +function makePosition( + latitude: number, + longitude: number, +): GeolocationPosition { + return { + coords: { + latitude, + longitude, + altitude: null, + accuracy: 0, + altitudeAccuracy: null, + heading: null, + speed: null, + }, + timestamp: Date.now(), + }; +} + function HookHarness({ onRef, }: { @@ -28,7 +50,7 @@ describe('useLocation hook', () => { afterEach(() => { if (instance) { - act(() => { + void act(() => { instance?.unmount(); }); instance = null; @@ -41,13 +63,13 @@ describe('useLocation hook', () => { .spyOn(PermissionsAndroid, 'request') .mockResolvedValue(PermissionsAndroid.RESULTS.GRANTED!); - let watchCallback: ((pos: any) => void) | null = null; + let watchCallback: ((pos: GeolocationPosition) => void) | null = null; const watchPositionMock = jest .spyOn(Geolocation, 'watchPosition') - .mockImplementation(((success: (pos: any) => void) => { + .mockImplementation(success => { watchCallback = success; return 123; - }) as any); + }); await act(async () => { instance = renderer.create( (hookRef = r)} />); @@ -68,11 +90,9 @@ describe('useLocation hook', () => { expect(hookRef.permissionGranted).toBe(true); // Simulate position update - act(() => { + void act(() => { if (watchCallback) { - watchCallback({ - coords: { latitude: 37.7749, longitude: -122.4194 }, - }); + watchCallback(makePosition(37.7749, -122.4194)); } }); @@ -100,18 +120,18 @@ describe('useLocation hook', () => { it('performs high-accuracy single fix on refresh()', async () => { Platform.OS = 'ios'; - let getCallback: ((pos: any) => void) | null = null; + let getCallback: ((pos: GeolocationPosition) => void) | null = null; const getCurrentPositionMock = jest .spyOn(Geolocation, 'getCurrentPosition') - .mockImplementation(((success: (pos: any) => void) => { + .mockImplementation(success => { getCallback = success; - }) as any); + }); await act(async () => { instance = renderer.create( (hookRef = r)} />); }); - act(() => { + void act(() => { hookRef.refresh(); }); @@ -124,11 +144,9 @@ describe('useLocation hook', () => { }), ); - act(() => { + void act(() => { if (getCallback) { - getCallback({ - coords: { latitude: 40.7128, longitude: -74.006 }, - }); + getCallback(makePosition(40.7128, -74.006)); } }); @@ -142,14 +160,14 @@ describe('useLocation hook', () => { Platform.OS = 'ios'; const clearWatchMock = jest .spyOn(Geolocation, 'clearWatch') - .mockImplementation(() => {}); - jest.spyOn(Geolocation, 'watchPosition').mockReturnValue(456 as any); + .mockImplementation(() => undefined); + jest.spyOn(Geolocation, 'watchPosition').mockReturnValue(456); await act(async () => { instance = renderer.create( (hookRef = r)} />); }); - act(() => { + void act(() => { instance?.unmount(); instance = null; }); diff --git a/src/__tests__/useNetworkStatus.test.tsx b/src/__tests__/useNetworkStatus.test.tsx index 858bdea..3171a9f 100644 --- a/src/__tests__/useNetworkStatus.test.tsx +++ b/src/__tests__/useNetworkStatus.test.tsx @@ -6,9 +6,10 @@ import NetInfo from '@react-native-community/netinfo'; import { useNetworkStatus, NetworkStatusProvider, + NetworkStatus, } from '../hooks/useNetworkStatus'; -function HookHarnessInner({ onRef }: { onRef: (ref: any) => void }) { +function HookHarnessInner({ onRef }: { onRef: (ref: NetworkStatus) => void }) { const status = useNetworkStatus(); React.useEffect(() => { onRef(status); @@ -16,7 +17,7 @@ function HookHarnessInner({ onRef }: { onRef: (ref: any) => void }) { return null; } -function HookHarness({ onRef }: { onRef: (ref: any) => void }) { +function HookHarness({ onRef }: { onRef: (ref: NetworkStatus) => void }) { return ( @@ -31,15 +32,16 @@ describe('useNetworkStatus', () => { }); it('does not report initialised until NetInfo.fetch resolves', async () => { - let resolveFetch: (value: any) => void = () => {}; + let resolveFetch: (value: { isConnected: boolean }) => void = () => + undefined; (NetInfo.fetch as jest.Mock).mockReturnValue( new Promise(resolve => { resolveFetch = resolve; }), ); - let ref: any; - act(() => { + let ref!: NetworkStatus; + void act(() => { renderer.create( (ref = r)} />); }); @@ -58,7 +60,7 @@ describe('useNetworkStatus', () => { (NetInfo.fetch as jest.Mock).mockResolvedValue({ isConnected: true }); await act(async () => { - renderer.create( {}} />); + renderer.create( undefined} />); }); expect(NetInfo.fetch).toHaveBeenCalledTimes(1); @@ -67,7 +69,7 @@ describe('useNetworkStatus', () => { it('returns isConnected false when NetInfo.fetch resolves as disconnected on mount', async () => { (NetInfo.fetch as jest.Mock).mockResolvedValue({ isConnected: false }); - let ref: any; + let ref!: NetworkStatus; await act(async () => { renderer.create( (ref = r)} />); }); @@ -78,20 +80,20 @@ describe('useNetworkStatus', () => { it('updates isConnected when NetInfo emits a later connectivity change', async () => { (NetInfo.fetch as jest.Mock).mockResolvedValue({ isConnected: true }); - let listener: (state: any) => void = () => {}; + let listener: (state: { isConnected: boolean }) => void = () => undefined; (NetInfo.addEventListener as jest.Mock).mockImplementation(cb => { listener = cb; return jest.fn(); }); - let ref: any; + let ref!: NetworkStatus; await act(async () => { renderer.create( (ref = r)} />); }); expect(ref.isConnected).toBe(true); - act(() => { + void act(() => { listener({ isConnected: false }); }); @@ -105,10 +107,10 @@ describe('useNetworkStatus', () => { let tree: renderer.ReactTestRenderer | undefined; await act(async () => { - tree = renderer.create( {}} />); + tree = renderer.create( undefined} />); }); - act(() => { + void act(() => { tree?.unmount(); }); diff --git a/src/__tests__/useProofStatus.test.tsx b/src/__tests__/useProofStatus.test.tsx index b33e288..7e0773e 100644 --- a/src/__tests__/useProofStatus.test.tsx +++ b/src/__tests__/useProofStatus.test.tsx @@ -62,7 +62,7 @@ function mountHook( return null; } let tree: renderer.ReactTestRenderer; - act(() => { + void act(() => { tree = renderer.create(); }); return { @@ -220,7 +220,8 @@ describe('useProofStatus', () => { it('pauses polling while offline and resumes on reconnect', async () => { // Start offline — NetInfo.fetch resolves immediately as disconnected. (NetInfo.fetch as jest.Mock).mockResolvedValue({ isConnected: false }); - let netInfoListener: ((state: any) => void) | null = null; + let netInfoListener: ((state: { isConnected: boolean }) => void) | null = + null; (NetInfo.addEventListener as jest.Mock).mockImplementation(cb => { cb({ isConnected: false }); netInfoListener = cb; @@ -289,7 +290,7 @@ describe('useProofStatus', () => { rewardToken: 'ECO', }); - unmount(); + void unmount(); // After unmount, advancing timers should not trigger further API calls. await act(async () => { diff --git a/src/__tests__/useProofSubmit.ipfs.test.tsx b/src/__tests__/useProofSubmit.ipfs.test.tsx index af7ee62..a0da695 100644 --- a/src/__tests__/useProofSubmit.ipfs.test.tsx +++ b/src/__tests__/useProofSubmit.ipfs.test.tsx @@ -10,7 +10,19 @@ import * as ipfs from '../services/ipfs'; import * as api from '../services/api'; import { clearQueue, loadQueue } from '../services/proofQueue'; -function HookHarness({ onRef }: { onRef: (ref: any) => void }) { +type UseProofSubmitResult = ReturnType; + +interface FormDataLike { + get?: (key: string) => unknown; + _parts?: [string, unknown][]; + getParts?: () => { fieldName: string; string?: unknown }[]; +} + +function HookHarness({ + onRef, +}: { + onRef: (ref: UseProofSubmitResult) => void; +}) { const hook = useProofSubmit(); React.useEffect(() => { onRef(hook); @@ -38,11 +50,16 @@ describe('useProofSubmit IPFS resilience', () => { .mockResolvedValueOnce({ cid: 'QmRetry', url: 'https://ipfs.io/ipfs/QmRetry', - } as any); - jest.spyOn(ipfs, 'pinJSON').mockResolvedValue({ cid: 'QmMeta' } as any); - jest.spyOn(api, 'submitProof').mockResolvedValue({ success: true } as any); + size: 100, + }); + jest.spyOn(ipfs, 'pinJSON').mockResolvedValue({ + cid: 'QmMeta', + url: 'https://ipfs.io/ipfs/QmMeta', + size: 50, + }); + jest.spyOn(api, 'submitProof').mockResolvedValue({ taskTitle: 'success' }); - let ref: any; + let ref!: UseProofSubmitResult; await act(async () => { renderer.create( (ref = r)} />); }); @@ -55,7 +72,7 @@ describe('useProofSubmit IPFS resilience', () => { ); // Submission succeeded and IPFS eventually pinned, so no ipfsPending flag. expect(res.status).toBe('success'); - expect((res as any).ipfsPending).toBeFalsy(); + expect(res.ipfsPending).toBeFalsy(); expect(ref.ipfsPending).toBe(false); }); @@ -68,11 +85,12 @@ describe('useProofSubmit IPFS resilience', () => { ); // The retried CID is what gets submitted. - const formDataArg = (api.submitProof as jest.Mock).mock.calls[0][0] as any; + const formDataArg = (api.submitProof as jest.Mock).mock + .calls[0]![0] as unknown as FormDataLike; const photoCid = typeof formDataArg.get === 'function' ? formDataArg.get('ipfsPhotoCid') - : formDataArg._parts?.find((p: any) => p[0] === 'ipfsPhotoCid')?.[1]; + : formDataArg._parts?.find(p => p[0] === 'ipfsPhotoCid')?.[1]; expect(photoCid).toBe('QmRetry'); }); @@ -85,9 +103,9 @@ describe('useProofSubmit IPFS resilience', () => { .mockRejectedValue(new Error('ipfs down for good')); const submitProof = jest .spyOn(api, 'submitProof') - .mockResolvedValue({ ok: true } as any); + .mockResolvedValue({ taskTitle: 'ok' }); - let ref: any; + let ref!: UseProofSubmitResult; await act(async () => { renderer.create( (ref = r)} />); }); @@ -106,11 +124,12 @@ describe('useProofSubmit IPFS resilience', () => { // 1 initial attempt + 1 retry. expect(ipfs.pinFile as jest.Mock).toHaveBeenCalledTimes(2); // Submission still proceeds (best-effort) without a photo CID. - const formDataArg = (submitProof as jest.Mock).mock.calls[0][0] as any; + const formDataArg = (submitProof as jest.Mock).mock + .calls[0]![0] as unknown as FormDataLike; const hasPhotoCid = typeof formDataArg.get === 'function' ? formDataArg.get('ipfsPhotoCid') - : formDataArg._parts?.some((p: any) => p[0] === 'ipfsPhotoCid'); + : formDataArg._parts?.some(p => p[0] === 'ipfsPhotoCid'); expect(hasPhotoCid).toBeFalsy(); // The final failure is logged with the underlying error details. expect(warnSpy).toHaveBeenCalledWith( @@ -126,12 +145,17 @@ describe('useProofSubmit IPFS resilience', () => { .mockResolvedValueOnce({ cid: 'QmFromRetry', url: 'https://ipfs.io/ipfs/QmFromRetry', - } as any); - jest.spyOn(ipfs, 'pinJSON').mockResolvedValue({ cid: 'QmMeta' } as any); + size: 100, + }); + jest.spyOn(ipfs, 'pinJSON').mockResolvedValue({ + cid: 'QmMeta', + url: 'https://ipfs.io/ipfs/QmMeta', + size: 50, + }); // Upload itself fails so the proof is queued. jest.spyOn(api, 'submitProof').mockRejectedValue(new Error('network')); - let ref: any; + let ref!: UseProofSubmitResult; await act(async () => { renderer.create( (ref = r)} />); }); diff --git a/src/__tests__/useProofSubmit.test.tsx b/src/__tests__/useProofSubmit.test.tsx index 615d341..c59e282 100644 --- a/src/__tests__/useProofSubmit.test.tsx +++ b/src/__tests__/useProofSubmit.test.tsx @@ -9,9 +9,19 @@ import { useProofSubmit } from '../hooks/useProofSubmit'; import * as ipfs from '../services/ipfs'; import * as api from '../services/api'; import { clearQueue, loadQueue } from '../services/proofQueue'; +import * as proofQueueService from '../services/proofQueue'; import { useProofSyncStore } from '../store/proofSyncStore'; +import { SubmitProofResult } from '../types'; -function HookHarness({ onRef }: { onRef: (ref: any) => void }) { +type UseProofSubmitResult = ReturnType; + +const CAPTURED_AT = '2026-01-01T00:00:00.000Z'; + +function HookHarness({ + onRef, +}: { + onRef: (hook: UseProofSubmitResult) => void; +}) { const hook = useProofSubmit(); React.useEffect(() => { onRef(hook); @@ -26,12 +36,21 @@ describe('useProofSubmit integration', () => { }); test('online happy path', async () => { - const ipfsFile = { cid: 'Qm123', url: 'https://ipfs.io/ipfs/Qm123' }; - jest.spyOn(ipfs, 'pinFile').mockResolvedValue(ipfsFile as any); - jest.spyOn(ipfs, 'pinJSON').mockResolvedValue({ cid: 'QmMeta' } as any); - jest.spyOn(api, 'submitProof').mockResolvedValue({ success: true } as any); + const ipfsFile = { + cid: 'Qm123', + url: 'https://ipfs.io/ipfs/Qm123', + size: 100, + }; + jest.spyOn(ipfs, 'pinFile').mockResolvedValue(ipfsFile); + jest.spyOn(ipfs, 'pinJSON').mockResolvedValue({ + cid: 'QmMeta', + url: 'https://ipfs.io/ipfs/QmMeta', + size: 50, + }); + const submitResult: SubmitProofResult = { taskTitle: 'success' }; + jest.spyOn(api, 'submitProof').mockResolvedValue(submitResult); - let ref: any; + let ref!: UseProofSubmitResult; await act(async () => { renderer.create( (ref = r)} />); }); @@ -40,13 +59,13 @@ describe('useProofSubmit integration', () => { const res = await ref.submit( 'task-1', '/path/photo.jpg', - '2026-01-01T00:00:00.000Z', + CAPTURED_AT, 1, 2, ); expect(res).toEqual({ status: 'success', - result: { success: true }, + result: submitResult, }); // After a successful POST the hook stays at 'verifying'; useProofStatus // drives the transition to 'confirmed' once the backend responds. @@ -56,11 +75,21 @@ describe('useProofSubmit integration', () => { }); test('undefined API response returns an explicit failed result', async () => { - jest.spyOn(ipfs, 'pinFile').mockResolvedValue({ cid: 'QmX' } as any); - jest.spyOn(ipfs, 'pinJSON').mockResolvedValue({ cid: 'QmMeta' } as any); - jest.spyOn(api, 'submitProof').mockResolvedValue(undefined as any); + jest.spyOn(ipfs, 'pinFile').mockResolvedValue({ + cid: 'QmX', + url: 'https://ipfs.io/ipfs/QmX', + size: 10, + }); + jest.spyOn(ipfs, 'pinJSON').mockResolvedValue({ + cid: 'QmMeta', + url: 'https://ipfs.io/ipfs/QmMeta', + size: 50, + }); + jest + .spyOn(api, 'submitProof') + .mockResolvedValue(undefined as unknown as SubmitProofResult); - let ref: any; + let ref!: UseProofSubmitResult; await act(async () => { renderer.create( (ref = r)} />); }); @@ -82,24 +111,28 @@ describe('useProofSubmit integration', () => { }); test('offline enqueue then sync', async () => { - jest.spyOn(ipfs, 'pinFile').mockResolvedValue({ cid: 'QmX' } as any); - jest.spyOn(ipfs, 'pinJSON').mockResolvedValue({ cid: 'QmMeta' } as any); + jest.spyOn(ipfs, 'pinFile').mockResolvedValue({ + cid: 'QmX', + url: 'https://ipfs.io/ipfs/QmX', + size: 10, + }); + jest.spyOn(ipfs, 'pinJSON').mockResolvedValue({ + cid: 'QmMeta', + url: 'https://ipfs.io/ipfs/QmMeta', + size: 50, + }); jest .spyOn(api, 'submitProof') .mockRejectedValueOnce(new Error('network')) - .mockResolvedValueOnce({ ok: true } as any); + .mockResolvedValueOnce({ taskTitle: 'ok' }); - let ref: any; + let ref!: UseProofSubmitResult; await act(async () => { renderer.create( (ref = r)} />); }); await act(async () => { - const res = await ref.submit( - 'task-2', - '/path/p.jpg', - '2026-01-01T00:00:00.000Z', - ); + const res = await ref.submit('task-2', '/path/p.jpg', CAPTURED_AT); expect(res).toEqual({ status: 'queued', error: 'Upload failed, saved for later: network', @@ -118,9 +151,10 @@ describe('useProofSubmit integration', () => { test('ipfs failure handled (still attempts submit)', async () => { jest.spyOn(ipfs, 'pinFile').mockRejectedValue(new Error('ipfs down')); jest.spyOn(ipfs, 'pinJSON').mockRejectedValue(new Error('ipfs down')); - jest.spyOn(api, 'submitProof').mockResolvedValue({ ok: true } as any); + const submitResult: SubmitProofResult = { taskTitle: 'ok' }; + jest.spyOn(api, 'submitProof').mockResolvedValue(submitResult); - let ref: any; + let ref!: UseProofSubmitResult; await act(async () => { renderer.create( (ref = r)} />); }); @@ -133,7 +167,7 @@ describe('useProofSubmit integration', () => { ); expect(res).toEqual({ status: 'success', - result: { ok: true }, + result: { taskTitle: 'ok' }, ipfsPending: true, }); // Progress stays at 'verifying' — polling will drive to 'confirmed'. @@ -146,9 +180,9 @@ describe('useProofSubmit integration', () => { jest .spyOn(api, 'submitProof') .mockRejectedValueOnce(new Error('fail1')) - .mockResolvedValueOnce({ ok: true } as any); + .mockResolvedValueOnce({ taskTitle: 'ok' }); // enqueue two proofs manually via the submit failure path - let ref: any; + let ref!: UseProofSubmitResult; await act(async () => { renderer.create( (ref = r)} />); }); @@ -156,19 +190,19 @@ describe('useProofSubmit integration', () => { await act(async () => { // first submit fails and enqueues jest.spyOn(ipfs, 'pinFile').mockRejectedValue(new Error('ipfs')); - await ref.submit('task-A', '/a.jpg'); + await ref.submit('task-A', '/a.jpg', CAPTURED_AT); // second submit fails and enqueues (simulate network) jest .spyOn(api, 'submitProof') .mockRejectedValueOnce(new Error('network')); - await ref.submit('task-B', '/b.jpg'); + await ref.submit('task-B', '/b.jpg', CAPTURED_AT); }); // now make next sync attempt: first will fail, second will succeed jest .spyOn(api, 'submitProof') .mockRejectedValueOnce(new Error('still-fail')) - .mockResolvedValueOnce({ ok: true } as any); + .mockResolvedValueOnce({ taskTitle: 'ok' }); await act(async () => { await ref.syncPendingProofs(); @@ -178,6 +212,12 @@ describe('useProofSubmit integration', () => { }); }); +interface FormDataLike { + get?: (key: string) => unknown; + _parts?: [string, unknown][]; + getParts?: () => { fieldName: string; string?: unknown }[]; +} + describe('useProofSubmit retry logic', () => { beforeEach(() => { jest.clearAllMocks(); @@ -189,10 +229,7 @@ describe('useProofSubmit retry logic', () => { const mockPinFile = jest.spyOn(ipfs, 'pinFile'); const mockPinJSON = jest.spyOn(ipfs, 'pinJSON'); const mockSubmitProof = jest.spyOn(api, 'submitProof'); - const mockLoadQueue = jest.spyOn( - require('../services/proofQueue'), - 'loadQueue', - ); + const mockLoadQueue = jest.spyOn(proofQueueService, 'loadQueue'); mockLoadQueue.mockReturnValue([ { @@ -204,10 +241,10 @@ describe('useProofSubmit retry logic', () => { photoCid: 'QmPhoto', metadataCid: 'QmMeta', }, - ] as any); - mockSubmitProof.mockResolvedValue({} as any); + ]); + mockSubmitProof.mockResolvedValue({}); - let hookResult: any; + let hookResult!: UseProofSubmitResult; function TestComponent() { hookResult = useProofSubmit(); return null; @@ -223,7 +260,8 @@ describe('useProofSubmit retry logic', () => { expect(mockPinFile).not.toHaveBeenCalled(); expect(mockPinJSON).not.toHaveBeenCalled(); - const formDataArg = (mockSubmitProof as jest.Mock).mock.calls[0][0] as any; + const formDataArg = mockSubmitProof.mock + .calls[0]![0] as unknown as FormDataLike; // Extract ipfs CIDs from FormData variations let photoCid; @@ -233,18 +271,14 @@ describe('useProofSubmit retry logic', () => { photoCid = formDataArg.get('ipfsPhotoCid'); metadataCid = formDataArg.get('ipfsMetadataCid'); } else if (formDataArg._parts) { - photoCid = formDataArg._parts.find( - (p: any) => p[0] === 'ipfsPhotoCid', - )?.[1]; + photoCid = formDataArg._parts.find(p => p[0] === 'ipfsPhotoCid')?.[1]; metadataCid = formDataArg._parts.find( - (p: any) => p[0] === 'ipfsMetadataCid', + p => p[0] === 'ipfsMetadataCid', )?.[1]; } else if (typeof formDataArg.getParts === 'function') { const parts = formDataArg.getParts(); - photoCid = parts.find((p: any) => p.fieldName === 'ipfsPhotoCid')?.string; - metadataCid = parts.find( - (p: any) => p.fieldName === 'ipfsMetadataCid', - )?.string; + photoCid = parts.find(p => p.fieldName === 'ipfsPhotoCid')?.string; + metadataCid = parts.find(p => p.fieldName === 'ipfsMetadataCid')?.string; } expect(photoCid).toBe('QmPhoto'); @@ -281,7 +315,7 @@ describe('useProofSubmit retry logic', () => { mockLoadQueue.mockReturnValue(pendingProofs); mockSubmitProof.mockResolvedValue({}); - let hookResult: any; + let hookResult!: UseProofSubmitResult; function TestComponent() { hookResult = useProofSubmit(); return null; diff --git a/src/__tests__/useTaskFeed.test.ts b/src/__tests__/useTaskFeed.test.ts index 8419acb..15bff13 100644 --- a/src/__tests__/useTaskFeed.test.ts +++ b/src/__tests__/useTaskFeed.test.ts @@ -14,13 +14,13 @@ import { fetchTasks } from '../services/api'; const mockFetchTasks = fetchTasks as jest.MockedFunction; -type FeedOptions = { +interface FeedOptions { type?: string; lat?: number; lng?: number; radius?: number; sort?: TaskSortMode; -}; +} function makeTask(id: string): Task { return { @@ -48,26 +48,26 @@ function createHarness(options: FeedOptions) { } async function flush() { - await renderer.act(async () => {}); + await renderer.act(async () => undefined); } describe('useTaskFeed', () => { let tree: renderer.ReactTestRenderer | null = null; function renderFeed(options: FeedOptions) { - renderer.act(() => { + void renderer.act(() => { tree = renderer.create(createHarness(options)); }); } function updateFeed(options: FeedOptions) { - renderer.act(() => { + void renderer.act(() => { tree?.update(createHarness(options)); }); } function unmountFeed() { - renderer.act(() => { + void renderer.act(() => { tree?.unmount(); tree = null; }); @@ -186,7 +186,7 @@ describe('useTaskFeed', () => { await flush(); expect(useTaskStore.getState().tasks).toHaveLength(1); - renderer.act(() => { + void renderer.act(() => { feedRef.loadMore(); }); await flush(); diff --git a/src/__tests__/useTaskFeed.test.tsx b/src/__tests__/useTaskFeedRefetch.test.tsx similarity index 71% rename from src/__tests__/useTaskFeed.test.tsx rename to src/__tests__/useTaskFeedRefetch.test.tsx index f11b3cb..7d574ba 100644 --- a/src/__tests__/useTaskFeed.test.tsx +++ b/src/__tests__/useTaskFeedRefetch.test.tsx @@ -15,10 +15,17 @@ import renderer, { act } from 'react-test-renderer'; import * as api from '../services/api'; import { useTaskFeed } from '../hooks/useTaskFeed'; import { useTaskStore } from '../store/taskStore'; +import { Task } from '../types'; const EMPTY_PAGE = { tasks: [], totalPages: 0 }; -function HookHarness({ onRef, ...opts }: any) { +type UseTaskFeedOptions = Parameters[0]; +type UseTaskFeedResult = ReturnType; + +function HookHarness({ + onRef, + ...opts +}: { onRef: (hook: UseTaskFeedResult) => void } & UseTaskFeedOptions) { const hook = useTaskFeed(opts); React.useEffect(() => { onRef(hook); @@ -26,12 +33,25 @@ function HookHarness({ onRef, ...opts }: any) { return null; } +function makeTask(id: string): Task { + return { + id, + title: `Task ${id}`, + description: '', + type: 'OTHER', + rewardAmount: 10, + lat: 0, + lng: 0, + status: 'open', + }; +} + describe('useTaskFeed', () => { - let instance: any; + let instance: renderer.ReactTestRenderer | null = null; afterEach(() => { if (instance) { - act(() => instance.unmount()); + void act(() => instance?.unmount()); instance = null; } }); @@ -52,10 +72,10 @@ describe('useTaskFeed', () => { test('does NOT re-fetch on mount when tasks are already in the store', async () => { // Simulate a previous session that already populated the store. useTaskStore.setState({ - tasks: [{ id: 'existing-1' } as any, { id: 'existing-2' } as any], + tasks: [makeTask('existing-1'), makeTask('existing-2')], }); - let ref: any; + let ref!: UseTaskFeedResult; await act(async () => { instance = renderer.create( (ref = r)} />); }); @@ -71,7 +91,7 @@ describe('useTaskFeed', () => { test('re-fetches exactly once when the filter changes', async () => { await act(async () => { - renderer.create( {}} type="all" />); + renderer.create( undefined} type="all" />); }); // Initial mount fetch should have happened once. @@ -81,8 +101,8 @@ describe('useTaskFeed', () => { (api.fetchTasks as jest.Mock).mockClear(); // Changing the filter triggers exactly one re-fetch. renderer - .create( {}} type="all" />) - .update( {}} type="open" />); + .create( undefined} type="all" />) + .update( undefined} type="open" />); }); await act(async () => { @@ -98,7 +118,7 @@ describe('useTaskFeed', () => { jest.useFakeTimers(); await act(async () => { instance = renderer.create( - {}} lat={1} lng={1} radius={5} />, + undefined} lat={1} lng={1} radius={5} />, ); }); @@ -107,9 +127,12 @@ describe('useTaskFeed', () => { await act(async () => { (api.fetchTasks as jest.Mock).mockClear(); - instance = renderer - .create( {}} lat={1} lng={1} radius={5} />) - .update( {}} lat={2} lng={2} radius={5} />); + instance = renderer.create( + undefined} lat={1} lng={1} radius={5} />, + ); + instance.update( + undefined} lat={2} lng={2} radius={5} />, + ); jest.advanceTimersByTime(5000); }); diff --git a/src/components/ErrorBoundary.tsx b/src/components/ErrorBoundary.tsx index 6724b67..2f0acef 100644 --- a/src/components/ErrorBoundary.tsx +++ b/src/components/ErrorBoundary.tsx @@ -25,7 +25,7 @@ export default class ErrorBoundary extends Component { render() { if (this.state.hasError) { - if (this.props.fallback) { + if (this.props.fallback != null) { return this.props.fallback; } return ( diff --git a/src/components/LoadingSkeleton.tsx b/src/components/LoadingSkeleton.tsx index 9ac6ba4..08a2875 100644 --- a/src/components/LoadingSkeleton.tsx +++ b/src/components/LoadingSkeleton.tsx @@ -1,12 +1,18 @@ import React, { useEffect, useRef } from 'react'; -import { View, Animated } from 'react-native'; +import { + View, + Animated, + StyleProp, + ViewStyle, + DimensionValue, +} from 'react-native'; import { colors, spacing } from '../utils/theme'; interface SkeletonProps { - width?: number | string; + width?: DimensionValue; height?: number; borderRadius?: number; - style?: any; + style?: StyleProp; } export default function Skeleton({ diff --git a/src/components/TransactionHistory.tsx b/src/components/TransactionHistory.tsx index 54fd588..92155c9 100644 --- a/src/components/TransactionHistory.tsx +++ b/src/components/TransactionHistory.tsx @@ -55,7 +55,7 @@ export default function TransactionHistory({ }, [publicKey]); useEffect(() => { - loadPayments(); + void loadPayments(); }, [loadPayments]); if (loading) { @@ -82,7 +82,7 @@ export default function TransactionHistory({ return ( {error} - + void loadPayments()}> (null); useEffect(() => { - requestPermission(); + void requestPermission(); return () => { // Acceptance: clear watcher on unmount @@ -41,8 +41,8 @@ export function useLocation() { } setPermissionGranted(true); startWatch(); - } catch (err: any) { - setError(err.message); + } catch (err) { + setError(err instanceof Error ? err.message : 'Location error'); } } diff --git a/src/hooks/useNetworkStatus.ts b/src/hooks/useNetworkStatus.ts index 3be4d2c..edede4d 100644 --- a/src/hooks/useNetworkStatus.ts +++ b/src/hooks/useNetworkStatus.ts @@ -23,7 +23,7 @@ export function NetworkStatusProvider({ useEffect(() => { let cancelled = false; - NetInfo.fetch().then(state => { + void NetInfo.fetch().then(state => { if (cancelled) { return; } @@ -62,7 +62,7 @@ export function useNetworkStatus(): NetworkStatus { let cancelled = false; - NetInfo.fetch().then(state => { + void NetInfo.fetch().then(state => { if (cancelled) { return; } diff --git a/src/hooks/useProofStatus.ts b/src/hooks/useProofStatus.ts index 85faac4..b3ef62d 100644 --- a/src/hooks/useProofStatus.ts +++ b/src/hooks/useProofStatus.ts @@ -86,91 +86,93 @@ export function useProofStatus( (currentProofId: string, delayMs: number) => { cancelTimer(); - timerRef.current = setTimeout(async () => { - if (!mountedRef.current || !isRunningRef.current) { - return; - } - - // Pause while offline; reschedule with normal back-off once back. - if (!isInitialisedRef.current || !isConnectedRef.current) { - const nextInterval = Math.min( - intervalRef.current * BACKOFF_FACTOR, - MAX_INTERVAL_MS, - ); - intervalRef.current = nextInterval; - schedulePoll(currentProofId, nextInterval); - return; - } - - // Hard 10-minute timeout. - if (Date.now() - startedAtRef.current >= TIMEOUT_MS) { - updateActivityStatus(activityId, 'failed'); - scheduleLocalNotification({ - title: 'Proof verification timed out', - body: 'Proof verification timed out. Please check your submission status.', - type: NOTIFICATION_TYPES.PROOF_TIMEOUT, - data: { - type: NOTIFICATION_TYPES.PROOF_TIMEOUT, - proofId: currentProofId, - activityId, - deepLink: 'ecotask://wallet', - }, - }); - stopPolling(); - return; - } - - try { - const response = await fetchProofStatus(currentProofId); - + timerRef.current = setTimeout(() => { + void (async () => { if (!mountedRef.current || !isRunningRef.current) { return; } - if (TERMINAL_STATUSES.has(response.status)) { - updateActivityStatus( - activityId, - response.status, - response.rewardAmount, + // Pause while offline; reschedule with normal back-off once back. + if (!isInitialisedRef.current || !isConnectedRef.current) { + const nextInterval = Math.min( + intervalRef.current * BACKOFF_FACTOR, + MAX_INTERVAL_MS, ); + intervalRef.current = nextInterval; + schedulePoll(currentProofId, nextInterval); + return; + } - if (response.status === 'confirmed') { - const amount = response.rewardAmount ?? 0; - scheduleLocalNotification({ - title: 'Reward confirmed! 🎉', - body: `You earned ${amount} ${rewardToken} for "${taskTitle}".`, - type: NOTIFICATION_TYPES.REWARD_CONFIRMED, - data: { - type: NOTIFICATION_TYPES.REWARD_CONFIRMED, - proofId: currentProofId, - activityId, - }, - }); - } - + // Hard 10-minute timeout. + if (Date.now() - startedAtRef.current >= TIMEOUT_MS) { + updateActivityStatus(activityId, 'failed'); + void scheduleLocalNotification({ + title: 'Proof verification timed out', + body: 'Proof verification timed out. Please check your submission status.', + type: NOTIFICATION_TYPES.PROOF_TIMEOUT, + data: { + type: NOTIFICATION_TYPES.PROOF_TIMEOUT, + proofId: currentProofId, + activityId, + deepLink: 'ecotask://wallet', + }, + }); stopPolling(); return; } - // Still pending — schedule the next poll with back-off. - const nextInterval = Math.min( - intervalRef.current * BACKOFF_FACTOR, - MAX_INTERVAL_MS, - ); - intervalRef.current = nextInterval; - schedulePoll(currentProofId, nextInterval); - } catch { - // Network or server error: back off and retry unless timed out. - if (!mountedRef.current || !isRunningRef.current) { - return; + try { + const response = await fetchProofStatus(currentProofId); + + if (!mountedRef.current || !isRunningRef.current) { + return; + } + + if (TERMINAL_STATUSES.has(response.status)) { + updateActivityStatus( + activityId, + response.status, + response.rewardAmount, + ); + + if (response.status === 'confirmed') { + const amount = response.rewardAmount ?? 0; + void scheduleLocalNotification({ + title: 'Reward confirmed! 🎉', + body: `You earned ${amount} ${rewardToken} for "${taskTitle}".`, + type: NOTIFICATION_TYPES.REWARD_CONFIRMED, + data: { + type: NOTIFICATION_TYPES.REWARD_CONFIRMED, + proofId: currentProofId, + activityId, + }, + }); + } + + stopPolling(); + return; + } + + // Still pending — schedule the next poll with back-off. + const nextInterval = Math.min( + intervalRef.current * BACKOFF_FACTOR, + MAX_INTERVAL_MS, + ); + intervalRef.current = nextInterval; + schedulePoll(currentProofId, nextInterval); + } catch { + // Network or server error: back off and retry unless timed out. + if (!mountedRef.current || !isRunningRef.current) { + return; + } + const nextInterval = Math.min( + intervalRef.current * BACKOFF_FACTOR, + MAX_INTERVAL_MS, + ); + intervalRef.current = nextInterval; + schedulePoll(currentProofId, nextInterval); } - const nextInterval = Math.min( - intervalRef.current * BACKOFF_FACTOR, - MAX_INTERVAL_MS, - ); - intervalRef.current = nextInterval; - schedulePoll(currentProofId, nextInterval); - } + })(); }, delayMs); }, diff --git a/src/hooks/useProofSubmit.ts b/src/hooks/useProofSubmit.ts index a89f498..8e2926a 100644 --- a/src/hooks/useProofSubmit.ts +++ b/src/hooks/useProofSubmit.ts @@ -12,6 +12,17 @@ import { import { useProofSyncStore } from '../store/proofSyncStore'; import { useNetworkStatus } from './useNetworkStatus'; +/** + * A submission failure with the IPFS CIDs obtained before the failure (if + * any) attached, so callers can persist them with the queued proof instead + * of re-pinning on retry. + */ +interface ProofUploadError extends Error { + photoCid?: string; + metadataCid?: string; + ipfsPending?: boolean; +} + export type ProofSubmitResult = | { status: 'success'; @@ -139,6 +150,7 @@ export function useProofSubmit() { async ( taskId: string, photoUri: string, + capturedAt: string, opts?: { lat?: number; lng?: number; @@ -164,11 +176,16 @@ export function useProofSubmit() { formData.append('lng', String(opts.lng)); } + interface ReactNativeFilePart { + uri: string; + type: string; + name: string; + } formData.append('photos', { uri: photoUri, type: 'image/jpeg', name: 'proof.jpg', - } as any); + } as ReactNativeFilePart as unknown as Blob); if (photoCid) { formData.append('ipfsPhotoCid', photoCid); @@ -182,10 +199,11 @@ export function useProofSubmit() { return { result, ipfsPending }; } catch (err) { // attach the generated cids so callers can persist them - (err as any).photoCid = photoCid; - (err as any).metadataCid = metadataCid; - (err as any).ipfsPending = ipfsPending; - throw err; + const uploadError = err as ProofUploadError; + uploadError.photoCid = photoCid; + uploadError.metadataCid = metadataCid; + uploadError.ipfsPending = ipfsPending; + throw uploadError; } }, [], @@ -207,7 +225,10 @@ export function useProofSubmit() { try { setProgress('verifying'); const { result, ipfsPending: attemptIpfsPending } = - await submitProofAttempt(taskId, photoUri, { lat, lng }); + await submitProofAttempt(taskId, photoUri, capturedAt, { + lat, + lng, + }); if (attemptIpfsPending) { setIpfsPending(true); } @@ -232,8 +253,9 @@ export function useProofSubmit() { ...(attemptIpfsPending ? { ipfsPending: true } : {}), }; } catch (err) { - const message = (err as any)?.message || 'Upload failed'; - const ipfsPending = (err as any)?.ipfsPending || false; + const uploadError = err as ProofUploadError; + const message = uploadError.message || 'Upload failed'; + const ipfsPending = uploadError.ipfsPending || false; try { enqueueProof({ id: `${Date.now()}`, @@ -243,8 +265,8 @@ export function useProofSubmit() { lng, createdAt: new Date().toISOString(), capturedAt, - photoCid: (err as any)?.photoCid, - metadataCid: (err as any)?.metadataCid, + photoCid: uploadError.photoCid, + metadataCid: uploadError.metadataCid, }); if (ipfsPending) { setIpfsPending(true); @@ -260,8 +282,9 @@ export function useProofSubmit() { }; } catch (queueError) { const failureMessage = - (queueError as any)?.message || - `Upload failed and could not be saved: ${message}`; + queueError instanceof Error + ? queueError.message + : `Upload failed and could not be saved: ${message}`; setError(failureMessage); setProgress('failed'); return { @@ -301,19 +324,24 @@ export function useProofSubmit() { const remaining: PendingProof[] = []; for (const proof of pending) { try { - await submitProofAttempt(proof.taskId, proof.photoPath, { - lat: (proof as any).lat, - lng: (proof as any).lng, - photoCid: (proof as any).photoCid, - metadataCid: (proof as any).metadataCid, - }); + await submitProofAttempt( + proof.taskId, + proof.photoPath, + proof.capturedAt, + { + lat: proof.lat, + lng: proof.lng, + photoCid: proof.photoCid, + metadataCid: proof.metadataCid, + }, + ); } catch (err) { + const uploadError = err as ProofUploadError; remaining.push({ ...proof, - photoCid: (err as any)?.photoCid || (proof as any).photoCid, - metadataCid: - (err as any)?.metadataCid || (proof as any).metadataCid, - } as PendingProof); + photoCid: uploadError.photoCid || proof.photoCid, + metadataCid: uploadError.metadataCid || proof.metadataCid, + }); } } saveQueue(remaining); diff --git a/src/hooks/useStellarWallet.ts b/src/hooks/useStellarWallet.ts index a56b072..5b8ced9 100644 --- a/src/hooks/useStellarWallet.ts +++ b/src/hooks/useStellarWallet.ts @@ -17,6 +17,10 @@ interface FreighterWindow { }; } +// React Native has no DOM `window`; Freighter (browser extension) only +// exists when this code happens to run in a web context. +declare const window: FreighterWindow; + export function useStellarWallet() { const { connect, @@ -86,11 +90,10 @@ export function useStellarWallet() { setIsConnecting(true); setError(null); try { - const freighter = ( - typeof (globalThis as any).window !== 'undefined' - ? (globalThis as any).window - : ({} as FreighterWindow) - ).freighter; + // `typeof window` is safe to reference even where no global `window` + // is declared (native platforms); a direct `window` reference is not. + const freighter = + typeof window !== 'undefined' ? window.freighter : undefined; if (!freighter) { throw new Error('Freighter extension not detected'); } @@ -100,8 +103,8 @@ export function useStellarWallet() { } const key = await freighter.getPublicKey(); await connectAccount(key, undefined, 'freighter'); - } catch (err: any) { - setError(err.message); + } catch (err) { + setError(err instanceof Error ? err.message : 'Could not connect'); } finally { setIsConnecting(false); } @@ -171,8 +174,8 @@ export function useStellarWallet() { 'source' in parsed ? parsed.source : parsed.feeSource; await connectAccount(lobstrPublicKey, undefined, 'lobstr'); - } catch (err: any) { - setError(err.message); + } catch (err) { + setError(err instanceof Error ? err.message : 'Could not connect'); } finally { setIsConnecting(false); } @@ -186,8 +189,8 @@ export function useStellarWallet() { await stellar.createTestnetAccount(); await connectAccount(key, secretKey, 'inapp'); return { publicKey: key, secretKey }; - } catch (err: any) { - setError(err.message); + } catch (err) { + setError(err instanceof Error ? err.message : 'Could not create wallet'); } finally { setIsConnecting(false); } @@ -205,8 +208,10 @@ export function useStellarWallet() { const key = stellar.getPublicKeyFromSecret(trimmed); await connectAccount(key, trimmed, 'inapp'); return { publicKey: key }; - } catch (err: any) { - setError(err.message || 'Could not import wallet'); + } catch (err) { + setError( + err instanceof Error ? err.message : 'Could not import wallet', + ); return undefined; } finally { setIsConnecting(false); @@ -231,9 +236,9 @@ export function useStellarWallet() { useEffect(() => { if (isConnected && publicKey) { - refreshBalance(); - refreshEcoBalance(); - refreshUsdcBalance(); + void refreshBalance(); + void refreshEcoBalance(); + void refreshUsdcBalance(); } }, [ isConnected, diff --git a/src/hooks/useTaskFeed.ts b/src/hooks/useTaskFeed.ts index 24d4d01..8f9479b 100644 --- a/src/hooks/useTaskFeed.ts +++ b/src/hooks/useTaskFeed.ts @@ -48,26 +48,30 @@ export function useTaskFeed(options: UseTaskFeedOptions = {}) { ); const loadTasks = useCallback( - async (pageNum: number = 1) => { + async (pageNum = 1) => { setLoading(true); setError(null); try { - const params: Record = { page: pageNum, limit: 20 }; + const params: Record = { + page: pageNum, + limit: 20, + }; if (serverParams.type) { params.type = serverParams.type; } const loc = locationRef.current; - const lat = loc.lat; - const lng = loc.lng; - const withLocation = lat !== undefined && lng !== undefined; - if (withLocation && lat !== undefined && lng !== undefined) { - params.lat = lat; - params.lng = lng; + const location = + loc.lat !== undefined && loc.lng !== undefined + ? { lat: loc.lat, lng: loc.lng } + : null; + if (location) { + params.lat = location.lat; + params.lng = location.lng; if (serverParams.radius !== undefined) { params.radius = serverParams.radius; } - lastFetchLocationRef.current = { lat, lng }; + lastFetchLocationRef.current = location; } else { lastFetchLocationRef.current = null; } @@ -79,10 +83,9 @@ export function useTaskFeed(options: UseTaskFeedOptions = {}) { ...task, status: normalizeTaskStatus(task.status), })); - if (withLocation && loc.lat !== undefined && loc.lng !== undefined) { - return enrichTasksWithDistance(withStatus, loc.lat, loc.lng); - } - return withStatus; + return location + ? enrichTasksWithDistance(withStatus, location.lat, location.lng) + : withStatus; }; if (pageNum === 1) { @@ -92,8 +95,8 @@ export function useTaskFeed(options: UseTaskFeedOptions = {}) { } setPage(pageNum); setHasMore(pageNum < result.totalPages); - } catch (err: any) { - setError(err.message || 'Failed to load tasks'); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load tasks'); } finally { setLoading(false); } @@ -112,7 +115,7 @@ export function useTaskFeed(options: UseTaskFeedOptions = {}) { const refresh = useCallback(() => loadTasks(1), [loadTasks]); const loadMore = useCallback(() => { if (!isLoading && hasMore) { - loadTasks(page + 1); + void loadTasks(page + 1); } }, [isLoading, hasMore, page, loadTasks]); @@ -121,7 +124,7 @@ export function useTaskFeed(options: UseTaskFeedOptions = {}) { // don't burn a network request re-fetching data we already hold. useEffect(() => { if (tasksRef.current.length === 0) { - loadTasks(1); + void loadTasks(1); } else if (hasLocation) { // Tasks already present: record the current location as "already // fetched" so the debounced location effect doesn't immediately @@ -140,7 +143,7 @@ export function useTaskFeed(options: UseTaskFeedOptions = {}) { skipFirstFilterRun.current = false; return; } - loadTasks(1); + void loadTasks(1); }, [serverParams, loadTasks]); const currentLocation = useMemo( @@ -161,7 +164,7 @@ export function useTaskFeed(options: UseTaskFeedOptions = {}) { ) { return; } - loadTasks(1); + void loadTasks(1); }, LOCATION_DEBOUNCE_MS); return () => clearTimeout(timer); }, [currentLocation, loadTasks]); diff --git a/src/navigation/RootNavigator.tsx b/src/navigation/RootNavigator.tsx index 8dac704..a481315 100644 --- a/src/navigation/RootNavigator.tsx +++ b/src/navigation/RootNavigator.tsx @@ -72,15 +72,15 @@ export default function RootNavigator() { await sendTokenToServer(token); } - stopTokenRefresh = listenForTokenRefresh(async (newToken: string) => { - await sendTokenToServer(newToken); + stopTokenRefresh = listenForTokenRefresh(newToken => { + void sendTokenToServer(newToken); }); const messaging = getMessaging(); const initialNotification = await messaging.getInitialNotification(); - if (initialNotification?.data?.deepLink) { - pendingDeepLink.current = initialNotification.data.deepLink as string; + if (typeof initialNotification?.data?.deepLink === 'string') { + pendingDeepLink.current = initialNotification.data.deepLink; } const unsubForeground = messaging.onMessage(async remoteMessage => { @@ -110,7 +110,7 @@ export default function RootNavigator() { return () => { stopTokenRefresh?.(); - cleanupPromise.then(cleanup => cleanup?.()); + void cleanupPromise.then(cleanup => cleanup?.()); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); @@ -140,7 +140,7 @@ export default function RootNavigator() { const subscription = Linking.addEventListener('url', handleUrl); - Linking.getInitialURL().then(url => { + void Linking.getInitialURL().then(url => { if (url) { handleUrl({ url }); } diff --git a/src/screens/EditProfileScreen.tsx b/src/screens/EditProfileScreen.tsx index 63dd658..8d298b9 100644 --- a/src/screens/EditProfileScreen.tsx +++ b/src/screens/EditProfileScreen.tsx @@ -44,8 +44,11 @@ export default function EditProfileScreen() { } navigation.goBack(); - } catch (err: any) { - Alert.alert('Save Failed', err.message || 'Could not update profile'); + } catch (err) { + Alert.alert( + 'Save Failed', + err instanceof Error ? err.message : 'Could not update profile', + ); } finally { setIsSaving(false); } @@ -142,7 +145,7 @@ export default function EditProfileScreen() { /> void handleSave()} disabled={isSaving} style={{ padding: spacing.md, diff --git a/src/screens/HomeScreen.tsx b/src/screens/HomeScreen.tsx index 146af2c..62c7955 100644 --- a/src/screens/HomeScreen.tsx +++ b/src/screens/HomeScreen.tsx @@ -1,6 +1,5 @@ import React, { useEffect } from 'react'; import { View, Text, TouchableOpacity, ScrollView } from 'react-native'; - import { colors, spacing } from '../utils/theme'; import ImpactStats from '../components/ImpactStats'; import StreakCard from '../components/StreakCard'; @@ -162,7 +161,7 @@ export default function HomeScreen() { void syncPendingProofs()} /> diff --git a/src/screens/MapScreen.tsx b/src/screens/MapScreen.tsx index 408a6da..f7c6b2c 100644 --- a/src/screens/MapScreen.tsx +++ b/src/screens/MapScreen.tsx @@ -271,7 +271,10 @@ export default function MapScreen() { /> )} {error != null && ( - + void refresh()} + style={styles.retryBtn} + > Retry )} diff --git a/src/screens/NotificationPreferencesScreen.tsx b/src/screens/NotificationPreferencesScreen.tsx index 5b56558..7952fa1 100644 --- a/src/screens/NotificationPreferencesScreen.tsx +++ b/src/screens/NotificationPreferencesScreen.tsx @@ -1,6 +1,5 @@ import React from 'react'; import { View, Text, Switch, TouchableOpacity, ScrollView } from 'react-native'; - import { colors, spacing } from '../utils/theme'; import { NOTIFICATION_TYPES } from '../services/notifications'; import usePrefsStore from '../store/prefsStore'; diff --git a/src/screens/OnboardingScreen.tsx b/src/screens/OnboardingScreen.tsx index 18b699e..77cb543 100644 --- a/src/screens/OnboardingScreen.tsx +++ b/src/screens/OnboardingScreen.tsx @@ -30,7 +30,7 @@ export default function OnboardingScreen() { useEffect(() => { if (isConnected && publicKey) { - authenticate(publicKey).catch(() => {}); + authenticate(publicKey).catch(() => undefined); } }, [isConnected, publicKey, authenticate]); @@ -104,7 +104,7 @@ export default function OnboardingScreen() { )} void connectFreighter()} disabled={busy} style={{ padding: spacing.md, @@ -125,7 +125,7 @@ export default function OnboardingScreen() { void connectLobstr()} disabled={busy} style={{ padding: spacing.md, @@ -144,7 +144,7 @@ export default function OnboardingScreen() { void handleCreateWallet()} disabled={busy} style={{ padding: spacing.md, @@ -200,7 +200,7 @@ export default function OnboardingScreen() { }} /> void handleImport()} disabled={busy || !secretKey.trim()} style={{ padding: spacing.md, diff --git a/src/screens/ProfileScreen.tsx b/src/screens/ProfileScreen.tsx index 65812f3..a8df86e 100644 --- a/src/screens/ProfileScreen.tsx +++ b/src/screens/ProfileScreen.tsx @@ -1,6 +1,5 @@ import React from 'react'; import { View, Text, ScrollView, TouchableOpacity } from 'react-native'; - import { useUserStore } from '../store/userStore'; import { useWalletStore } from '../store/walletStore'; import { useStellarWallet } from '../hooks/useStellarWallet'; @@ -148,8 +147,8 @@ export default function ProfileScreen() { label="Notification Preferences" onPress={() => navigation.navigate('NotificationPreferences')} /> - {}} /> - {}} /> + + { @@ -180,7 +179,7 @@ function SettingsRow({ }: { label: string; value?: string; - onPress: () => void; + onPress?: () => void; }) { return ( { - refreshBalance(); - refreshEcoBalance(); - refreshUsdcBalance(); + void refreshBalance(); + void refreshEcoBalance(); + void refreshUsdcBalance(); }, 3000); Alert.alert( 'Payment opened in Lobstr', @@ -113,27 +113,24 @@ export default function SendTokensScreen() { amount: amount.trim(), asset: assetParam, }); - refreshBalance(); - refreshEcoBalance(); - refreshUsdcBalance(); + void refreshBalance(); + void refreshEcoBalance(); + void refreshUsdcBalance(); Alert.alert( 'Payment sent', `Transaction ${result.hash.slice(0, 12)}… submitted to the network.`, ); setDestination(''); setAmount(''); - } catch (err: any) { + } catch (err) { if (err instanceof LobstrNotInstalledError) { setError(err.message); - } else if ( - typeof err.message === 'string' && - err.message.includes('op_no_trust') - ) { + } else if (err instanceof Error && err.message.includes('op_no_trust')) { setError( 'The destination account has no trustline for this asset. They must add a trustline before receiving it.', ); } else { - setError(err.message || 'Failed to send payment'); + setError(err instanceof Error ? err.message : 'Failed to send payment'); } } finally { setIsSending(false); @@ -289,7 +286,7 @@ export default function SendTokensScreen() { )} void handleSend()} disabled={isSending} style={{ padding: spacing.md, diff --git a/src/screens/SubmitProofScreen.tsx b/src/screens/SubmitProofScreen.tsx index 4e45588..3fa6ec1 100644 --- a/src/screens/SubmitProofScreen.tsx +++ b/src/screens/SubmitProofScreen.tsx @@ -56,7 +56,7 @@ export default function SubmitProofScreen() { useEffect(() => { if (!hasPermission) { - requestPermission(); + void requestPermission(); } }, [hasPermission, requestPermission]); @@ -70,8 +70,11 @@ export default function SubmitProofScreen() { }); setPhotoUri(`file://${photo.path}`); setCapturedAt(new Date().toISOString()); - } catch (err: any) { - Alert.alert('Camera Error', err.message || 'Failed to capture photo'); + } catch (err) { + Alert.alert( + 'Camera Error', + err instanceof Error ? err.message : 'Failed to capture photo', + ); } }, []); @@ -130,7 +133,7 @@ export default function SubmitProofScreen() { const token = result.rewardToken || route.params.rewardToken || 'ECO'; const title = result.taskTitle || route.params.taskTitle || 'Task Completed'; - scheduleLocalNotification({ + void scheduleLocalNotification({ title: 'Reward confirmed! 🎉', body: `You earned ${amount} ${token} for "${title}".`, type: NOTIFICATION_TYPES.REWARD_CONFIRMED, @@ -249,7 +252,7 @@ export default function SubmitProofScreen() { {hasPermission === false && ( void requestPermission()} style={{ marginTop: spacing.md, padding: spacing.sm }} > Grant Permission @@ -317,7 +320,7 @@ export default function SubmitProofScreen() { > {!photoUri ? ( void handleCapture()} disabled={isSubmitting} style={{ flex: 1, @@ -357,7 +360,7 @@ export default function SubmitProofScreen() { Retake void handleSubmit()} disabled={isSubmitting} style={{ flex: 1, diff --git a/src/screens/TaskDetailScreen.tsx b/src/screens/TaskDetailScreen.tsx index b397fb4..ad5feee 100644 --- a/src/screens/TaskDetailScreen.tsx +++ b/src/screens/TaskDetailScreen.tsx @@ -29,7 +29,7 @@ export default function TaskDetailScreen() { const [error, setError] = useState(null); useEffect(() => { - loadTask(); + void loadTask(); }, [taskId]); // eslint-disable-line react-hooks/exhaustive-deps async function loadTask() { @@ -63,7 +63,10 @@ export default function TaskDetailScreen() { }} > {error || 'Task not found'} - + void loadTask()} + style={{ marginTop: spacing.md }} + > Try Again diff --git a/src/screens/TaskListScreen.tsx b/src/screens/TaskListScreen.tsx index a4b8310..7e336da 100644 --- a/src/screens/TaskListScreen.tsx +++ b/src/screens/TaskListScreen.tsx @@ -8,7 +8,6 @@ import { ScrollView, TextInput, } from 'react-native'; - import { colors, spacing } from '../utils/theme'; import { useTaskFeed } from '../hooks/useTaskFeed'; import { useLocation } from '../hooks/useLocation'; @@ -123,7 +122,10 @@ export default function TaskListScreen() { }} > {error} - + void refresh()} + style={{ marginTop: spacing.md }} + > Retry @@ -334,7 +336,7 @@ export default function TaskListScreen() { onEndReached={loadMore} onEndReachedThreshold={0.5} refreshing={isLoading && tasks.length > 0} - onRefresh={refresh} + onRefresh={() => void refresh()} ListEmptyComponent={ !isLoading ? ( { - refreshBalance().finally(() => setLoading(false)); + void refreshBalance().finally(() => setLoading(false)); }, [refreshBalance]); if (!isConnected) { diff --git a/src/services/__tests__/api.integration.test.ts b/src/services/__tests__/api.integration.test.ts index ee019d3..29c1f0c 100644 --- a/src/services/__tests__/api.integration.test.ts +++ b/src/services/__tests__/api.integration.test.ts @@ -5,31 +5,44 @@ import { useUserStore } from '../../store/userStore'; describe('api integration flow (mocked)', () => { beforeEach(() => { jest.restoreAllMocks(); - useUserStore.setState({ token: null, profile: null } as any); + useUserStore.setState({ token: null, profile: null }); }); test('auth challenge -> login -> fetchTasks -> submitProof', async () => { const wallet = 'GABC'; const spyGetAuth = jest.spyOn(api, 'getAuthChallenge'); - spyGetAuth.mockResolvedValue({ challenge: 'nonce-123' } as any); + spyGetAuth.mockResolvedValue({ challenge: 'nonce-123' }); const spyLogin = jest.spyOn(api, 'loginWithWallet'); - spyLogin.mockResolvedValue({ token: 'tok', user: { id: 'u1' } } as any); + spyLogin.mockResolvedValue({ token: 'tok', user: { id: 'u1' } }); const spyFetch = jest.spyOn(api, 'fetchTasks'); - spyFetch.mockResolvedValue([{ id: 't1' }] as any); + spyFetch.mockResolvedValue([{ id: 't1' }]); const spySubmit = jest.spyOn(api, 'submitProof'); - spySubmit.mockResolvedValue({ id: 'proof1', status: 'confirmed' } as any); + spySubmit.mockResolvedValue({ + id: 'proof1', + status: 'confirmed', + } as unknown as Awaited>); const c = await api.getAuthChallenge(wallet); expect(c.challenge).toBe('nonce-123'); const login = await api.loginWithWallet(wallet, 'sig', c.challenge); expect(login.token).toBe('tok'); - useUserStore.setState({ token: login.token, profile: login.user } as any); + useUserStore.setState({ + token: login.token, + profile: { + id: login.user.id, + wallet, + stats: { treesPlanted: 0, plasticCollected: 0, co2Reduced: 0 }, + }, + }); const tasks = await api.fetchTasks(); expect(Array.isArray(tasks)).toBe(true); - const proofRes = await api.submitProof(new FormData() as any); + const proofRes = (await api.submitProof(new FormData())) as unknown as { + id: string; + status: string; + }; expect(proofRes.id).toBe('proof1'); }); }); diff --git a/src/services/api.ts b/src/services/api.ts index 8f09b1f..83c831f 100644 --- a/src/services/api.ts +++ b/src/services/api.ts @@ -1,11 +1,11 @@ -import axios from 'axios'; +import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios'; +import { Task, SubmitProofResult } from '../types'; import { Alert } from 'react-native'; import Config from 'react-native-config'; import { useUserStore } from '../store/userStore'; import { useWalletStore } from '../store/walletStore'; import { getInAppSecret } from './walletVault'; import { signChallengeXDR } from './stellar'; -import { Task } from '../types'; import { normalizeTaskStatus } from '../utils/sortTasks'; declare module 'axios' { @@ -116,13 +116,13 @@ api.interceptors.request.use(async config => { api.interceptors.response.use( response => response, - async error => { - const original = error.config; + async (error: AxiosError) => { + const original = error.config as InternalAxiosRequestConfig | undefined; if ( error.response?.status === 401 && original && - !original._retry && - !original.skipAuthRefresh + original._retry !== true && + original.skipAuthRefresh !== true ) { original._retry = true; try { @@ -184,12 +184,12 @@ export async function updateProfile(data: { name?: string; bio?: string; avatarUrl?: string; -}) { +}): Promise<{ name?: string; bio?: string; avatarUrl?: string }> { const res = await api.put('/auth/me', data); return res.data; } -export async function fetchTasks(params?: Record) { +export async function fetchTasks(params?: Record) { const res = await api.get('/tasks', { params }); return res.data; } @@ -210,11 +210,13 @@ export async function fetchTaskById(id: string): Promise { return toTask(res.data); } -export async function submitProof(formData: FormData) { +export async function submitProof( + formData: FormData, +): Promise { const res = await api.post('/proofs', formData, { headers: { 'Content-Type': 'multipart/form-data' }, }); - return res.data; + return res.data as SubmitProofResult; } export type ProofVerificationStatus = 'pending' | 'confirmed' | 'failed'; diff --git a/src/services/firebaseMessaging.ts b/src/services/firebaseMessaging.ts index 6d34341..bd303e7 100644 --- a/src/services/firebaseMessaging.ts +++ b/src/services/firebaseMessaging.ts @@ -29,10 +29,10 @@ export interface FirebaseMessagingAdapter { /** No-op adapter — used when Firebase native module is not available. */ const nullAdapter: FirebaseMessagingAdapter = { getToken: async () => null, - onTokenRefresh: () => () => {}, + onTokenRefresh: () => () => undefined, getInitialNotification: async () => null, - onMessage: () => () => {}, - onNotificationOpenedApp: () => () => {}, + onMessage: () => () => undefined, + onNotificationOpenedApp: () => () => undefined, requestPermission: async () => null, }; diff --git a/src/services/ipfs.ts b/src/services/ipfs.ts index 94cb400..3a6afbf 100644 --- a/src/services/ipfs.ts +++ b/src/services/ipfs.ts @@ -38,12 +38,18 @@ export async function pinFile( throw new Error('IPFS API key not configured. Set IPFS_API_KEY in .env'); } + interface ReactNativeFilePart { + uri: string; + type: string; + name: string; + } + const formData = new FormData(); formData.append('file', { uri: filePath.startsWith('file://') ? filePath : `file://${filePath}`, type: 'image/jpeg', name: fileName, - } as any); + } as ReactNativeFilePart as unknown as Blob); formData.append( 'pinataMetadata', diff --git a/src/services/lobstr.ts b/src/services/lobstr.ts index 02855a5..ec74125 100644 --- a/src/services/lobstr.ts +++ b/src/services/lobstr.ts @@ -49,8 +49,8 @@ export function resolveLobstrCallback(url: string): void { try { const signedXDR = parseLobstrCallbackUrl(url); _pendingResolve(signedXDR); - } catch (err: any) { - _pendingReject(err); + } catch (err) { + _pendingReject(err instanceof Error ? err : new Error(String(err))); } finally { _pendingResolve = null; _pendingReject = null; diff --git a/src/services/notifications.ts b/src/services/notifications.ts index fb181e1..e9eee87 100644 --- a/src/services/notifications.ts +++ b/src/services/notifications.ts @@ -114,44 +114,30 @@ export async function scheduleLocalNotification( const type = payload.type || payload.data?.type || payload.data?.notificationType; - if ( - type && - prefs.notificationPrefs && - prefs.notificationPrefs[type] === false - ) { + if (type && prefs.notificationPrefs[type] === false) { return; } - const { from, to } = prefs.quietHours || { from: '00:00', to: '00:00' }; + const { from, to } = prefs.quietHours; if (isNowInQuietHours(from, to)) { return; } // Best-effort: if notifee is available and payload requests scheduling, use it try { - const notifeeModule = await import('@notifee/react-native'); - const notifee = notifeeModule.default || notifeeModule; + const { default: notifee } = await import('@notifee/react-native'); // If payload contains a timestamp/data.trigger we could schedule; for now display immediately - const notification = await notifee.displayNotification({ + const notificationId = await notifee.displayNotification({ title: payload.title, body: payload.body, - data: payload.data as any, - } as any); - // notifee.displayNotification may return an id in some setups; store it if present - if (notification && (notification as any).id) { - usePrefsStore - .getState() - .addScheduledId(type || 'unknown', (notification as any).id); - } - if (onNotificationCallback) { - onNotificationCallback(payload); - } + data: payload.data, + }); + usePrefsStore.getState().addScheduledId(type || 'unknown', notificationId); + onNotificationCallback?.(payload); return; } catch { // fallback to local callback - if (onNotificationCallback) { - onNotificationCallback(payload); - } + onNotificationCallback?.(payload); } } diff --git a/src/services/stellar.ts b/src/services/stellar.ts index 507c53d..001b057 100644 --- a/src/services/stellar.ts +++ b/src/services/stellar.ts @@ -63,7 +63,7 @@ export async function getTokenBalance( export async function getPayments( publicKey: string, - limit: number = 10, + limit = 10, ): Promise { try { const page = await server diff --git a/src/store/prefsStore.ts b/src/store/prefsStore.ts index a6d1c74..8e30ceb 100644 --- a/src/store/prefsStore.ts +++ b/src/store/prefsStore.ts @@ -10,7 +10,10 @@ const zustandMMKVStorage = { removeItem: (name: string) => storage.delete(name), }; -type QuietHours = { from: string; to: string }; +interface QuietHours { + from: string; + to: string; +} // Create default notification preferences with all types enabled function makeDefaultNotificationPrefs() { @@ -35,7 +38,7 @@ interface PrefsState { allEnabled: boolean; notificationPrefs: Record; quietHours: QuietHours; - scheduledNotificationIds: Record; // type -> ids + scheduledNotificationIds: Record; // type -> ids toggleType: (type: string, enabled: boolean) => void; setAllEnabled: (enabled: boolean) => void; setQuietHours: (from: string, to: string) => void; @@ -59,13 +62,13 @@ export const usePrefsStore = create()( // if disabling, cancel any scheduled notifications asynchronously if (!enabled) { - (async () => { + void (async () => { const ids = usePrefsStore.getState().scheduledNotificationIds[type] || []; if (ids.length > 0) { try { - const notifeeModule = await import('@notifee/react-native'); - const notifee = notifeeModule.default || notifeeModule; + const { default: notifee } = + await import('@notifee/react-native'); await Promise.all( ids.map(id => notifee.cancelNotification(id).catch(() => null), @@ -75,15 +78,12 @@ export const usePrefsStore = create()( // notifee not available or cancel failed; best-effort } } - usePrefsStore.setState( - s => - ({ - scheduledNotificationIds: { - ...s.scheduledNotificationIds, - [type]: [], - }, - }) as any, - ); + usePrefsStore.setState(s => ({ + scheduledNotificationIds: { + ...s.scheduledNotificationIds, + [type]: [], + }, + })); })(); } }, @@ -109,22 +109,17 @@ export const usePrefsStore = create()( { name: 'prefs-storage', storage: createJSONStorage(() => zustandMMKVStorage), - onRehydrateStorage: () => () => { - return (persisted?: unknown) => { - if ( - persisted && - typeof persisted === 'object' && - 'notificationPrefs' in persisted - ) { - const p = persisted as { - notificationPrefs?: Record; - }; - if (p.notificationPrefs) { - const merged = mergeNotificationDefaults(p.notificationPrefs); - usePrefsStore.setState({ notificationPrefs: merged }); - } - } - }; + // zustand's persist option calls this once with the pre-hydration + // state and expects the *return value* to be the post-hydration + // callback `(state?, error?) => void` — it does not call a further + // nested function, so any extra level of wrapping here is dead code + // that silently never runs. + onRehydrateStorage: () => (persisted, error) => { + if (error != null || !persisted) { + return; + } + const merged = mergeNotificationDefaults(persisted.notificationPrefs); + usePrefsStore.setState({ notificationPrefs: merged }); }, }, ), diff --git a/src/store/taskStore.ts b/src/store/taskStore.ts index 91c97ed..0cf5dc7 100644 --- a/src/store/taskStore.ts +++ b/src/store/taskStore.ts @@ -13,13 +13,13 @@ const zustandMMKVStorage = { /** Must match the page size used by useTaskFeed when fetching tasks. */ export const TASK_PAGE_SIZE = 20; -export type PersistedTaskSlice = { +export interface PersistedTaskSlice { tasks: Task[]; selectedTask: Task | null; selectedAt: string | null; page: number; hasMore: boolean; -}; +} interface TaskState { tasks: Task[]; diff --git a/src/types/index.ts b/src/types/index.ts index a0cabd9..88da4ae 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -94,6 +94,15 @@ export interface SubmitProofParams { rewardToken?: string; } +export interface SubmitProofResult { + taskTitle?: string; + taskType?: TaskType; + rewardAmount?: number; + rewardToken?: string; + /** ID returned by the backend after a successful proof POST; used for status polling. */ + proofId?: string; +} + export const TASK_TYPE_CONFIG: Record< TaskType, { icon: string; label: string } diff --git a/src/utils/achievements.ts b/src/utils/achievements.ts index c00ae20..7b1e97c 100644 --- a/src/utils/achievements.ts +++ b/src/utils/achievements.ts @@ -132,9 +132,8 @@ export function getEarnedCount(stats: UserStats): number { } export function getNextAchievement(stats: UserStats): Achievement | null { - const unlocked = getAchievements(stats); - return ( - unlocked.filter(a => !a.earned).sort((a, b) => a.target - b.target)[0] || - null - ); + const remaining = getAchievements(stats) + .filter(a => !a.earned) + .sort((a, b) => a.target - b.target); + return remaining[0] ?? null; } diff --git a/src/utils/geoUtils.ts b/src/utils/geoUtils.ts index a1e1b48..8f0f5ba 100644 --- a/src/utils/geoUtils.ts +++ b/src/utils/geoUtils.ts @@ -42,11 +42,7 @@ export function enrichTasksWithDistance< lng?: number; distance?: number; }, ->( - tasks: T[], - userLat: number, - userLng: number, -): Array { +>(tasks: T[], userLat: number, userLng: number): (T & { distance?: number })[] { return tasks .map(task => { if (