From 73d0fd7d2f1ccc936347c866b7565cf686144e54 Mon Sep 17 00:00:00 2001 From: gospeltout Date: Mon, 28 Sep 2026 16:38:01 +0100 Subject: [PATCH] feat: add landscape-optimized layouts for analytics charts --- MOBILE_OPTIMIZATION_GUIDE.md | 106 +++-- RESPONSIVE_FEATURES.md | 77 ++-- src/components/charts/AnalyticsChart.tsx | 90 ++-- .../charts/LandscapeAnalyticsLayout.tsx | 389 ++++++++++++++++++ .../charts/MobileChartContainer.tsx | 44 +- .../LandscapeAnalyticsLayout.test.tsx | 207 ++++++++++ src/components/charts/index.ts | 4 + src/components/dashboard/AuditLog.tsx | 1 - src/components/dashboard/Contracts.tsx | 3 +- .../TransactionAnalyticsDashboard.tsx | 116 +++--- src/hooks/__tests__/useResponsive.test.ts | 113 +++++ src/hooks/useResponsive.ts | 111 +++-- .../transactionBuilder.property.test.ts | 5 - src/lib/tests/contractInvoker.test.ts | 1 - src/types/components.ts | 65 +-- 15 files changed, 1126 insertions(+), 206 deletions(-) create mode 100644 src/components/charts/LandscapeAnalyticsLayout.tsx create mode 100644 src/components/charts/__tests__/LandscapeAnalyticsLayout.test.tsx create mode 100644 src/components/charts/index.ts create mode 100644 src/hooks/__tests__/useResponsive.test.ts diff --git a/MOBILE_OPTIMIZATION_GUIDE.md b/MOBILE_OPTIMIZATION_GUIDE.md index 9647b8d3..2d4fe029 100644 --- a/MOBILE_OPTIMIZATION_GUIDE.md +++ b/MOBILE_OPTIMIZATION_GUIDE.md @@ -14,7 +14,7 @@ The dashboard is now fully optimized for mobile devices with touch-friendly inte ✅ Lighthouse mobile score target: ≥ 90 ✅ Touch targets: 48×48px (WCAG 2.1 AAA) ✅ Cross-platform (iOS, Android, Web) -✅ Page load < 3s on 4G +✅ Page load < 3s on 4G --- @@ -23,34 +23,36 @@ The dashboard is now fully optimized for mobile devices with touch-friendly inte ### 1. Touch Gestures #### Swipe Gestures + - **Swipe-left-to-close**: Close mobile sidebar with a left swipe - **Swipe-right-to-open**: Open sidebar from left edge of screen - **Swipe-down-to-dismiss**: Dismiss bottom sheet modals with a downward swipe ```typescript -import { useSwipeGesture } from './hooks/useSwipeGesture' +import { useSwipeGesture } from './hooks/useSwipeGesture'; const ref = useSwipeGesture({ onSwipeLeft: () => closeMenu(), onSwipeRight: () => openMenu(), - threshold: 50, // Min distance to register swipe + threshold: 50, // Min distance to register swipe restraint: 100, // Max perpendicular drift -}) +}); ``` #### Pinch-to-Zoom + - **Chart zooming**: Pinch-to-zoom on charts for detailed inspection - **Scale range**: 0.5x to 4x zoom with smooth interpolation - **Reset button**: Appears when zoomed > 1.1x ```typescript -import { usePinchZoom } from './hooks/usePinchZoom' +import { usePinchZoom } from './hooks/usePinchZoom'; const { ref, scale, reset } = usePinchZoom({ minScale: 0.5, maxScale: 4, onScaleChange: (scale) => updateChart(scale), -}) +}); ``` --- @@ -58,6 +60,7 @@ const { ref, scale, reset } = usePinchZoom({ ### 2. Mobile-Optimized Charts **Enhancements:** + - Responsive container heights (180px mobile, 220px+ desktop) - Reduced bar sizes on mobile (14px vs 18px) - Smaller axis labels (9px font size) @@ -67,19 +70,47 @@ const { ref, scale, reset } = usePinchZoom({ - Legend auto-hide on screens < 480px **Chart Container:** + ```typescript import MobileChartContainer from './components/charts/MobileChartContainer' - + ``` +#### Landscape-Optimized Analytics Layouts + +Provides tablet-optimized landscape compositions for key analytics views (Analytics dashboard, Transaction analytics, Portfolio trends). + +```typescript +import { LandscapeAnalyticsLayout } from './components/charts' + +, ]} + sidePanel={} + fallbackMessage="Unable to load analytics view" +/> +``` + +**Landscape Features & Compatibility:** + +- **Side-by-Side Split Compositions**: Arranges charts and metric panels side-by-side in landscape tablet viewports. +- **Dynamic Viewport Height**: Constrains chart height (`maxHeight="min(360px, 55vh)"`) to prevent vertical scrolling overflow in landscape mode. +- **Orientation Awareness**: Detects orientation changes (`isLandscape`, `isTabletLandscape`, `orientation`) via `useResponsive()`. +- **Manual View Switcher**: Interactive orientation badge and button toggles to switch between Side-by-Side and Stacked compositions. +- **Input Validation & Security**: Validates corrupt/null/NaN dataset inputs and wraps child components in an ErrorBoundary to recover gracefully from rendering crashes. +- **Migration & Backward Compatibility**: Fully backward compatible with existing single-column responsive chart wrappers. + --- ### 3. Bottom Navigation Bar Quick access to 5 most-used features: + - Home (Overview) - Transactions - DEX Explorer @@ -87,6 +118,7 @@ Quick access to 5 most-used features: - More (Settings) **Touch-friendly:** + - 48×48px touch targets (WCAG AAA) - Active state indicator - Touch feedback (scale animation) @@ -107,6 +139,7 @@ import { BottomSheet } from './components/mobile' ``` **Features:** + - Slides up from bottom on mobile - Drag handle for swipe-to-dismiss - Centered modal on desktop @@ -118,6 +151,7 @@ import { BottomSheet } from './components/mobile' ### 5. Mobile Sidebar **Navigation drawer with:** + - Hamburger menu toggle (top-left) - Swipe gestures (left-to-close, right-to-open) - Touch-optimized nav items (48×48px) @@ -128,6 +162,7 @@ import { BottomSheet } from './components/mobile' - Theme toggle **Features:** + - 280px wide drawer - Smooth cubic-bezier transitions - Escape key support @@ -140,17 +175,20 @@ import { BottomSheet } from './components/mobile' #### CSS Optimizations (`mobile-performance.css`) **Content Visibility:** + ```css .lazy-render { content-visibility: auto; contain-intrinsic-size: 0 500px; } ``` + - Defers rendering off-screen content - Reduces initial paint time - Improves scroll performance **GPU Acceleration:** + ```css .gpu-accelerate { transform: translateZ(0); @@ -160,6 +198,7 @@ import { BottomSheet } from './components/mobile' ``` **Momentum Scrolling:** + ```css .mobile-scroll-container { -webkit-overflow-scrolling: touch; @@ -168,9 +207,11 @@ import { BottomSheet } from './components/mobile' ``` **Layout Containment:** + ```css @media (max-width: 768px) { - .mobile-card, .chart-container { + .mobile-card, + .chart-container { contain: layout style paint; } } @@ -188,7 +229,9 @@ import { BottomSheet } from './components/mobile' ```css /* Prevent iOS zoom on input focus */ -input, textarea, select { +input, +textarea, +select { font-size: 16px !important; } ``` @@ -202,6 +245,7 @@ input, textarea, select { ### 7. Touch Target Compliance **WCAG 2.1 Level AAA:** + - Minimum touch target: **48×48px** - Small targets: **40×40px** (with spacing) @@ -227,6 +271,7 @@ All buttons, links, and interactive elements meet this standard on mobile. ``` **Layout Adjustments:** + - **Mobile (≤768px)**: Single column, touch nav, bottom bar - **Tablet (769-1024px)**: Two-column grid, sidebar visible - **Desktop (>1024px)**: Full desktop layout @@ -246,6 +291,7 @@ For notched devices (iPhone X+, Android with gestures): ``` **Applied to:** + - Mobile navigation bar - Bottom sheets - Fixed headers @@ -266,6 +312,7 @@ For notched devices (iPhone X+, Android with gestures): ## Performance Metrics ### Target Metrics + - **Lighthouse Mobile Score**: ≥ 90 - **First Contentful Paint (FCP)**: < 1.8s - **Largest Contentful Paint (LCP)**: < 2.5s @@ -273,6 +320,7 @@ For notched devices (iPhone X+, Android with gestures): - **Cumulative Layout Shift (CLS)**: < 0.1 ### Optimization Techniques + 1. **Lazy Loading**: `content-visibility: auto` for below-fold content 2. **Code Splitting**: Dynamic imports for heavy components 3. **Image Optimization**: `loading="lazy"` attribute @@ -285,11 +333,13 @@ For notched devices (iPhone X+, Android with gestures): ## Testing Guidelines ### Device Testing + - **iOS**: Safari on iPhone 12+, iPad - **Android**: Chrome on Pixel, Samsung devices - **Desktop**: Chrome, Firefox, Safari (responsive mode) ### Touch Testing + 1. Test swipe gestures on sidebar 2. Verify pinch-to-zoom on charts 3. Check touch target sizes (use browser dev tools) @@ -297,6 +347,7 @@ For notched devices (iPhone X+, Android with gestures): 5. Validate safe-area insets on notched devices ### Performance Testing + ```bash # Lighthouse mobile audit npx lighthouse https://your-app-url --preset=desktop --view @@ -309,14 +360,14 @@ npx lighthouse https://your-app-url --preset=desktop --view ## Browser Compatibility -| Feature | Chrome | Safari | Firefox | Edge | -|---------|--------|--------|---------|------| -| Swipe Gestures | ✅ | ✅ | ✅ | ✅ | -| Pinch Zoom | ✅ | ✅ | ✅ | ✅ | -| Bottom Sheet | ✅ | ✅ | ✅ | ✅ | -| Safe Area | ✅ | ✅ | ⚠️ | ✅ | -| Content Visibility | ✅ | ✅ (15+) | ✅ (109+) | ✅ | -| Touch Events | ✅ | ✅ | ✅ | ✅ | +| Feature | Chrome | Safari | Firefox | Edge | +| ------------------ | ------ | -------- | --------- | ---- | +| Swipe Gestures | ✅ | ✅ | ✅ | ✅ | +| Pinch Zoom | ✅ | ✅ | ✅ | ✅ | +| Bottom Sheet | ✅ | ✅ | ✅ | ✅ | +| Safe Area | ✅ | ✅ | ⚠️ | ✅ | +| Content Visibility | ✅ | ✅ (15+) | ✅ (109+) | ✅ | +| Touch Events | ✅ | ✅ | ✅ | ✅ | --- @@ -325,23 +376,25 @@ npx lighthouse https://your-app-url --preset=desktop --view ### Converting Modals to Bottom Sheets **Before:** + ```tsx -{isOpen && ( -
-
- {content} +{ + isOpen && ( +
+
{content}
-
-)} + ); +} ``` **After:** + ```tsx -import { BottomSheet } from './components/mobile' +import { BottomSheet } from './components/mobile'; {content} - +; ``` ### Adding Swipe to Custom Components @@ -354,7 +407,7 @@ function MyComponent() { onSwipeLeft: handleSwipeLeft, onSwipeRight: handleSwipeRight, }) - + return
{content}
} ``` @@ -384,5 +437,6 @@ function MyComponent() { ## Support For issues or questions about mobile optimization, see: + - [GitHub Issues](../../issues) - [Contributing Guide](./docs/contributing.md) diff --git a/RESPONSIVE_FEATURES.md b/RESPONSIVE_FEATURES.md index a9a6a86e..afdc634f 100644 --- a/RESPONSIVE_FEATURES.md +++ b/RESPONSIVE_FEATURES.md @@ -9,11 +9,13 @@ The dashboard has been transformed from a desktop-only interface into a fully re ## Key Features ### 🔧 Responsive Breakpoints + - **Mobile**: ≤ 768px -- **Tablet**: 769px - 1024px +- **Tablet**: 769px - 1024px - **Desktop**: > 1024px ### 📱 Mobile Features + - **Collapsible Sidebar**: Slides in from the left with overlay - **Mobile Header**: Fixed header with menu toggle and theme switcher - **Touch-Optimized Controls**: 44px minimum touch targets @@ -21,6 +23,7 @@ The dashboard has been transformed from a desktop-only interface into a fully re - **Stacked Layouts**: Vertical layouts for better mobile UX ### 🎯 Touch Optimizations + - Minimum 44px touch targets for buttons - Active states with scale feedback - Improved tap highlights @@ -28,39 +31,49 @@ The dashboard has been transformed from a desktop-only interface into a fully re - Better scrolling with `-webkit-overflow-scrolling: touch` ### 🎨 Responsive CSS Variables + ```css ---sidebar-width: 220px ---sidebar-width-mobile: 280px ---header-height: 60px ---content-padding: 36px ---content-padding-tablet: 24px ---content-padding-mobile: 16px ---touch-target: 44px ---touch-target-sm: 36px +--sidebar-width: 220px --sidebar-width-mobile: 280px --header-height: 60px --content-padding: 36px + --content-padding-tablet: 24px --content-padding-mobile: 16px --touch-target: 44px + --touch-target-sm: 36px; ``` ## Components ### Layout Components + - **Sidebar.jsx**: Responsive sidebar with mobile menu functionality - **MobileHeader.jsx**: Mobile-only header with menu toggle - **ResponsiveContainer.jsx**: Utility components for responsive layouts - `ResponsiveGrid`: Responsive grid with configurable columns - `ResponsiveFlex`: Responsive flex container +### Chart Layout Components + +- **LandscapeAnalyticsLayout.tsx**: Landscape-optimized layout for tablet/mobile analytics charts + - Side-by-side split grid for multi-chart or chart + side panel compositions + - Viewport-height constraint (`maxHeight="min(360px, 55vh)"`) for landscape viewports + - Input validation and ErrorBoundary recovery for fault tolerance + - Orientation badge and manual Side-by-Side vs Stacked composition toggle + ### Hooks -- **useResponsive.js**: Hook for responsive behavior - - `windowWidth`: Current window width - - `isMobile`, `isTablet`, `isDesktop`: Boolean flags + +- **useResponsive.ts**: Hook for responsive behavior and orientation awareness + - `windowWidth`, `windowHeight`: Current viewport dimensions + - `orientation`: `'portrait'` | `'landscape'` + - `isLandscape`, `isPortrait`, `isTabletLandscape`: Orientation boolean flags + - `isMobile`, `isTablet`, `isDesktop`: Breakpoint boolean flags - `useMediaQuery`: Custom media query hook ### Store Updates + - Added `theme` and `toggleTheme` for theme management - Added `isMobileMenuOpen` and `setMobileMenuOpen` for mobile menu state ## CSS Utilities ### Responsive Classes + - `.mobile-only`: Show only on mobile - `.desktop-only`: Show only on desktop - `.mobile-card`: Mobile-optimized card styling @@ -69,32 +82,37 @@ The dashboard has been transformed from a desktop-only interface into a fully re - `.mobile-table`: Mobile-optimized table layout ### Touch Classes + - `.touch-target`: 44px minimum touch target - `.touch-target-sm`: 36px smaller touch target ## Implementation Examples ### Basic Responsive Component + ```jsx -import { useResponsive } from '../../hooks/useResponsive' +import { useResponsive } from '../../hooks/useResponsive'; function MyComponent() { - const { isMobile, isTablet } = useResponsive() - + const { isMobile, isTablet } = useResponsive(); + return ( -
+
{/* Content */}
- ) + ); } ``` ### Using Responsive Grid + ```jsx -import { ResponsiveGrid } from '../layout/ResponsiveContainer' +import { ResponsiveGrid } from '../layout/ResponsiveContainer'; function GridComponent() { return ( @@ -106,15 +124,16 @@ function GridComponent() {
Item 2
Item 3
- ) + ); } ``` ## Mobile Menu Behavior ### Opening/Closing + - **Open**: Click hamburger menu in mobile header -- **Close**: +- **Close**: - Click X button in sidebar - Click overlay background - Press Escape key @@ -122,6 +141,7 @@ function GridComponent() { - Resize window to desktop size ### Features + - Prevents body scroll when open - Smooth slide-in animation - Backdrop blur overlay @@ -130,12 +150,14 @@ function GridComponent() { ## Performance Optimizations ### CSS Optimizations + - Reduced grid background opacity on mobile - Faster animations (0.25s vs 0.35s) - Smaller font sizes for better mobile readability - Optimized touch scrolling ### JavaScript Optimizations + - Debounced resize listeners - Efficient responsive hooks - Minimal re-renders with proper dependencies @@ -143,6 +165,7 @@ function GridComponent() { ## Browser Support ### Modern Features Used + - CSS Custom Properties (CSS Variables) - CSS Grid and Flexbox - `matchMedia` API @@ -150,6 +173,7 @@ function GridComponent() { - Backdrop filters ### Fallbacks + - Graceful degradation for older browsers - Progressive enhancement approach - Feature detection where needed @@ -157,6 +181,7 @@ function GridComponent() { ## Testing Recommendations ### Manual Testing + 1. Test on actual mobile devices (iOS Safari, Android Chrome) 2. Test tablet orientations (portrait/landscape) 3. Verify touch targets are easily tappable @@ -164,12 +189,14 @@ function GridComponent() { 5. Verify form inputs don't cause zoom on iOS ### Responsive Testing + 1. Use browser dev tools responsive mode 2. Test all breakpoints (768px, 1024px) 3. Test window resize behavior 4. Verify layout doesn't break at edge cases ### Accessibility Testing + 1. Test with screen readers on mobile 2. Verify keyboard navigation works 3. Test with high contrast mode @@ -178,6 +205,7 @@ function GridComponent() { ## Future Enhancements ### Potential Improvements + - PWA support for mobile app-like experience - Swipe gestures for navigation - Pull-to-refresh functionality @@ -186,7 +214,8 @@ function GridComponent() { - Push notifications for account updates ### Component Responsiveness + - Make remaining dashboard components responsive - Add mobile-optimized table layouts - Implement mobile-friendly modals -- Add swipe-to-dismiss functionality \ No newline at end of file +- Add swipe-to-dismiss functionality diff --git a/src/components/charts/AnalyticsChart.tsx b/src/components/charts/AnalyticsChart.tsx index 3e6fefd6..06651464 100644 --- a/src/components/charts/AnalyticsChart.tsx +++ b/src/components/charts/AnalyticsChart.tsx @@ -1,4 +1,4 @@ -import React from "react"; +import React from 'react'; import { ResponsiveContainer, LineChart, @@ -9,26 +9,26 @@ import { YAxis, BarChart, Bar, -} from "recharts"; -import AccessibleChart from "./AccessibleChart"; -import { useReducedMotion } from "../../hooks/useReducedMotion"; -import { getAnimationProps } from "../../utils/reducedMotion"; +} from 'recharts'; +import AccessibleChart from './AccessibleChart'; +import { useReducedMotion } from '../../hooks/useReducedMotion'; +import { getAnimationProps } from '../../utils/reducedMotion'; function ChartShell({ title, children }) { return (
{title} @@ -45,7 +45,7 @@ export function ActivityTrendChart({ data = [] }) { - - + + - + @@ -72,7 +78,7 @@ export function LatencyTrendChart({ data = [] }) { new Date(value).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} + tick={{ fontSize: 10, fill: 'var(--text-muted)' }} + tickFormatter={(value) => + new Date(value).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) + } minTickGap={40} /> [`${value} ms`, 'Latency']} /> - + @@ -108,7 +122,7 @@ export function FeeTrendChart({ data = [] }) { - - + + @@ -128,12 +142,28 @@ export function FeeTrendChart({ data = [] }) { ); } -export default function AnalyticsChart({ data = [], latencyData = [] }) { +import LandscapeAnalyticsLayout from './LandscapeAnalyticsLayout'; + +export default function AnalyticsChart({ + data = [], + latencyData = [], +}: { + data?: any[]; + latencyData?: any[]; +}) { + const chartComponents = [ + , + ...(latencyData.length > 0 ? [] : []), + , + ]; + return ( -
- - {latencyData.length > 0 && } - -
+ ); } diff --git a/src/components/charts/LandscapeAnalyticsLayout.tsx b/src/components/charts/LandscapeAnalyticsLayout.tsx new file mode 100644 index 00000000..8cf3038d --- /dev/null +++ b/src/components/charts/LandscapeAnalyticsLayout.tsx @@ -0,0 +1,389 @@ +import React, { Component, useState, useMemo, ErrorInfo, ReactNode } from 'react'; +import { useResponsive } from '../../hooks/useResponsive'; +import { LayoutGrid, Columns, RefreshCw, AlertTriangle, Monitor } from 'lucide-react'; + +interface ErrorBoundaryProps { + children: ReactNode; + fallbackMessage?: string; + onError?: (_error: Error, _info: ErrorInfo) => void; +} + +interface ErrorBoundaryState { + hasError: boolean; + error: Error | null; +} + +class LandscapeAnalyticsErrorBoundary extends Component { + constructor(props: ErrorBoundaryProps) { + super(props); + this.state = { hasError: false, error: null }; + } + + static getDerivedStateFromError(error: Error): ErrorBoundaryState { + return { hasError: true, error }; + } + + componentDidCatch(error: Error, info: ErrorInfo) { + if (this.props.onError) { + this.props.onError(error, info); + } + } + + resetError = () => { + this.setState({ hasError: false, error: null }); + }; + + render() { + if (this.state.hasError) { + return ( +
+
+ + {this.props.fallbackMessage || 'Landscape Chart Composition Error'} +
+

+ {this.state.error?.message || + 'An error occurred while rendering the landscape analytics layout.'} +

+ +
+ ); + } + + return this.props.children; + } +} + +export interface LandscapeAnalyticsLayoutProps { + /** Primary chart element or container */ + children?: ReactNode; + /** Optional side panel (metrics, stat cards, or controls) shown side-by-side in landscape */ + sidePanel?: ReactNode; + /** Optional array of chart elements to display side-by-side in landscape mode */ + charts?: ReactNode[]; + /** Mode: 'auto' adapts based on viewport, 'side-by-side' forces split, 'stacked' forces stacked */ + layoutMode?: 'auto' | 'side-by-side' | 'stacked'; + /** Title for the analytics section */ + title?: string; + /** Subtitle or description */ + subtitle?: string; + /** Max height for chart area in landscape mode */ + maxHeight?: string | number; + /** Data object/array for automatic validity check */ + data?: any; + /** Fallback message for invalid data or error state */ + fallbackMessage?: string; + /** Callback on rendering error */ + onError?: (_error: Error, _info: ErrorInfo) => void; + className?: string; + style?: React.CSSProperties; +} + +export function LandscapeAnalyticsLayout({ + children, + sidePanel, + charts = [], + layoutMode = 'auto', + title, + subtitle, + maxHeight = 'min(360px, 55vh)', + data, + fallbackMessage = 'Unable to render analytics layout', + onError, + className = '', + style = {}, +}: LandscapeAnalyticsLayoutProps) { + const { isLandscape, isTablet, isMobile, orientation, windowWidth } = useResponsive(); + const [viewOverride, setViewOverride] = useState<'auto' | 'side-by-side' | 'stacked'>('auto'); + + // Input Validation + const inputValidation = useMemo(() => { + if (data === undefined) return { isValid: true }; + if (data === null) return { isValid: false, reason: 'Data is null' }; + if (typeof data === 'number' && (isNaN(data) || !isFinite(data))) { + return { isValid: false, reason: 'Data is NaN or infinite' }; + } + if (Array.isArray(data) && data.length === 0) { + return { isValid: true, isDegenerate: true, reason: 'Dataset is empty' }; + } + return { isValid: true }; + }, [data]); + + // Determine effective landscape active status + const effectiveLayoutMode = viewOverride !== 'auto' ? viewOverride : layoutMode; + const isLandscapeActive = + effectiveLayoutMode === 'side-by-side' || + (effectiveLayoutMode === 'auto' && isLandscape && (isTablet || isMobile || windowWidth >= 600)); + + const chartHeightStyle = isLandscapeActive + ? typeof maxHeight === 'number' + ? `${maxHeight}px` + : maxHeight + : undefined; + + return ( + +
+ {/* Header & Controls */} + {(title || isLandscapeActive || (isTablet && isLandscape)) && ( +
+
+ {title && ( +

+ {title} +

+ )} + {subtitle && ( +

+ {subtitle} +

+ )} +
+ +
+ {isLandscapeActive && ( + + + Tablet Landscape Composition + + )} + +
+ + +
+
+
+ )} + + {/* Input Validation Error */} + {!inputValidation.isValid && ( +
+ + + {fallbackMessage}: {inputValidation.reason} + +
+ )} + + {/* Main Content Layout */} + {inputValidation.isValid && ( +
0 + ? `repeat(${Math.min(charts.length + (children ? 1 : 0), 3)}, minmax(0, 1fr))` + : 'repeat(auto-fit, minmax(300px, 1fr))' + : '1fr', + gap: '16px', + alignItems: 'stretch', + width: '100%', + }} + > + {/* Primary Chart Container */} + {children && ( +
+ {children} +
+ )} + + {/* Multiple Charts Side-by-Side */} + {charts.map((chartItem, idx) => ( +
+ {chartItem} +
+ ))} + + {/* Side Panel (Metrics/Controls) */} + {sidePanel && ( +
+ {sidePanel} +
+ )} +
+ )} +
+
+ ); +} + +export default LandscapeAnalyticsLayout; diff --git a/src/components/charts/MobileChartContainer.tsx b/src/components/charts/MobileChartContainer.tsx index 47f9f1aa..401a5e48 100644 --- a/src/components/charts/MobileChartContainer.tsx +++ b/src/components/charts/MobileChartContainer.tsx @@ -2,17 +2,18 @@ * MobileChartContainer – wrapper for charts with touch gestures. * Adds pinch-to-zoom support and horizontal panning for wide charts on mobile. */ -import React, { useState } from 'react' -import { useResponsive } from '../../hooks/useResponsive' -import { usePinchZoom } from '../../hooks/usePinchZoom' +import React, { useState } from 'react'; +import { useResponsive } from '../../hooks/useResponsive'; +import { usePinchZoom } from '../../hooks/usePinchZoom'; interface MobileChartContainerProps { - children: React.ReactNode - /** Allow horizontal panning for wide content. Default true on mobile. */ - allowPan?: boolean - /** Allow pinch-to-zoom. Default true on mobile. */ - allowZoom?: boolean - minHeight?: number + children: React.ReactNode; + /** Allow horizontal panning for wide content. Default true on mobile/landscape. */ + allowPan?: boolean; + /** Allow pinch-to-zoom. Default true on mobile/landscape. */ + allowZoom?: boolean; + minHeight?: number; + landscapeMinHeight?: number; } export default function MobileChartContainer({ @@ -20,24 +21,33 @@ export default function MobileChartContainer({ allowPan = true, allowZoom = true, minHeight = 200, + landscapeMinHeight = 180, }: MobileChartContainerProps) { - const { isMobile } = useResponsive() - const [zoom, setZoom] = useState(1) - const { ref: zoomRef, scale, reset } = usePinchZoom({ + const { isMobile, isTablet, isLandscape } = useResponsive(); + const [, setZoom] = useState(1); + const { + ref: zoomRef, + scale, + reset, + } = usePinchZoom({ minScale: 0.7, maxScale: 3, onScaleChange: (s) => setZoom(s), - }) + }); - if (!isMobile) { - return
{children}
+ const isTouchDevice = isMobile || isTablet || isLandscape; + const effectiveMinHeight = isLandscape ? landscapeMinHeight : minHeight; + + if (!isTouchDevice) { + return
{children}
; } return (
)}
- ) + ); } diff --git a/src/components/charts/__tests__/LandscapeAnalyticsLayout.test.tsx b/src/components/charts/__tests__/LandscapeAnalyticsLayout.test.tsx new file mode 100644 index 00000000..ca1551c1 --- /dev/null +++ b/src/components/charts/__tests__/LandscapeAnalyticsLayout.test.tsx @@ -0,0 +1,207 @@ +import React from 'react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import LandscapeAnalyticsLayout from '../LandscapeAnalyticsLayout'; +import { useResponsive } from '../../../hooks/useResponsive'; + +// Mock useResponsive hook for controlled testing +vi.mock('../../../hooks/useResponsive', () => ({ + useResponsive: vi.fn(), +})); + +const mockUseResponsive = vi.mocked(useResponsive); + +describe('', () => { + beforeEach(() => { + vi.resetAllMocks(); + mockUseResponsive.mockReturnValue({ + windowWidth: 1024, + windowHeight: 768, + orientation: 'landscape', + isLandscape: true, + isPortrait: false, + isTabletLandscape: true, + isMobile: false, + isTablet: true, + isDesktop: false, + breakpoints: { mobile: 768, tablet: 1024, desktop: 1200 }, + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('Primary Flow', () => { + it('renders landscape layout with orientation badge and side-by-side composition', () => { + render( + +
Primary Activity Chart
+
+ ); + + expect(screen.getByText('Tablet Analytics')).toBeInTheDocument(); + expect(screen.getByText('Tablet Landscape Composition')).toBeInTheDocument(); + expect(screen.getByTestId('chart-1')).toBeInTheDocument(); + const layoutContainer = screen.getByTestId('chart-1').closest('.landscape-analytics-layout'); + expect(layoutContainer).toHaveAttribute('data-orientation', 'landscape'); + expect(layoutContainer).toHaveAttribute('data-landscape-active', 'true'); + }); + + it('renders side-by-side panel layout when sidePanel is provided in landscape mode', () => { + render( + Control Panel
} + > +
Main Chart
+ + ); + + expect(screen.getByTestId('main-chart')).toBeInTheDocument(); + expect(screen.getByTestId('side-panel')).toBeInTheDocument(); + }); + + it('toggles composition mode between side-by-side and stacked view via view mode buttons', () => { + render( + +
Chart Content
+
+ ); + + const stackedButton = screen.getByRole('button', { name: /stacked view/i }); + fireEvent.click(stackedButton); + + const layoutContainer = screen + .getByTestId('chart-content') + .closest('.landscape-analytics-layout'); + expect(layoutContainer).toHaveAttribute('data-landscape-active', 'false'); + + const sideBySideButton = screen.getByRole('button', { name: /side-by-side view/i }); + fireEvent.click(sideBySideButton); + expect(layoutContainer).toHaveAttribute('data-landscape-active', 'true'); + }); + }); + + describe('Boundary Cases', () => { + it('handles exact tablet breakpoint boundary (768px x 600px landscape)', () => { + mockUseResponsive.mockReturnValue({ + windowWidth: 768, + windowHeight: 600, + orientation: 'landscape', + isLandscape: true, + isPortrait: false, + isTabletLandscape: true, + isMobile: true, + isTablet: false, + isDesktop: false, + breakpoints: { mobile: 768, tablet: 1024, desktop: 1200 }, + }); + + render( + +
Boundary Chart
+
+ ); + + expect(screen.getByTestId('boundary-chart')).toBeInTheDocument(); + expect(screen.getByText('Tablet Landscape Composition')).toBeInTheDocument(); + }); + + it('handles empty dataset gracefully without error alerts', () => { + render( + +
No Data Chart
+
+ ); + + expect(screen.getByTestId('empty-chart')).toBeInTheDocument(); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + }); + + it('renders multiple charts side-by-side when charts array is provided', () => { + render( + + Chart Alpha +
, +
+ Chart Beta +
, + ]} + /> + ); + + expect(screen.getByTestId('chart-alpha')).toBeInTheDocument(); + expect(screen.getByTestId('chart-beta')).toBeInTheDocument(); + }); + }); + + describe('Failure Cases & Error Handling', () => { + it('handles invalid input data (null or NaN) with explicit alert message', () => { + render( + +
Chart Content
+
+ ); + + expect(screen.getByRole('alert')).toHaveTextContent('Data processing failed: Data is null'); + }); + + it('handles unsupported environment (portrait mode fallback)', () => { + mockUseResponsive.mockReturnValue({ + windowWidth: 400, + windowHeight: 800, + orientation: 'portrait', + isLandscape: false, + isPortrait: true, + isTabletLandscape: false, + isMobile: true, + isTablet: false, + isDesktop: false, + breakpoints: { mobile: 768, tablet: 1024, desktop: 1200 }, + }); + + render( + +
Portrait Chart
+
+ ); + + expect(screen.getByTestId('portrait-chart')).toBeInTheDocument(); + expect(screen.queryByText('Tablet Landscape Composition')).not.toBeInTheDocument(); + }); + + it('catches child component render error with ErrorBoundary and allows retry', () => { + const ErrorChild = () => { + throw new Error('Broken chart rendering'); + }; + + const onErrorSpy = vi.fn(); + + // Suppress console.error output during error boundary test + const originalConsoleError = console.error; + console.error = vi.fn(); + + render( + + + + ); + + expect(screen.getByRole('alert')).toHaveTextContent('Broken chart rendering'); + expect(screen.getByRole('button', { name: /retry landscape view/i })).toBeInTheDocument(); + expect(onErrorSpy).toHaveBeenCalled(); + + console.error = originalConsoleError; + }); + }); +}); diff --git a/src/components/charts/index.ts b/src/components/charts/index.ts new file mode 100644 index 00000000..cbe23b08 --- /dev/null +++ b/src/components/charts/index.ts @@ -0,0 +1,4 @@ +export { default as LandscapeAnalyticsLayout } from './LandscapeAnalyticsLayout'; +export { default as MobileChartContainer } from './MobileChartContainer'; +export { default as AnalyticsChart } from './AnalyticsChart'; +export { default as AccessibleChart } from './AccessibleChart'; diff --git a/src/components/dashboard/AuditLog.tsx b/src/components/dashboard/AuditLog.tsx index af9f0dd9..70bd7a2b 100644 --- a/src/components/dashboard/AuditLog.tsx +++ b/src/components/dashboard/AuditLog.tsx @@ -592,6 +592,5 @@ export default function AuditLog() { )}
- ); } diff --git a/src/components/dashboard/Contracts.tsx b/src/components/dashboard/Contracts.tsx index b28de569..47a097cd 100644 --- a/src/components/dashboard/Contracts.tsx +++ b/src/components/dashboard/Contracts.tsx @@ -594,6 +594,8 @@ export default function Contracts() {
+ )} + {contractError && (
{contractError} @@ -634,7 +636,6 @@ export default function Contracts() {
)} - {contractData && ( diff --git a/src/components/dashboard/TransactionAnalyticsDashboard.tsx b/src/components/dashboard/TransactionAnalyticsDashboard.tsx index b96f7a45..a37f167b 100644 --- a/src/components/dashboard/TransactionAnalyticsDashboard.tsx +++ b/src/components/dashboard/TransactionAnalyticsDashboard.tsx @@ -1,6 +1,17 @@ import React, { useEffect, useState } from 'react'; import { fetchTransactions } from '../../api/transactions'; -import { LineChart, Line, BarChart, Bar, XAxis, YAxis, Tooltip, CartesianGrid, ResponsiveContainer, Legend } from 'recharts'; +import { + LineChart, + Line, + BarChart, + Bar, + XAxis, + YAxis, + Tooltip, + CartesianGrid, + ResponsiveContainer, + Legend, +} from 'recharts'; import AccessibleChart from '../charts/AccessibleChart'; // Mock data generators (replace with real data) @@ -30,67 +41,78 @@ const generateAmountDistribution = (transactions) => { return bins.map((b, i) => ({ range: `${b}+`, count: counts[i] })); }; +import LandscapeAnalyticsLayout from '../charts/LandscapeAnalyticsLayout'; + export default function TransactionAnalyticsDashboard() { - const [transactions, setTransactions] = useState([]); const [freqData, setFreqData] = useState([]); const [distData, setDistData] = useState([]); useEffect(() => { fetchTransactions() .then((data) => { - setTransactions(data); setFreqData(generateFrequencyData(data)); setDistData(generateAmountDistribution(data)); }) .catch((e) => console.error('Failed to load transactions', e)); }, []); + const chartComponents = [ + + + + + + + + + + + + , + + + + + + + + + + + + + , + ]; + return ( -
-

Transaction Analytics

- {/* Frequency Chart */} - +

+ Transaction Analytics +

+ - - - - - - - - - - -
- {/* Amount Distribution */} - - - - - - - - - - - - - {/* Additional charts (counterparty, time‑of‑day, seasonal, prediction) can be added similarly */} + charts={chartComponents} + />
); } diff --git a/src/hooks/__tests__/useResponsive.test.ts b/src/hooks/__tests__/useResponsive.test.ts new file mode 100644 index 00000000..b24fa5d8 --- /dev/null +++ b/src/hooks/__tests__/useResponsive.test.ts @@ -0,0 +1,113 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { useResponsive } from '../useResponsive'; + +describe('useResponsive hook', () => { + const originalInnerWidth = window.innerWidth; + const originalInnerHeight = window.innerHeight; + + const setWindowDimensions = (width: number, height: number) => { + Object.defineProperty(window, 'innerWidth', { + writable: true, + configurable: true, + value: width, + }); + Object.defineProperty(window, 'innerHeight', { + writable: true, + configurable: true, + value: height, + }); + }; + + afterEach(() => { + setWindowDimensions(originalInnerWidth, originalInnerHeight); + }); + + describe('Primary Flow', () => { + it('detects desktop layout in portrait or landscape mode', () => { + setWindowDimensions(1400, 900); + const { result } = renderHook(() => useResponsive()); + + expect(result.current.windowWidth).toBe(1400); + expect(result.current.windowHeight).toBe(900); + expect(result.current.isDesktop).toBe(true); + expect(result.current.isMobile).toBe(false); + expect(result.current.isLandscape).toBe(true); + expect(result.current.orientation).toBe('landscape'); + }); + + it('detects tablet landscape mode correctly', () => { + setWindowDimensions(900, 600); + const { result } = renderHook(() => useResponsive()); + + expect(result.current.isTablet).toBe(true); + expect(result.current.isLandscape).toBe(true); + expect(result.current.isTabletLandscape).toBe(true); + expect(result.current.orientation).toBe('landscape'); + }); + + it('updates dimensions when window resize event fires', () => { + setWindowDimensions(1000, 600); + const { result } = renderHook(() => useResponsive()); + + expect(result.current.windowWidth).toBe(1000); + + act(() => { + setWindowDimensions(500, 800); + window.dispatchEvent(new Event('resize')); + }); + + expect(result.current.windowWidth).toBe(500); + expect(result.current.windowHeight).toBe(800); + expect(result.current.isMobile).toBe(true); + expect(result.current.isPortrait).toBe(true); + }); + }); + + describe('Boundary Cases', () => { + it('handles exact mobile breakpoint boundary (768px)', () => { + setWindowDimensions(768, 1024); + const { result } = renderHook(() => useResponsive()); + + expect(result.current.isMobile).toBe(true); + expect(result.current.isTablet).toBe(false); + expect(result.current.isPortrait).toBe(true); + }); + + it('handles exact tablet breakpoint boundary (1024px)', () => { + setWindowDimensions(1024, 768); + const { result } = renderHook(() => useResponsive()); + + expect(result.current.isTablet).toBe(true); + expect(result.current.isDesktop).toBe(false); + expect(result.current.isLandscape).toBe(true); + }); + + it('handles 1:1 square aspect ratio boundary correctly', () => { + setWindowDimensions(800, 800); + const { result } = renderHook(() => useResponsive()); + + expect(result.current.isPortrait).toBe(true); + expect(result.current.orientation).toBe('portrait'); + }); + }); + + describe('Failure & Edge Cases', () => { + it('handles zero or negative width gracefully', () => { + setWindowDimensions(0, 0); + const { result } = renderHook(() => useResponsive()); + + expect(result.current.windowWidth).toBe(0); + expect(result.current.windowHeight).toBe(0); + expect(result.current.isMobile).toBe(true); + }); + + it('handles NaN window dimensions without crashing', () => { + setWindowDimensions(NaN, NaN); + const { result } = renderHook(() => useResponsive()); + + expect(typeof result.current.windowWidth).toBe('number'); + expect(result.current.windowWidth).toBeGreaterThan(0); + }); + }); +}); diff --git a/src/hooks/useResponsive.ts b/src/hooks/useResponsive.ts index 92a23e8b..55620e7d 100644 --- a/src/hooks/useResponsive.ts +++ b/src/hooks/useResponsive.ts @@ -1,53 +1,116 @@ -import { useState, useEffect } from 'react' -import type { ResponsiveBreakpoints, ResponsiveState } from '../types/components' +import { useState, useEffect } from 'react'; +import type { ResponsiveBreakpoints, ResponsiveState } from '../types/components'; const BREAKPOINTS: ResponsiveBreakpoints = { mobile: 768, tablet: 1024, desktop: 1200, -} +}; export function useResponsive(): ResponsiveState { - const [windowWidth, setWindowWidth] = useState( - typeof window !== 'undefined' ? window.innerWidth : 1200, - ) + const [dimensions, setDimensions] = useState<{ width: number; height: number }>(() => { + if (typeof window !== 'undefined') { + return { + width: + typeof window.innerWidth === 'number' && !isNaN(window.innerWidth) + ? Math.max(0, window.innerWidth) + : 1200, + height: + typeof window.innerHeight === 'number' && !isNaN(window.innerHeight) + ? Math.max(0, window.innerHeight) + : 800, + }; + } + return { width: 1200, height: 800 }; + }); useEffect(() => { + if (typeof window === 'undefined') return; + const handleResize = () => { - setWindowWidth(window.innerWidth) + try { + const w = + typeof window.innerWidth === 'number' && !isNaN(window.innerWidth) + ? Math.max(0, window.innerWidth) + : 1200; + const h = + typeof window.innerHeight === 'number' && !isNaN(window.innerHeight) + ? Math.max(0, window.innerHeight) + : 800; + setDimensions({ width: w, height: h }); + } catch { + // Safe fallback if window dimensions access fails + } + }; + + window.addEventListener('resize', handleResize); + window.addEventListener('orientationchange', handleResize); + return () => { + window.removeEventListener('resize', handleResize); + window.removeEventListener('orientationchange', handleResize); + }; + }, []); + + const windowWidth = dimensions.width; + const windowHeight = dimensions.height; + + let isLandscape = windowWidth > windowHeight; + if (typeof window !== 'undefined' && typeof window.matchMedia === 'function') { + try { + const media = window.matchMedia('(orientation: landscape)'); + if (media && media.matches) { + isLandscape = true; + } + } catch { + // Fallback to width > height calculation } + } - window.addEventListener('resize', handleResize) - return () => window.removeEventListener('resize', handleResize) - }, []) + const isMobile = windowWidth <= BREAKPOINTS.mobile; + const isTablet = windowWidth > BREAKPOINTS.mobile && windowWidth <= BREAKPOINTS.tablet; + const isDesktop = windowWidth > BREAKPOINTS.tablet; - const isMobile = windowWidth <= BREAKPOINTS.mobile - const isTablet = windowWidth > BREAKPOINTS.mobile && windowWidth <= BREAKPOINTS.tablet - const isDesktop = windowWidth > BREAKPOINTS.tablet + const isPortrait = !isLandscape; + const orientation: 'portrait' | 'landscape' = isLandscape ? 'landscape' : 'portrait'; + const isTabletLandscape = + (isTablet || (windowWidth >= 600 && windowWidth <= 1024)) && isLandscape; return { windowWidth, + windowHeight, + orientation, + isLandscape, + isPortrait, + isTabletLandscape, isMobile, isTablet, isDesktop, breakpoints: BREAKPOINTS, - } + }; } export function useMediaQuery(query: string): boolean { - const [matches, setMatches] = useState(false) + const [matches, setMatches] = useState(false); useEffect(() => { - if (typeof window === 'undefined') return + if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return; - const media = window.matchMedia(query) - setMatches(media.matches) + try { + const media = window.matchMedia(query); + setMatches(media.matches); - const listener = (e: MediaQueryListEvent) => setMatches(e.matches) - media.addEventListener('change', listener) - - return () => media.removeEventListener('change', listener) - }, [query]) + const listener = (e: MediaQueryListEvent) => setMatches(e.matches); + if (typeof media.addEventListener === 'function') { + media.addEventListener('change', listener); + return () => media.removeEventListener('change', listener); + } else if (typeof (media as any).addListener === 'function') { + (media as any).addListener(listener); + return () => (media as any).removeListener(listener); + } + } catch { + // Fallback for unsupported media query execution + } + }, [query]); - return matches + return matches; } diff --git a/src/lib/__tests__/transactionBuilder.property.test.ts b/src/lib/__tests__/transactionBuilder.property.test.ts index ff4c9939..94ecc8fa 100644 --- a/src/lib/__tests__/transactionBuilder.property.test.ts +++ b/src/lib/__tests__/transactionBuilder.property.test.ts @@ -35,8 +35,6 @@ function formatStellarAmount(stroops: bigint): string { function validAmountArb() { return fc - .bigInt({ min: 1n, max: 10000000000000000n }) - .map((stroops) => (Number(stroops) / 10000000).toFixed(7)); .bigInt({ min: 1n, max: 1_000_000_000n * 10_000_000n }) .map(formatStellarAmount); } @@ -201,9 +199,6 @@ describe("Property-based: Amount boundary rejection", () => { fc.assert( fc.property(publicKeyArb(), extremeAmountArb(), (dest, amount) => { const source = buildAccount(); - const numAmount = typeof amount === 'number' ? amount : parseFloat(String(amount)); - - if (numAmount < MIN_AMOUNT || !isFinite(numAmount) || isNaN(numAmount) || numAmount > MAX_SAFE_AMOUNT) { const num = Number(amount); const isInvalid = !/^\d+(\.\d{1,7})?$/.test(amount) || isNaN(num) || num <= 0 || !isFinite(num) || num > MAX_SAFE_AMOUNT; diff --git a/src/lib/tests/contractInvoker.test.ts b/src/lib/tests/contractInvoker.test.ts index afa1ea61..a105d5b9 100644 --- a/src/lib/tests/contractInvoker.test.ts +++ b/src/lib/tests/contractInvoker.test.ts @@ -253,7 +253,6 @@ describe('Contract Invoker Flows', () => { owner: MOCK_PUBKEY } }); - }); }); }); diff --git a/src/types/components.ts b/src/types/components.ts index c3a0ff81..8d14ae6f 100644 --- a/src/types/components.ts +++ b/src/types/components.ts @@ -4,51 +4,56 @@ * lets us evolve a single source of truth as the TS migration progresses. */ -import type { CSSProperties, ReactNode } from 'react' +import type { CSSProperties, ReactNode } from 'react'; export interface CardProps { - children?: ReactNode - title?: ReactNode - subtitle?: ReactNode - action?: ReactNode - glow?: boolean - style?: CSSProperties - className?: string + children?: ReactNode; + title?: ReactNode; + subtitle?: ReactNode; + action?: ReactNode; + glow?: boolean; + style?: CSSProperties; + className?: string; } export interface StatCardProps { - label: ReactNode - value?: ReactNode - sub?: ReactNode - accent?: string - loading?: boolean + label: ReactNode; + value?: ReactNode; + sub?: ReactNode; + accent?: string; + loading?: boolean; } export interface CopyableValueProps { - value: string - children?: ReactNode - title?: string - textStyle?: CSSProperties - containerStyle?: CSSProperties - buttonStyle?: CSSProperties + value: string; + children?: ReactNode; + title?: string; + textStyle?: CSSProperties; + containerStyle?: CSSProperties; + buttonStyle?: CSSProperties; /** Force confirmation before copying, even if the value isn't auto-detected. */ - sensitive?: boolean + sensitive?: boolean; /** Override the confirmation prompt shown when a copy is gated. */ - sensitiveLabel?: string + sensitiveLabel?: string; /** Called after a successful copy (receives the copied value). */ - onCopy?: (value: string) => void + onCopy?: (_value: string) => void; } export interface ResponsiveBreakpoints { - mobile: number - tablet: number - desktop: number + mobile: number; + tablet: number; + desktop: number; } export interface ResponsiveState { - windowWidth: number - isMobile: boolean - isTablet: boolean - isDesktop: boolean - breakpoints: ResponsiveBreakpoints + windowWidth: number; + windowHeight: number; + orientation: 'portrait' | 'landscape'; + isLandscape: boolean; + isPortrait: boolean; + isTabletLandscape: boolean; + isMobile: boolean; + isTablet: boolean; + isDesktop: boolean; + breakpoints: ResponsiveBreakpoints; }