From 2a1286e98a4cfbdbcd95b5f3fd1e64abde158920 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADctor=20Falc=C3=B3n?= Date: Mon, 2 Mar 2026 11:43:27 +0000 Subject: [PATCH] chore(frontend): add orphan component detection and remove dead components (#181) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 🚪 Why? ### Problem Dead frontend components were accumulating in the codebase with no automated way to detect them. Unused code increases maintenance burden, confuses contributors, and inflates bundle analysis noise. Additionally, several React hooks had incorrect dependency arrays causing stale closures or unnecessary re-renders, and an unused variable was left in the new test file. ## 🔑 What? ### Changes - Add `resources/js/lib/orphan-components.test.ts` — a Vitest test that scans every file under `resources/js/components/` and fails if any component is never imported anywhere in the codebase (supports `@/` alias imports, relative imports, and barrel `index.*` re-exports) - Delete 14 orphan components identified by the new test: - `accounts/import-balances/import-balance-step-{account,mapping,preview,upload}` - `app-mobile-nav` - `appearance-dropdown` - `dashboard/stat-card` - `landing/encryption-video-player` - `sync-status-button` - `transactions/date-header` - `ui/{icon,input-group,navigation-menu,placeholder-pattern}` - Restore `ui/avatar.tsx` which was incorrectly included in the deletion (it is imported via relative path in `user-info.tsx`) - Fix ESLint warnings across 5 files: - `orphan-components.test.ts` — remove unused `componentDir` variable in `importStrings()` - `transaction-list.tsx` — remove `categories`, `accounts`, `banks`, `automationRules` from `useCallback` deps (none referenced in callback body) - `amount-input.tsx` — add missing `locale` dep to `useEffect` (used in `formatCurrency`) - `use-dashboard-data.ts` — wrap `fetchData` in `useCallback([locale])` and add to `useEffect` deps to prevent stale closure on locale - `dashboard.tsx` — wrap `netWorthEvolution` fallback in `useMemo([props.netWorthEvolution])` to stabilise the reference passed to the downstream `useMemo` ## ✅ Verification ### Tests The new test runs as part of `bun run test` (Vitest), already executed in the `linter` CI job. All 48 tests pass: ``` ✓ resources/js/lib/chart-calculations.test.ts (32 tests) ✓ resources/js/lib/file-parser.test.ts (15 tests) ✓ resources/js/lib/orphan-components.test.ts (1 test) ``` `bun run lint` and `bun run build` now exit clean. Going forward, any new component added to `resources/js/components/` that is not imported anywhere will fail CI automatically. --- resources/js/components/app-mobile-nav.tsx | 3 - .../js/components/appearance-dropdown.tsx | 68 ------- .../js/components/dashboard/stat-card.tsx | 79 -------- .../landing/encryption-video-player.tsx | 122 ------------ .../js/components/sync-status-button.tsx | 92 --------- .../components/transactions/date-header.tsx | 25 --- .../transactions/transaction-list.tsx | 2 +- resources/js/components/ui/amount-input.tsx | 2 +- resources/js/components/ui/avatar.tsx | 74 +++---- resources/js/components/ui/glowing-effect.tsx | 2 +- resources/js/components/ui/icon.tsx | 14 -- resources/js/components/ui/input-group.tsx | 99 ---------- .../js/components/ui/navigation-menu.tsx | 168 ---------------- .../js/components/ui/placeholder-pattern.tsx | 20 -- resources/js/hooks/use-dashboard-data.ts | 8 +- resources/js/lib/orphan-components.test.ts | 183 ++++++++++++++++++ resources/js/pages/dashboard.tsx | 14 +- 17 files changed, 236 insertions(+), 739 deletions(-) delete mode 100644 resources/js/components/app-mobile-nav.tsx delete mode 100644 resources/js/components/appearance-dropdown.tsx delete mode 100644 resources/js/components/dashboard/stat-card.tsx delete mode 100644 resources/js/components/landing/encryption-video-player.tsx delete mode 100644 resources/js/components/sync-status-button.tsx delete mode 100644 resources/js/components/transactions/date-header.tsx delete mode 100644 resources/js/components/ui/icon.tsx delete mode 100644 resources/js/components/ui/input-group.tsx delete mode 100644 resources/js/components/ui/navigation-menu.tsx delete mode 100644 resources/js/components/ui/placeholder-pattern.tsx create mode 100644 resources/js/lib/orphan-components.test.ts diff --git a/resources/js/components/app-mobile-nav.tsx b/resources/js/components/app-mobile-nav.tsx deleted file mode 100644 index bb8693ee..00000000 --- a/resources/js/components/app-mobile-nav.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export default function AppMobileNav() { - return null; -} diff --git a/resources/js/components/appearance-dropdown.tsx b/resources/js/components/appearance-dropdown.tsx deleted file mode 100644 index 15375fa3..00000000 --- a/resources/js/components/appearance-dropdown.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import { Button } from '@/components/ui/button'; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from '@/components/ui/dropdown-menu'; -import { useAppearance } from '@/hooks/use-appearance'; -import { __ } from '@/utils/i18n'; -import { Monitor, Moon, Sun } from 'lucide-react'; -import { HTMLAttributes } from 'react'; - -export default function AppearanceToggleDropdown({ - className = '', - ...props -}: HTMLAttributes) { - const { appearance, updateAppearance } = useAppearance(); - - const getCurrentIcon = () => { - switch (appearance) { - case 'dark': - return ; - case 'light': - return ; - default: - return ; - } - }; - - return ( -
- - - - - - updateAppearance('light')}> - - - {__('Light')} - - - updateAppearance('dark')}> - - - {__('Dark')} - - - updateAppearance('system')} - > - - - {__('System')} - - - - -
- ); -} diff --git a/resources/js/components/dashboard/stat-card.tsx b/resources/js/components/dashboard/stat-card.tsx deleted file mode 100644 index 65e110a4..00000000 --- a/resources/js/components/dashboard/stat-card.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; -import { cn } from '@/lib/utils'; -import { ArrowDownIcon, ArrowUpIcon, LucideIcon } from 'lucide-react'; - -interface StatCardProps { - title: string; - value: string; - description?: string; - icon?: LucideIcon; - trend?: { - value: number; - label: string; - }; - className?: string; - loading?: boolean; -} - -export function StatCard({ - title, - value, - description, - icon: Icon, - trend, - className, - loading, -}: StatCardProps) { - if (loading) { - return ( - - - - {title} - - {Icon && } - - -
-
- - - ); - } - - return ( - - - {title} - {Icon && } - - -
{value}
- {(description || trend) && ( -
- {trend && ( - 0 - ? 'text-green-600' - : trend.value < 0 - ? 'text-red-600' - : '', - )} - > - {trend.value > 0 ? ( - - ) : trend.value < 0 ? ( - - ) : null} - {Math.abs(trend.value).toFixed(1)}% - - )} - {trend?.label || description} -
- )} -
-
- ); -} diff --git a/resources/js/components/landing/encryption-video-player.tsx b/resources/js/components/landing/encryption-video-player.tsx deleted file mode 100644 index 79ce2c06..00000000 --- a/resources/js/components/landing/encryption-video-player.tsx +++ /dev/null @@ -1,122 +0,0 @@ -import { cn } from '@/lib/utils'; -import { RotateCcwIcon } from 'lucide-react'; -import { useCallback, useEffect, useRef, useState } from 'react'; - -interface EncryptionVideoPlayerProps { - lightSrc: string; - darkSrc: string; - className?: string; -} - -export default function EncryptionVideoPlayer({ - lightSrc, - darkSrc, - className, -}: EncryptionVideoPlayerProps) { - const lightVideoRef = useRef(null); - const darkVideoRef = useRef(null); - const containerRef = useRef(null); - const [hasEnded, setHasEnded] = useState(false); - const [isHovering, setIsHovering] = useState(false); - const hasPlayedRef = useRef(false); - - const handleReplay = useCallback(() => { - const lightVideo = lightVideoRef.current; - const darkVideo = darkVideoRef.current; - - if (lightVideo) { - lightVideo.currentTime = 0; - lightVideo.play().catch(() => { - // Autoplay was prevented - user will need to interact - }); - } - if (darkVideo) { - darkVideo.currentTime = 0; - darkVideo.play().catch(() => { - // Autoplay was prevented - user will need to interact - }); - } - setHasEnded(false); - }, []); - - const handleEnded = useCallback(() => { - setHasEnded(true); - }, []); - - useEffect(() => { - const container = containerRef.current; - const lightVideo = lightVideoRef.current; - const darkVideo = darkVideoRef.current; - - if (!container || !lightVideo || !darkVideo) return; - - const observer = new IntersectionObserver( - (entries) => { - entries.forEach((entry) => { - if (entry.isIntersecting && !hasPlayedRef.current) { - hasPlayedRef.current = true; - lightVideo.play().catch(() => { - // Autoplay was prevented - user will need to interact - }); - darkVideo.play().catch(() => { - // Autoplay was prevented - user will need to interact - }); - } - }); - }, - { - threshold: 0.5, - }, - ); - - observer.observe(container); - - return () => { - observer.disconnect(); - }; - }, []); - - return ( -
setIsHovering(true)} - onMouseLeave={() => setIsHovering(false)} - > -
-
- - {/* Replay button - visible on hover when video has ended */} - {hasEnded && ( - - )} -
- ); -} diff --git a/resources/js/components/sync-status-button.tsx b/resources/js/components/sync-status-button.tsx deleted file mode 100644 index 2c851698..00000000 --- a/resources/js/components/sync-status-button.tsx +++ /dev/null @@ -1,92 +0,0 @@ -import { Button } from '@/components/ui/button'; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from '@/components/ui/dropdown-menu'; -import { useSyncContext } from '@/contexts/sync-context'; -import { __ } from '@/utils/i18n'; -import { formatDistanceToNow } from 'date-fns'; -import { CloudAlert, CloudCheck, CloudOff, RefreshCw } from 'lucide-react'; -import { useState } from 'react'; - -export function SyncStatusButton() { - const { syncStatus, lastSyncTime, isOnline, sync, error } = - useSyncContext(); - const [isMenuOpen, setIsMenuOpen] = useState(false); - - const getIcon = () => { - if (syncStatus === 'syncing') { - return ; - } - - if (syncStatus === 'error') { - return ; - } - - if (!isOnline) { - return ; - } - - return ; - }; - - const getLastSyncText = () => { - if (lastSyncTime) { - return `Synced ${formatDistanceToNow(lastSyncTime, { addSuffix: true })}`; - } - return 'Not synced yet'; - }; - - const getStatusText = () => { - if (syncStatus === 'syncing') { - return 'Syncing...'; - } - if (!isOnline) { - return 'Offline'; - } - if (syncStatus === 'error') { - return error || 'Sync failed'; - } - return getLastSyncText(); - }; - - const handleSyncNow = () => { - sync(); - }; - - return ( - - - - - - -

{getStatusText()}

-
- - { - e.preventDefault(); - handleSyncNow(); - }} - disabled={syncStatus === 'syncing' || !isOnline} - > - - {__('Sync now')} - -
-
- ); -} diff --git a/resources/js/components/transactions/date-header.tsx b/resources/js/components/transactions/date-header.tsx deleted file mode 100644 index b2f33c71..00000000 --- a/resources/js/components/transactions/date-header.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { format, getYear, parseISO } from 'date-fns'; - -export interface DateHeaderProps { - date: string; - colSpan: number; -} - -export function DateHeader({ date, colSpan }: DateHeaderProps) { - const parsedDate = parseISO(date); - const currentYear = getYear(new Date()); - const transactionYear = getYear(parsedDate); - const formatString = - transactionYear === currentYear ? 'MMM d' : 'MMM d, yy'; - - return ( - - - {format(parsedDate, formatString)} - - - ); -} diff --git a/resources/js/components/transactions/transaction-list.tsx b/resources/js/components/transactions/transaction-list.tsx index d30445d5..796e47e2 100644 --- a/resources/js/components/transactions/transaction-list.tsx +++ b/resources/js/components/transactions/transaction-list.tsx @@ -862,7 +862,7 @@ export function TransactionList({ consoleDebug('=== Re-evaluation complete ==='); } }, - [categories, accounts, banks, updateTransaction, automationRules], + [updateTransaction], ); async function handleBulkReEvaluateRules() { diff --git a/resources/js/components/ui/amount-input.tsx b/resources/js/components/ui/amount-input.tsx index 5b036015..bde95c63 100644 --- a/resources/js/components/ui/amount-input.tsx +++ b/resources/js/components/ui/amount-input.tsx @@ -179,7 +179,7 @@ export const AmountInput = React.forwardRef( setDisplayValue(formatCurrency(value, locale)); } } - }, [value, isFocused]); + }, [value, isFocused, locale]); const handleFocus = () => { setIsFocused(true); diff --git a/resources/js/components/ui/avatar.tsx b/resources/js/components/ui/avatar.tsx index b7224f00..1e1516d3 100644 --- a/resources/js/components/ui/avatar.tsx +++ b/resources/js/components/ui/avatar.tsx @@ -1,51 +1,51 @@ -import * as React from "react" -import * as AvatarPrimitive from "@radix-ui/react-avatar" +import * as AvatarPrimitive from '@radix-ui/react-avatar'; +import * as React from 'react'; -import { cn } from "@/lib/utils" +import { cn } from '@/lib/utils'; function Avatar({ - className, - ...props + className, + ...props }: React.ComponentProps) { - return ( - - ) + return ( + + ); } function AvatarImage({ - className, - ...props + className, + ...props }: React.ComponentProps) { - return ( - - ) + return ( + + ); } function AvatarFallback({ - className, - ...props + className, + ...props }: React.ComponentProps) { - return ( - - ) + return ( + + ); } -export { Avatar, AvatarImage, AvatarFallback } +export { Avatar, AvatarImage, AvatarFallback }; diff --git a/resources/js/components/ui/glowing-effect.tsx b/resources/js/components/ui/glowing-effect.tsx index 25c4c416..6a6a5fa4 100644 --- a/resources/js/components/ui/glowing-effect.tsx +++ b/resources/js/components/ui/glowing-effect.tsx @@ -77,7 +77,7 @@ const GlowingEffect = memo( const currentAngle = parseFloat(element.style.getPropertyValue("--start")) || 0; - let targetAngle = + const targetAngle = (180 * Math.atan2(mouseY - center[1], mouseX - center[0])) / Math.PI + 90; diff --git a/resources/js/components/ui/icon.tsx b/resources/js/components/ui/icon.tsx deleted file mode 100644 index bb8b3a06..00000000 --- a/resources/js/components/ui/icon.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { LucideIcon } from 'lucide-react'; - -interface IconProps { - iconNode?: LucideIcon | null; - className?: string; -} - -export function Icon({ iconNode: IconComponent, className }: IconProps) { - if (!IconComponent) { - return null; - } - - return ; -} diff --git a/resources/js/components/ui/input-group.tsx b/resources/js/components/ui/input-group.tsx deleted file mode 100644 index 2dab395c..00000000 --- a/resources/js/components/ui/input-group.tsx +++ /dev/null @@ -1,99 +0,0 @@ -import * as React from 'react'; - -import { cn } from '@/lib/utils'; - -const InputGroup = React.forwardRef< - HTMLDivElement, - React.HTMLAttributes ->(({ className, ...props }, ref) => { - return ( -
- ); -}); -InputGroup.displayName = 'InputGroup'; - -const InputGroupAddon = React.forwardRef< - HTMLDivElement, - React.HTMLAttributes & { - align?: 'inline-start' | 'inline-end' | 'block-end'; - } ->(({ className, align = 'inline-start', ...props }, ref) => { - return ( -
- ); -}); -InputGroupAddon.displayName = 'InputGroupAddon'; - -const InputGroupText = React.forwardRef< - HTMLSpanElement, - React.HTMLAttributes ->(({ className, ...props }, ref) => { - return ( - - ); -}); -InputGroupText.displayName = 'InputGroupText'; - -const InputGroupInput = React.forwardRef< - HTMLInputElement, - React.InputHTMLAttributes ->(({ className, type = 'text', ...props }, ref) => { - return ( - - ); -}); -InputGroupInput.displayName = 'InputGroupInput'; - -const InputGroupTextarea = React.forwardRef< - HTMLTextAreaElement, - React.TextareaHTMLAttributes ->(({ className, ...props }, ref) => { - return ( -