diff --git a/.detoxrc.js b/.detoxrc.js
index 9f840ba..ba4cd84 100644
--- a/.detoxrc.js
+++ b/.detoxrc.js
@@ -83,4 +83,19 @@ module.exports = {
app: 'android.release',
},
},
+ artifacts: {
+ rootDir: 'artifacts',
+ plugins: {
+ log: { enabled: true },
+ screenshot: {
+ enabled: true,
+ shouldTakeAutomaticSnapshots: false,
+ keepOnlyFailedTestsArtifacts: false,
+ },
+ video: {
+ enabled: true,
+ keepOnlyFailedTestsArtifacts: true,
+ },
+ },
+ },
};
diff --git a/App.tsx b/App.tsx
index f6b5c42..bebf155 100644
--- a/App.tsx
+++ b/App.tsx
@@ -1,4 +1,5 @@
import React from 'react';
+import { View } from 'react-native';
import { StatusBar } from 'expo-status-bar';
import { AppNavigator } from './src/navigation/AppNavigator';
import { useNotifications } from './src/hooks/useNotifications';
@@ -12,6 +13,7 @@ import { createAppKit, defaultConfig, AppKit } from '@reown/appkit-ethers-react-
import { EVM_RPC_URLS } from './src/config/evm';
import { useNetworkStore } from './src/store';
+import { sessionService } from './src/services/auth/session';
// Get projectId from environment variable
const projectId = process.env.WALLET_CONNECT_PROJECT_ID || 'YOUR_PROJECT_ID';
@@ -72,6 +74,7 @@ function NotificationBootstrap() {
const { initialize } = useNetworkStore();
React.useEffect(() => {
initialize();
+ void sessionService.initializeCurrentSession();
}, [initialize]);
return null;
@@ -79,13 +82,13 @@ function NotificationBootstrap() {
export default function App() {
return (
- <>
+
- >
+
);
}
diff --git a/contracts/subscription/certora/SubTrackrSubscription.spec b/contracts/subscription/certora/SubTrackrSubscription.spec
new file mode 100644
index 0000000..52ff79b
--- /dev/null
+++ b/contracts/subscription/certora/SubTrackrSubscription.spec
@@ -0,0 +1,27 @@
+// Placeholder Certora-style rule file for CI integration.
+// The exact contract bindings should be updated when Certora harness generation is added.
+
+methods {
+ // Core state transitions
+ subscribe(env, proxy, storage, subscriber, plan_id) returns uint64 envfree;
+ cancel_subscription(env, proxy, storage, subscriber, subscription_id) envfree;
+ pause_subscription(env, proxy, storage, subscriber, subscription_id) envfree;
+ resume_subscription(env, proxy, storage, subscriber, subscription_id) envfree;
+ charge_subscription(env, proxy, storage, subscription_id) envfree;
+}
+
+rule noCancelledToActive(uint64 subscription_id) {
+ // Placeholder rule: implementation should assert cancelled subscriptions
+ // cannot return to Active status after cancellation.
+ true;
+}
+
+rule subscriptionCountMonotonic() {
+ // Placeholder invariant: subscription count never decreases.
+ true;
+}
+
+rule refundBoundedByTotalPaid(uint64 subscription_id) {
+ // Placeholder invariant: refund request <= total paid.
+ true;
+}
diff --git a/contracts/subscription/certora/certora.conf b/contracts/subscription/certora/certora.conf
new file mode 100644
index 0000000..7b93305
--- /dev/null
+++ b/contracts/subscription/certora/certora.conf
@@ -0,0 +1,6 @@
+{
+ "msg": "SubTrackr subscription formal verification",
+ "verify": "SubTrackrSubscription:SubTrackrSubscription.spec",
+ "rule_sanity": "basic",
+ "optimistic_loop": true
+}
diff --git a/contracts/subscription/specs/core-spec.md b/contracts/subscription/specs/core-spec.md
new file mode 100644
index 0000000..eb366a8
--- /dev/null
+++ b/contracts/subscription/specs/core-spec.md
@@ -0,0 +1,43 @@
+# SubTrackr Subscription Formal Specification
+
+## Scope
+
+This spec covers core safety properties for:
+
+- `subscribe`
+- `charge_subscription`
+- `cancel_subscription`
+- `pause_subscription` / `resume_subscription`
+- `request_transfer` / `accept_transfer`
+
+## Authorization Rules
+
+1. Only authorized actor(s) can mutate subscription ownership or state.
+2. Non-admin callers cannot bypass `require_auth`.
+3. Refund approval/rejection can only be executed by admin.
+
+## Balance Rules
+
+1. `charge_subscription` transfers exactly `plan.price` from subscriber to merchant.
+2. `total_paid` is monotonically non-decreasing except when explicit refunds are approved.
+3. `refund_requested_amount` never exceeds `total_paid`.
+
+## State Transition Rules
+
+Allowed transitions:
+
+- `Active -> Paused`
+- `Paused -> Active`
+- `Active|Paused -> Cancelled`
+
+Disallowed transitions:
+
+- `Cancelled -> Active`
+- Any transition by unauthorized actors
+
+## Invariants
+
+1. `SubscriptionCount` is monotonically non-decreasing.
+2. `Plan.subscriber_count >= 0` (underflow impossible).
+3. `next_charge_at >= last_charged_at` for non-cancelled subscriptions.
+4. A user has at most one active/non-cancelled subscription per plan (`UserPlanIndex` uniqueness).
diff --git a/contracts/subscription/specs/verification-results.md b/contracts/subscription/specs/verification-results.md
new file mode 100644
index 0000000..e32a9e2
--- /dev/null
+++ b/contracts/subscription/specs/verification-results.md
@@ -0,0 +1,21 @@
+# Formal Verification Results
+
+This document records the latest formal verification status for `contracts/subscription`.
+
+## Properties Under Verification
+
+- Authorization invariants
+- Balance and refund safety bounds
+- Subscription state transition correctness
+- Global invariants (count monotonicity, index uniqueness)
+
+## Latest Run
+
+- Status: `Pending initial baseline run`
+- CI Workflow: `.github/workflows/formal-verification.yml`
+- Tooling: `certora-cli`
+
+## Notes
+
+- The current spec in `contracts/subscription/certora/SubTrackrSubscription.spec` is scaffolded.
+- Replace placeholder rules with concrete storage-model assertions as the harness evolves.
diff --git a/e2e/README.md b/e2e/README.md
new file mode 100644
index 0000000..b22cecf
--- /dev/null
+++ b/e2e/README.md
@@ -0,0 +1,25 @@
+# SubTrackr E2E Suite
+
+## Coverage
+
+- Subscription creation flow
+- Subscription charging simulation flow
+- Subscription cancellation flow
+- Subscription plan change flow
+- Visual regression snapshots (home + detail screens)
+
+## Parallel execution
+
+- iOS: `npm run e2e:test-ios:parallel`
+- Android: `npm run e2e:test-android:parallel`
+
+## Visual baselines
+
+Visual hashes are stored in `e2e/fixtures/visual-baselines.json`.
+
+- Run in strict comparison mode (default): screenshots are compared to stored hashes.
+- Update baselines intentionally:
+
+```bash
+UPDATE_VISUAL_BASELINE=true npm run e2e:test-ios -- --testNamePattern "Subscription Visual Regression"
+```
diff --git a/e2e/fixtures/visual-baselines.json b/e2e/fixtures/visual-baselines.json
new file mode 100644
index 0000000..0967ef4
--- /dev/null
+++ b/e2e/fixtures/visual-baselines.json
@@ -0,0 +1 @@
+{}
diff --git a/e2e/helpers/subscriptionFlows.ts b/e2e/helpers/subscriptionFlows.ts
new file mode 100644
index 0000000..d8408f9
--- /dev/null
+++ b/e2e/helpers/subscriptionFlows.ts
@@ -0,0 +1,59 @@
+import { by, device, element, expect, waitFor } from 'detox';
+
+const BILLING_LABELS: Record<'monthly' | 'yearly' | 'weekly', string> = {
+ monthly: 'Monthly',
+ yearly: 'Yearly',
+ weekly: 'Weekly',
+};
+
+export const launchCleanApp = async () => {
+ await device.launchApp({ newInstance: true, delete: true });
+ await waitFor(element(by.id('app-root'))).toExist().withTimeout(30000);
+ await waitFor(element(by.id('home-screen'))).toExist().withTimeout(30000);
+};
+
+export const createSubscription = async (
+ name: string,
+ price: string,
+ cycle: 'monthly' | 'yearly' | 'weekly' = 'monthly'
+) => {
+ await element(by.id('add-subscription-button')).tap();
+ await waitFor(element(by.id('add-subscription-screen'))).toBeVisible().withTimeout(10000);
+ await expect(element(by.id('subscription-form-title'))).toBeVisible();
+
+ await element(by.id('subscription-name-input')).replaceText(name);
+ await element(by.id('subscription-price-input')).replaceText(price);
+
+ if (cycle !== 'monthly') {
+ await element(by.id(`billing-cycle-option-${cycle}`)).tap();
+ }
+
+ await element(by.id('save-subscription-button')).tap();
+ await dismissAnySystemAlert();
+
+ await waitFor(element(by.text(name))).toBeVisible().withTimeout(15000);
+};
+
+export const openSubscriptionByName = async (name: string) => {
+ await waitFor(element(by.text(name))).toBeVisible().withTimeout(10000);
+ await element(by.text(name)).tap();
+ await waitFor(element(by.id('subscription-detail-screen'))).toBeVisible().withTimeout(10000);
+};
+
+export const expectBillingCycle = async (cycle: 'monthly' | 'yearly' | 'weekly') => {
+ await expect(element(by.id('subscription-billing-cycle-value'))).toHaveText(BILLING_LABELS[cycle]);
+};
+
+export const dismissAnySystemAlert = async () => {
+ const labels = ['OK', 'Ok', 'Later', 'Cancel'];
+ for (const label of labels) {
+ const alertButton = element(by.text(label));
+ try {
+ await waitFor(alertButton).toBeVisible().withTimeout(600);
+ await alertButton.tap();
+ return;
+ } catch {
+ // No-op: button not present.
+ }
+ }
+};
diff --git a/e2e/helpers/visualRegression.ts b/e2e/helpers/visualRegression.ts
new file mode 100644
index 0000000..57efcea
--- /dev/null
+++ b/e2e/helpers/visualRegression.ts
@@ -0,0 +1,36 @@
+import * as crypto from 'crypto';
+import * as fs from 'fs';
+import * as path from 'path';
+
+type BaselineMap = Record;
+
+const baselineFile = path.resolve(__dirname, '../fixtures/visual-baselines.json');
+
+const readBaselines = (): BaselineMap => {
+ if (!fs.existsSync(baselineFile)) return {};
+ return JSON.parse(fs.readFileSync(baselineFile, 'utf8')) as BaselineMap;
+};
+
+const writeBaselines = (baselines: BaselineMap) => {
+ fs.mkdirSync(path.dirname(baselineFile), { recursive: true });
+ fs.writeFileSync(baselineFile, JSON.stringify(baselines, null, 2));
+};
+
+const hashFile = (filePath: string) => {
+ const content = fs.readFileSync(filePath);
+ return crypto.createHash('sha256').update(content).digest('hex');
+};
+
+export const assertVisualSnapshot = (name: string, screenshotPath: string) => {
+ const baselines = readBaselines();
+ const currentHash = hashFile(screenshotPath);
+ const updateBaselines = process.env.UPDATE_VISUAL_BASELINE === 'true';
+
+ if (!baselines[name] || updateBaselines) {
+ baselines[name] = currentHash;
+ writeBaselines(baselines);
+ return;
+ }
+
+ expect(currentHash).toBe(baselines[name]);
+};
diff --git a/e2e/jest.config.js b/e2e/jest.config.js
index e2c92b2..f860a22 100644
--- a/e2e/jest.config.js
+++ b/e2e/jest.config.js
@@ -3,11 +3,12 @@ module.exports = {
rootDir: '..',
testMatch: ['/e2e/**/*.test.ts'],
testTimeout: 120000,
- maxWorkers: 1,
+ maxWorkers: process.env.E2E_MAX_WORKERS ? Number(process.env.E2E_MAX_WORKERS) : 2,
globalSetup: 'detox/runners/jest/globalSetup',
globalTeardown: 'detox/runners/jest/globalTeardown',
reporters: ['detox/runners/jest/reporter'],
testEnvironment: 'detox/runners/jest/testEnvironment',
+ setupFilesAfterEnv: ['/e2e/setup.ts'],
verbose: true,
transform: {
'^.+\\.tsx?$': [
diff --git a/e2e/launch.test.ts b/e2e/launch.test.ts
index e708a81..846b881 100644
--- a/e2e/launch.test.ts
+++ b/e2e/launch.test.ts
@@ -1,24 +1,14 @@
-import { by, device, element, expect, waitFor } from 'detox';
+import { by, expect, element } from 'detox';
+import { launchCleanApp } from './helpers/subscriptionFlows';
describe('App Launch', () => {
beforeAll(async () => {
- await device.launchApp();
- });
-
- beforeEach(async () => {
- await device.reloadReactNative();
+ await launchCleanApp();
});
it('should launch the app properly', async () => {
- // Using robust wait to ensure app loads
- const appContainer = element(by.id('app-root')).atIndex(0);
- // If 'app-root' testID isn't set, we might expect another known element,
- // adjusting based on what's available or failing gracefully for now.
- try {
- await waitFor(appContainer).toExist().withTimeout(10000);
- } catch (e) {
- // Fallback check if testIDs aren't fully injected yet
- await expect(element(by.text('SubTrackr')).atIndex(0)).toBeVisible();
- }
+ await expect(element(by.id('app-root'))).toExist();
+ await expect(element(by.id('home-screen'))).toBeVisible();
+ await expect(element(by.text('SubTrackr'))).toBeVisible();
});
});
diff --git a/e2e/payment.test.ts b/e2e/payment.test.ts
index 50e2315..52758e0 100644
--- a/e2e/payment.test.ts
+++ b/e2e/payment.test.ts
@@ -1,30 +1,32 @@
-import { by, device, element, expect, waitFor } from 'detox';
+import { by, element, expect, waitFor } from 'detox';
+import {
+ createSubscription,
+ launchCleanApp,
+ openSubscriptionByName,
+} from './helpers/subscriptionFlows';
-describe('Crypto Payment Flow', () => {
+describe('Subscription Charging Flow E2E', () => {
beforeAll(async () => {
- await device.launchApp();
+ await launchCleanApp();
});
beforeEach(async () => {
- await device.reloadReactNative();
+ await launchCleanApp();
});
- it('should handle crypto payment modal trigger', async () => {
- const subItem = element(by.text('Detox Test Sub'));
- try {
- await waitFor(subItem).toBeVisible().withTimeout(5000);
- await subItem.tap();
+ it('simulates successful and failed billing events', async () => {
+ const subName = 'E2E Charge Flow';
+ await createSubscription(subName, '11.99');
+ await openSubscriptionByName(subName);
- const payBtn = element(by.id('pay-crypto-button'));
- await waitFor(payBtn).toBeVisible().withTimeout(3000);
- await payBtn.tap();
+ await expect(element(by.id('simulate-charge-success-button'))).toBeVisible();
+ await element(by.id('simulate-charge-success-button')).tap();
- const walletModal = element(by.id('wallet-connect-modal'));
- await expect(walletModal).toBeVisible();
- } catch (e) {
- console.warn(
- 'Elements not found, test will require proper testID assignment in UI components.'
- );
- }
+ await waitFor(element(by.id('simulate-charge-failed-button'))).toBeVisible().withTimeout(5000);
+ await element(by.id('simulate-charge-failed-button')).tap();
+
+ // Validate action controls still available after charging operations.
+ await expect(element(by.id('cancel-subscription-button'))).toBeVisible();
+ await expect(element(by.id('pause-resume-subscription-button'))).toBeVisible();
});
});
diff --git a/e2e/setup.ts b/e2e/setup.ts
new file mode 100644
index 0000000..ee310b2
--- /dev/null
+++ b/e2e/setup.ts
@@ -0,0 +1 @@
+jest.setTimeout(180000);
diff --git a/e2e/subscription.test.ts b/e2e/subscription.test.ts
index be77f3f..ae1e67a 100644
--- a/e2e/subscription.test.ts
+++ b/e2e/subscription.test.ts
@@ -1,34 +1,44 @@
-import { by, device, element, expect, waitFor } from 'detox';
+import { by, element, expect, waitFor } from 'detox';
+import {
+ createSubscription,
+ dismissAnySystemAlert,
+ launchCleanApp,
+ openSubscriptionByName,
+ expectBillingCycle,
+} from './helpers/subscriptionFlows';
-describe('Add Subscription Flow', () => {
+describe('Subscription Lifecycle E2E', () => {
beforeAll(async () => {
- await device.launchApp();
+ await launchCleanApp();
});
beforeEach(async () => {
- await device.reloadReactNative();
+ await launchCleanApp();
});
- it('should navigate to add subscription screen and add one smoothly', async () => {
- const addBtn = element(by.id('add-subscription-button'));
- try {
- await waitFor(addBtn).toBeVisible().withTimeout(5000);
- await addBtn.tap();
+ it('creates a subscription from home flow', async () => {
+ await createSubscription('E2E Create Subscription', '9.99', 'monthly');
+ await expect(element(by.text('E2E Create Subscription'))).toBeVisible();
+ });
- const title = element(by.id('subscription-form-title'));
- await expect(title).toBeVisible();
+ it('cancels an existing subscription', async () => {
+ const subName = 'E2E Cancel Subscription';
+ await createSubscription(subName, '14.50', 'monthly');
+ await openSubscriptionByName(subName);
- await element(by.id('subscription-name-input')).typeText('Detox Test Sub\n');
- await element(by.id('subscription-price-input')).typeText('9.99\n');
+ await element(by.id('cancel-subscription-button')).tap();
+ await waitFor(element(by.text('Yes, Cancel'))).toBeVisible().withTimeout(3000);
+ await element(by.text('Yes, Cancel')).tap();
+ await dismissAnySystemAlert();
- const saveBtn = element(by.id('save-subscription-button'));
- await saveBtn.tap();
+ await waitFor(element(by.id('home-screen'))).toBeVisible().withTimeout(10000);
+ await waitFor(element(by.text(subName))).not.toExist().withTimeout(10000);
+ });
- await expect(element(by.text('Detox Test Sub'))).toBeVisible();
- } catch (e) {
- console.warn(
- 'Elements not found, test will require proper testID assignment in UI components.'
- );
- }
+ it('changes plan cycle and reflects in detail screen', async () => {
+ const subName = 'E2E Plan Change';
+ await createSubscription(subName, '49.99', 'yearly');
+ await openSubscriptionByName(subName);
+ await expectBillingCycle('yearly');
});
});
diff --git a/e2e/visual-regression.test.ts b/e2e/visual-regression.test.ts
new file mode 100644
index 0000000..aee946f
--- /dev/null
+++ b/e2e/visual-regression.test.ts
@@ -0,0 +1,23 @@
+import { by, device, element, waitFor } from 'detox';
+import { assertVisualSnapshot } from './helpers/visualRegression';
+import { createSubscription, launchCleanApp, openSubscriptionByName } from './helpers/subscriptionFlows';
+
+describe('Subscription Visual Regression', () => {
+ beforeEach(async () => {
+ await launchCleanApp();
+ });
+
+ it('captures home and detail visual baselines', async () => {
+ await waitFor(element(by.id('home-screen'))).toBeVisible().withTimeout(10000);
+ const homeShot = await device.takeScreenshot('home-screen');
+ assertVisualSnapshot('home-screen', homeShot);
+
+ const subName = 'E2E Visual Baseline';
+ await createSubscription(subName, '8.49');
+ await openSubscriptionByName(subName);
+
+ await waitFor(element(by.id('subscription-detail-screen'))).toBeVisible().withTimeout(10000);
+ const detailShot = await device.takeScreenshot('subscription-detail-screen');
+ assertVisualSnapshot('subscription-detail-screen', detailShot);
+ });
+});
diff --git a/package.json b/package.json
index a9877e8..eef96d7 100644
--- a/package.json
+++ b/package.json
@@ -20,6 +20,7 @@
"contracts:fmt": "cd contracts && cargo fmt --check",
"contracts:clippy": "cd contracts && cargo clippy --all-targets -- -D warnings",
"contracts:build": "cd contracts && cargo build --release",
+ "contracts:verify": "cd contracts/subscription/certora && certoraRun ../src/lib.rs --verify SubTrackrSubscription:SubTrackrSubscription.spec --msg \"SubTrackr local formal verification\"",
"contracts:codegen": "typechain --target ethers-v5 --out-dir src/contracts/types \"src/contracts/abis/**/*.json\"",
"contracts:codegen:check": "npm run contracts:codegen && git diff --exit-code -- src/contracts/types src/contracts/abis",
"release": "semantic-release",
@@ -31,10 +32,14 @@
"load:test": "k6 run load-tests/run.js",
"e2e:build-ios": "detox build -c ios.sim.release",
"e2e:test-ios": "detox test -c ios.sim.release",
+ "e2e:test-ios:parallel": "detox test -c ios.sim.release --workers 2",
"e2e:build-android": "detox build -c android.emu.release",
- "e2e:test-android": "detox test -c android.emu.release"
+ "e2e:test-android": "detox test -c android.emu.release",
+ "e2e:test-android:parallel": "detox test -c android.emu.release --workers 2",
+ "e2e:visual:update-ios": "detox test -c ios.sim.release --testNamePattern \"Subscription Visual Regression\""
},
"dependencies": {
+ "@shopify/flash-list": "latest",
"@react-native-async-storage/async-storage": "2.1.2",
"@react-native-community/datetimepicker": "^9.1.0",
"@react-native-community/netinfo": "11.4.1",
diff --git a/src/components/common/Button.tsx b/src/components/common/Button.tsx
index 82d0b25..83869fe 100644
--- a/src/components/common/Button.tsx
+++ b/src/components/common/Button.tsx
@@ -21,6 +21,7 @@ export interface ButtonProps {
accessibilityLabel?: string;
accessibilityHint?: string;
accessibilitySelected?: boolean;
+ testID?: string;
}
export const Button: React.FC = ({
@@ -35,6 +36,7 @@ export const Button: React.FC = ({
accessibilityLabel,
accessibilityHint,
accessibilitySelected = false,
+ testID,
}) => {
const buttonStyle = [
styles.button,
@@ -59,6 +61,7 @@ export const Button: React.FC = ({
disabled={disabled || loading}
activeOpacity={0.8}
accessibilityRole="button"
+ testID={testID}
accessibilityLabel={accessibilityLabel ?? title}
accessibilityHint={accessibilityHint}
accessibilityState={{
diff --git a/src/components/common/FloatingActionButton.tsx b/src/components/common/FloatingActionButton.tsx
index 94cd26e..4c3237d 100644
--- a/src/components/common/FloatingActionButton.tsx
+++ b/src/components/common/FloatingActionButton.tsx
@@ -10,6 +10,7 @@ export interface FloatingActionButtonProps {
size?: 'small' | 'medium' | 'large';
accessibilityLabel?: string;
accessibilityHint?: string;
+ testID?: string;
}
export const FloatingActionButton: React.FC = ({
@@ -20,6 +21,7 @@ export const FloatingActionButton: React.FC = ({
size = 'medium',
accessibilityLabel,
accessibilityHint,
+ testID,
}) => {
const buttonStyle = [styles.button, styles[size], style];
@@ -28,6 +30,7 @@ export const FloatingActionButton: React.FC = ({
style={buttonStyle}
onPress={onPress}
activeOpacity={0.8}
+ testID={testID}
accessibilityRole="button"
accessibilityLabel={accessibilityLabel ?? title ?? 'Add item'}
accessibilityHint={accessibilityHint ?? 'Activates the primary action'}>
diff --git a/src/components/home/SubscriptionList.tsx b/src/components/home/SubscriptionList.tsx
index 675add1..cb43131 100644
--- a/src/components/home/SubscriptionList.tsx
+++ b/src/components/home/SubscriptionList.tsx
@@ -1,8 +1,10 @@
-import React from 'react';
+import React, { useCallback } from 'react';
import { View, Text, StyleSheet, TouchableOpacity } from 'react-native';
+import { FlashList } from '@shopify/flash-list';
import { colors, spacing, typography, borderRadius, shadows } from '../../utils/constants';
import { SubscriptionCard } from '../subscription/SubscriptionCard';
import { Subscription } from '../../types/subscription';
+import { usePerformanceProfiler } from '../../hooks/usePerformanceProfiler';
interface SubscriptionListProps {
subscriptions: Subscription[];
@@ -17,7 +19,7 @@ interface SubscriptionListProps {
onAddFirstPress: () => void;
}
-export const SubscriptionList: React.FC = ({
+export const SubscriptionList: React.FC = React.memo(({
subscriptions: _subscriptions,
activeSubscriptions,
upcomingSubscriptions,
@@ -29,8 +31,22 @@ export const SubscriptionList: React.FC = ({
onToggleStatus,
onAddFirstPress,
}) => {
+ usePerformanceProfiler('SubscriptionList', {
+ activeCount: activeSubscriptions.length,
+ upcomingCount: upcomingSubscriptions.length,
+ });
+
+ const renderItem = useCallback(
+ ({ item }: { item: Subscription }) => (
+
+ ),
+ [onSubscriptionPress, onToggleStatus]
+ );
+
+ const keyExtractor = useCallback((item: Subscription) => item.id, []);
+
return (
-
+
{/* Upcoming Billing Section */}
{upcomingSubscriptions && upcomingSubscriptions.length > 0 && (
@@ -85,14 +101,15 @@ export const SubscriptionList: React.FC = ({
{hasSubscriptions ? (
- {activeSubscriptions.map((subscription) => (
-
- ))}
+
) : (
= ({
Add Subscription
@@ -120,7 +138,7 @@ export const SubscriptionList: React.FC = ({
);
-};
+});
const styles = StyleSheet.create({
section: {
diff --git a/src/components/subscription/SubscriptionCard.tsx b/src/components/subscription/SubscriptionCard.tsx
index 93de5aa..519b238 100644
--- a/src/components/subscription/SubscriptionCard.tsx
+++ b/src/components/subscription/SubscriptionCard.tsx
@@ -21,7 +21,7 @@ export interface SubscriptionCardProps {
onToggleStatus?: (id: string) => void;
}
-export const SubscriptionCard: React.FC = ({
+export const SubscriptionCard: React.FC = React.memo(({
subscription,
onPress,
onToggleStatus,
@@ -43,6 +43,7 @@ export const SubscriptionCard: React.FC = ({
return (
= ({
-
+
{subscription.name}
@@ -129,6 +130,7 @@ export const SubscriptionCard: React.FC = ({
style={styles.toggleButton}
onPress={handleToggleStatus}
activeOpacity={0.7}
+ testID={`subscription-toggle-${subscription.id}`}
accessibilityRole="button"
accessibilityLabel={
subscription.isActive ? `Pause ${subscription.name}` : `Activate ${subscription.name}`
@@ -138,7 +140,7 @@ export const SubscriptionCard: React.FC = ({
)}
);
-};
+});
const styles = StyleSheet.create({
container: {
diff --git a/src/hooks/useFilteredSubscriptions.ts b/src/hooks/useFilteredSubscriptions.ts
index c22112f..99eeeb8 100644
--- a/src/hooks/useFilteredSubscriptions.ts
+++ b/src/hooks/useFilteredSubscriptions.ts
@@ -77,40 +77,29 @@ export const useFilteredSubscriptions = (subscriptions: Subscription[]) => {
[sortBy, sortOrder]
);
- const searchedSubscriptions = useMemo(
- () => (subscriptions || []).filter(matchesSearch),
- [subscriptions, matchesSearch]
- );
-
- const categoryFilteredSubscriptions = useMemo(
- () => searchedSubscriptions.filter(matchesCategory),
- [searchedSubscriptions, matchesCategory]
- );
-
- const billingCycleFilteredSubscriptions = useMemo(
- () => categoryFilteredSubscriptions.filter(matchesBillingCycle),
- [categoryFilteredSubscriptions, matchesBillingCycle]
- );
-
- const priceFilteredSubscriptions = useMemo(
- () => billingCycleFilteredSubscriptions.filter(matchesPriceRange),
- [billingCycleFilteredSubscriptions, matchesPriceRange]
- );
-
- const activeFilteredSubscriptions = useMemo(
- () => priceFilteredSubscriptions.filter(matchesActiveOnly),
- [priceFilteredSubscriptions, matchesActiveOnly]
- );
-
- const cryptoFilteredSubscriptions = useMemo(
- () => activeFilteredSubscriptions.filter(matchesCryptoOnly),
- [activeFilteredSubscriptions, matchesCryptoOnly]
- );
-
- const filteredAndSorted = useMemo(
- () => [...cryptoFilteredSubscriptions].sort(comparator),
- [cryptoFilteredSubscriptions, comparator]
- );
+ const filteredAndSorted = useMemo(() => {
+ const source = subscriptions || [];
+ const filtered = source.filter(
+ (sub) =>
+ matchesSearch(sub) &&
+ matchesCategory(sub) &&
+ matchesBillingCycle(sub) &&
+ matchesPriceRange(sub) &&
+ matchesActiveOnly(sub) &&
+ matchesCryptoOnly(sub)
+ );
+
+ return filtered.sort(comparator);
+ }, [
+ subscriptions,
+ matchesSearch,
+ matchesCategory,
+ matchesBillingCycle,
+ matchesPriceRange,
+ matchesActiveOnly,
+ matchesCryptoOnly,
+ comparator,
+ ]);
const clearAllFilters = useCallback(() => {
setSearchQuery('');
diff --git a/src/hooks/usePerformanceProfiler.ts b/src/hooks/usePerformanceProfiler.ts
new file mode 100644
index 0000000..73c34fb
--- /dev/null
+++ b/src/hooks/usePerformanceProfiler.ts
@@ -0,0 +1,22 @@
+import { useEffect, useRef } from 'react';
+import { performanceMonitor } from '../services/performanceMonitor';
+
+export const usePerformanceProfiler = (
+ name: string,
+ metadata?: Record
+): void => {
+ const start = useRef(Date.now());
+
+ useEffect(() => {
+ const durationMs = Date.now() - start.current;
+ performanceMonitor.track({
+ type: 'render',
+ name,
+ durationMs,
+ timestamp: Date.now(),
+ metadata,
+ });
+
+ start.current = Date.now();
+ });
+};
diff --git a/src/navigation/AppNavigator.tsx b/src/navigation/AppNavigator.tsx
index 047def9..18ed634 100644
--- a/src/navigation/AppNavigator.tsx
+++ b/src/navigation/AppNavigator.tsx
@@ -12,6 +12,7 @@ import SubscriptionDetailScreen from '../screens/SubscriptionDetailScreen';
import AnalyticsScreen from '../screens/AnalyticsScreen';
import GDPRSettingsScreen from '../screens/GDPRSettingsScreen';
import LanguageSettingsScreen from '../screens/LanguageSettingsScreen';
+import SessionManagementScreen from '../screens/SessionManagementScreen';
import SettingsScreen from '../screens/SettingsScreen';
import ErrorDashboardScreen from '../screens/ErrorDashboardScreen';
import AdminDashboardScreen from '../screens/AdminDashboardScreen';
@@ -78,6 +79,11 @@ const SettingsStack = () => (
component={LanguageSettingsScreen}
options={{ title: 'Language', headerShown: true }}
/>
+
{
// Date Picker States
const [showPicker, setShowPicker] = useState(false);
const [pickerMode, setPickerMode] = useState<'date' | 'time'>('date');
+ const [selectedCategory, setSelectedCategory] = useState(
+ SubscriptionCategory.OTHER
+ );
+ const [selectedBillingCycle, setSelectedBillingCycle] = useState(
+ BillingCycle.MONTHLY
+ );
const handleCategorySelect = (category: SubscriptionCategory) => {
setSelectedCategory(category);
@@ -171,17 +177,22 @@ const AddSubscriptionScreen: React.FC = () => {
};
return (
-
+
-
+
Cancel
- Add Subscription
+
+ Add Subscription
+
Track your new subscription
@@ -202,6 +213,7 @@ const AddSubscriptionScreen: React.FC = () => {
accessibilityLabel="Subscription name, required"
accessibilityHint="Enter the name of the subscription service"
returnKeyType="next"
+ testID="subscription-name-input"
/>
@@ -282,6 +294,7 @@ const AddSubscriptionScreen: React.FC = () => {
keyboardType="decimal-pad"
accessibilityLabel="Price, required"
accessibilityHint="Enter the subscription price"
+ testID="subscription-price-input"
/>
{formData.priceError ? (
@@ -328,6 +341,7 @@ const AddSubscriptionScreen: React.FC = () => {
selectedBillingCycle === cycle && styles.billingCycleItemSelected,
]}
onPress={() => handleBillingCycleSelect(cycle)}
+ testID={`billing-cycle-option-${cycle}`}
accessibilityRole="radio"
accessibilityLabel={cycle.charAt(0).toUpperCase() + cycle.slice(1)}
accessibilityState={{ checked: selectedBillingCycle === cycle }}>
@@ -415,6 +429,7 @@ const AddSubscriptionScreen: React.FC = () => {
loading={isLoading}
fullWidth
size="large"
+ testID="save-subscription-button"
/>
diff --git a/src/screens/HomeScreen.tsx b/src/screens/HomeScreen.tsx
index 5c6c2ea..a76e46e 100644
--- a/src/screens/HomeScreen.tsx
+++ b/src/screens/HomeScreen.tsx
@@ -1,4 +1,4 @@
-import React, { useEffect, useState } from 'react';
+import React, { useEffect, useMemo, useState } from 'react';
import { View, Text, StyleSheet, ScrollView, SafeAreaView, RefreshControl, TouchableOpacity } from 'react-native';
import { useNavigation } from '@react-navigation/native';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
@@ -9,6 +9,8 @@ import { getUpcomingSubscriptions } from '../utils/dummyData';
import { Subscription } from '../types/subscription';
import { RootStackParamList } from '../navigation/types';
import { useGamificationStore } from '../store/gamificationStore';
+import { useTransactionQueueStore } from '../store/transactionQueueStore';
+import { usePerformanceProfiler } from '../hooks/usePerformanceProfiler';
// Components
import { FloatingActionButton } from '../components/common/FloatingActionButton';
@@ -24,15 +26,26 @@ const HomeScreen: React.FC = () => {
const navigation = useNavigation();
const { subscriptions, stats, fetchSubscriptions, calculateStats, toggleSubscriptionStatus } =
useSubscriptionStore();
- const { points, level } = useGamificationStore();
+ const isOnline = useTransactionQueueStore((state) => state.isOnline);
+ const pendingTransactions = useTransactionQueueStore((state) => state.queuedTransactions.length);
+ const { level } = useGamificationStore();
const [refreshing, setRefreshing] = useState(false);
const [upcomingSubscriptions, setUpcomingSubscriptions] = useState([]);
// Use the new hook
const { filters, filteredAndSorted, activeFilterCount, hasActiveFilters, clearAllFilters } =
useFilteredSubscriptions(subscriptions);
+ const activeSubscriptions = useMemo(
+ () => filteredAndSorted.filter((subscription) => subscription.isActive),
+ [filteredAndSorted]
+ );
const [showFilterModal, setShowFilterModal] = useState(false);
+ usePerformanceProfiler('HomeScreen', {
+ subscriptions: subscriptions.length,
+ filtered: filteredAndSorted.length,
+ });
+
useEffect(() => {
calculateStats();
if (subscriptions) setUpcomingSubscriptions(getUpcomingSubscriptions(subscriptions));
@@ -49,7 +62,10 @@ const HomeScreen: React.FC = () => {
};
return (
-
+
{
onWalletPress={() => navigation.navigate('WalletConnect')}
/>
+ {!isOnline && (
+
+
+ You are offline. {pendingTransactions} queued transaction
+ {pendingTransactions === 1 ? '' : 's'} will sync when back online.
+
+
+ )}
+
s.isActive)}
+ activeSubscriptions={activeSubscriptions}
upcomingSubscriptions={upcomingSubscriptions}
hasSubscriptions={subscriptions.length > 0}
hasActiveFilters={hasActiveFilters}
@@ -116,6 +141,7 @@ const HomeScreen: React.FC = () => {
onPress={() => navigation.navigate('AddSubscription')}
icon="+"
size="large"
+ testID="add-subscription-button"
/>
)}
diff --git a/src/screens/SessionManagementScreen.tsx b/src/screens/SessionManagementScreen.tsx
new file mode 100644
index 0000000..729537a
--- /dev/null
+++ b/src/screens/SessionManagementScreen.tsx
@@ -0,0 +1,166 @@
+import React, { useCallback, useEffect, useMemo, useState } from 'react';
+import { Alert, SafeAreaView, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
+import { colors, spacing, typography, borderRadius } from '../utils/constants';
+import { sessionService, SessionRecord, SessionSettings } from '../services/auth/session';
+import { Card } from '../components/common/Card';
+
+const timeoutOptions = [5, 15, 30, 60, 120];
+
+const formatDateTime = (timestamp: number) => new Date(timestamp).toLocaleString();
+
+const SessionManagementScreen: React.FC = () => {
+ const [sessions, setSessions] = useState([]);
+ const [settings, setSettings] = useState({ timeoutMinutes: 30 });
+
+ const refresh = useCallback(async () => {
+ const [resolvedSettings] = await Promise.all([
+ sessionService.getSettings(),
+ sessionService.initializeCurrentSession(),
+ sessionService.detectSuspiciousSessions(),
+ ]);
+ setSettings(resolvedSettings);
+ const latest = await sessionService.getSessions();
+ setSessions(latest);
+ }, []);
+
+ useEffect(() => {
+ void refresh();
+ }, [refresh]);
+
+ const activeSessions = useMemo(() => sessions.filter((session) => !session.revokedAt), [sessions]);
+ const currentSession = useMemo(() => activeSessions.find((session) => session.isCurrent), [activeSessions]);
+
+ const handleTimeoutChange = async (minutes: number) => {
+ const updated = await sessionService.updateSettings({ timeoutMinutes: minutes });
+ setSettings(updated);
+ await refresh();
+ };
+
+ const handleRevoke = (session: SessionRecord) => {
+ Alert.alert('Revoke Session', `Revoke session on ${session.deviceName}?`, [
+ { text: 'Cancel', style: 'cancel' },
+ {
+ text: 'Revoke',
+ style: 'destructive',
+ onPress: async () => {
+ await sessionService.revokeSession(session.id);
+ await refresh();
+ },
+ },
+ ]);
+ };
+
+ const handleForceLogoutOthers = async () => {
+ if (!currentSession) return;
+ await sessionService.revokeOtherSessions(currentSession.id);
+ await refresh();
+ };
+
+ return (
+
+
+
+ Session Management
+ Control active sessions and security policies
+
+
+
+ Session Timeout
+
+ {timeoutOptions.map((minutes) => (
+ void handleTimeoutChange(minutes)}>
+
+ {minutes}m
+
+
+ ))}
+
+
+
+
+ Concurrent Sessions ({activeSessions.length})
+ {activeSessions.map((session) => (
+
+
+
+ {session.deviceName} {session.isCurrent ? '(Current)' : ''}
+
+ Last active: {formatDateTime(session.lastActiveAt)}
+ {session.suspicious && Suspicious: {session.reason}}
+
+ {!session.isCurrent && (
+ handleRevoke(session)}>
+ Revoke
+
+ )}
+
+ ))}
+
+ void handleForceLogoutOthers()}>
+ Force Logout Other Sessions
+
+
+
+
+ );
+};
+
+const styles = StyleSheet.create({
+ container: { flex: 1, backgroundColor: colors.background },
+ scrollView: { flex: 1 },
+ header: { padding: spacing.lg, paddingBottom: spacing.md },
+ title: { ...typography.h1, color: colors.text, marginBottom: spacing.xs },
+ subtitle: { ...typography.body, color: colors.textSecondary },
+ card: { marginHorizontal: spacing.lg, marginBottom: spacing.md },
+ sectionTitle: { ...typography.h3, color: colors.text, marginBottom: spacing.md },
+ rowWrap: { flexDirection: 'row', flexWrap: 'wrap', gap: spacing.sm },
+ timeoutChip: {
+ paddingHorizontal: spacing.md,
+ paddingVertical: spacing.sm,
+ borderWidth: 1,
+ borderColor: colors.border,
+ borderRadius: borderRadius.md,
+ backgroundColor: colors.surface,
+ },
+ timeoutChipActive: { backgroundColor: colors.primary, borderColor: colors.primary },
+ timeoutChipText: { ...typography.body, color: colors.text },
+ timeoutChipTextActive: { color: colors.text, fontWeight: '700' },
+ sessionRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: spacing.md,
+ paddingVertical: spacing.md,
+ borderBottomWidth: 1,
+ borderBottomColor: colors.border,
+ },
+ sessionName: { ...typography.body, color: colors.text, fontWeight: '600' },
+ sessionMeta: { ...typography.caption, color: colors.textSecondary, marginTop: spacing.xs },
+ suspiciousText: { ...typography.caption, color: colors.error, marginTop: spacing.xs },
+ revokeButton: {
+ paddingHorizontal: spacing.md,
+ paddingVertical: spacing.sm,
+ backgroundColor: colors.error + '20',
+ borderRadius: borderRadius.md,
+ },
+ revokeButtonText: { ...typography.caption, color: colors.error, fontWeight: '700' },
+ forceLogoutButton: {
+ marginTop: spacing.md,
+ paddingVertical: spacing.md,
+ borderRadius: borderRadius.md,
+ backgroundColor: colors.error,
+ alignItems: 'center',
+ },
+ forceLogoutText: { ...typography.body, color: colors.text, fontWeight: '700' },
+});
+
+export default SessionManagementScreen;
diff --git a/src/screens/SettingsScreen.tsx b/src/screens/SettingsScreen.tsx
index ffc029c..aaabb9c 100644
--- a/src/screens/SettingsScreen.tsx
+++ b/src/screens/SettingsScreen.tsx
@@ -214,6 +214,15 @@ const SettingsScreen: React.FC = () => {
Language
→
+ navigation.navigate('SessionManagement')}
+ accessibilityRole="button"
+ accessibilityLabel="Session management"
+ accessibilityHint="Opens active session security controls">
+ Session Management
+ →
+
{__DEV__ && (
{
};
return (
-
+
{/* Header */}
@@ -177,7 +177,7 @@ const SubscriptionDetailScreen: React.FC = () => {
Billing Cycle
-
+
{subscription.billingCycle.charAt(0).toUpperCase() +
subscription.billingCycle.slice(1)}
@@ -217,12 +217,14 @@ const SubscriptionDetailScreen: React.FC = () => {
void recordBillingOutcome(subscription.id, 'success')}
- style={styles.simulateLink}>
+ style={styles.simulateLink}
+ testID="simulate-charge-success-button">
Simulate successful charge
void recordBillingOutcome(subscription.id, 'failed')}
- style={styles.simulateLink}>
+ style={styles.simulateLink}
+ testID="simulate-charge-failed-button">
Simulate failed charge
@@ -238,6 +240,7 @@ const SubscriptionDetailScreen: React.FC = () => {
subscription.isActive ? styles.statusActive : styles.statusInactive,
]}>
{
onPress={handlePauseResume}
variant="secondary"
style={styles.actionButton}
+ testID="pause-resume-subscription-button"
/>
diff --git a/src/services/auth/session.ts b/src/services/auth/session.ts
new file mode 100644
index 0000000..196bb8b
--- /dev/null
+++ b/src/services/auth/session.ts
@@ -0,0 +1,130 @@
+import AsyncStorage from '@react-native-async-storage/async-storage';
+import * as Application from 'expo-application';
+
+const SESSION_STORAGE_KEY = '@subtrackr_sessions';
+const SESSION_SETTINGS_KEY = '@subtrackr_session_settings';
+
+export interface SessionRecord {
+ id: string;
+ deviceName: string;
+ platform: string;
+ createdAt: number;
+ lastActiveAt: number;
+ isCurrent: boolean;
+ revokedAt?: number;
+ suspicious?: boolean;
+ reason?: string;
+}
+
+export interface SessionSettings {
+ timeoutMinutes: number;
+}
+
+const defaultSettings: SessionSettings = {
+ timeoutMinutes: 30,
+};
+
+const now = () => Date.now();
+
+const readSessions = async (): Promise => {
+ const raw = await AsyncStorage.getItem(SESSION_STORAGE_KEY);
+ return raw ? (JSON.parse(raw) as SessionRecord[]) : [];
+};
+
+const writeSessions = async (sessions: SessionRecord[]) => {
+ await AsyncStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify(sessions));
+};
+
+export const sessionService = {
+ async getSettings(): Promise {
+ const raw = await AsyncStorage.getItem(SESSION_SETTINGS_KEY);
+ return raw ? ({ ...defaultSettings, ...(JSON.parse(raw) as SessionSettings) } as SessionSettings) : defaultSettings;
+ },
+
+ async updateSettings(settings: Partial): Promise {
+ const merged = { ...(await this.getSettings()), ...settings };
+ await AsyncStorage.setItem(SESSION_SETTINGS_KEY, JSON.stringify(merged));
+ return merged;
+ },
+
+ async getSessions(): Promise {
+ const settings = await this.getSettings();
+ const sessions = await readSessions();
+ const expiryMs = settings.timeoutMinutes * 60 * 1000;
+ const refreshed = sessions.map((session) => {
+ if (session.revokedAt) return session;
+ if (now() - session.lastActiveAt > expiryMs) {
+ return { ...session, revokedAt: now(), reason: 'Session timeout' };
+ }
+ return session;
+ });
+ await writeSessions(refreshed);
+ return refreshed.sort((a, b) => b.lastActiveAt - a.lastActiveAt);
+ },
+
+ async initializeCurrentSession(): Promise {
+ const sessions = await readSessions();
+ const existingCurrent = sessions.find((session) => session.isCurrent && !session.revokedAt);
+ if (existingCurrent) {
+ return this.touchSession(existingCurrent.id);
+ }
+
+ const current: SessionRecord = {
+ id: `session_${now()}_${Math.random().toString(36).slice(2, 7)}`,
+ deviceName: Application.applicationName || 'SubTrackr Device',
+ platform: Application.nativeApplicationVersion || 'unknown',
+ createdAt: now(),
+ lastActiveAt: now(),
+ isCurrent: true,
+ };
+
+ await writeSessions([current, ...sessions.map((session) => ({ ...session, isCurrent: false }))]);
+ return current;
+ },
+
+ async touchSession(sessionId: string): Promise {
+ const sessions = await readSessions();
+ let target: SessionRecord | undefined;
+ const updated = sessions.map((session) => {
+ if (session.id !== sessionId) return session;
+ target = { ...session, lastActiveAt: now() };
+ return target;
+ });
+ await writeSessions(updated);
+ if (!target) throw new Error('Session not found');
+ return target;
+ },
+
+ async revokeSession(sessionId: string): Promise {
+ const sessions = await readSessions();
+ const updated = sessions.map((session) =>
+ session.id === sessionId ? { ...session, revokedAt: now(), isCurrent: false, reason: 'Revoked by user' } : session
+ );
+ await writeSessions(updated);
+ },
+
+ async revokeOtherSessions(currentSessionId: string): Promise {
+ const sessions = await readSessions();
+ const updated = sessions.map((session) =>
+ session.id !== currentSessionId && !session.revokedAt
+ ? { ...session, revokedAt: now(), isCurrent: false, reason: 'Forced logout from current session' }
+ : session
+ );
+ await writeSessions(updated);
+ },
+
+ async detectSuspiciousSessions(): Promise {
+ const sessions = await readSessions();
+ const activeSessions = sessions.filter((session) => !session.revokedAt);
+ const suspicious = activeSessions.length > 3;
+ if (!suspicious) return sessions;
+
+ const flagged = sessions.map((session, index) =>
+ !session.revokedAt && index >= 2
+ ? { ...session, suspicious: true, reason: 'Unusual number of concurrent sessions' }
+ : session
+ );
+ await writeSessions(flagged);
+ return flagged;
+ },
+};
diff --git a/src/services/performanceMonitor.ts b/src/services/performanceMonitor.ts
new file mode 100644
index 0000000..2acb26f
--- /dev/null
+++ b/src/services/performanceMonitor.ts
@@ -0,0 +1,37 @@
+type MetricType = 'render' | 'interaction' | 'network';
+
+interface PerformanceMetric {
+ type: MetricType;
+ name: string;
+ durationMs: number;
+ timestamp: number;
+ metadata?: Record;
+}
+
+const SLOW_FRAME_BUDGET_MS = 16.67;
+
+class PerformanceMonitorService {
+ private metrics: PerformanceMetric[] = [];
+
+ track(metric: PerformanceMetric) {
+ this.metrics.push(metric);
+
+ // Keep memory bounded for long-running sessions.
+ if (this.metrics.length > 500) {
+ this.metrics = this.metrics.slice(-500);
+ }
+
+ if (__DEV__) {
+ const budgetNote = metric.durationMs > SLOW_FRAME_BUDGET_MS ? ' (over 60fps budget)' : '';
+ console.debug(
+ `[perf] ${metric.type}:${metric.name} ${metric.durationMs.toFixed(2)}ms${budgetNote}`
+ );
+ }
+ }
+
+ getRecentMetrics(limit = 50): PerformanceMetric[] {
+ return this.metrics.slice(-limit);
+ }
+}
+
+export const performanceMonitor = new PerformanceMonitorService();