chore(frontend): add orphan component detection and remove dead components (#181)

## 🚪 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.
This commit is contained in:
Víctor Falcón 2026-03-02 11:43:27 +00:00 committed by GitHub
parent 152b186c10
commit 2a1286e98a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 236 additions and 739 deletions

View File

@ -1,3 +0,0 @@
export default function AppMobileNav() {
return null;
}

View File

@ -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<HTMLDivElement>) {
const { appearance, updateAppearance } = useAppearance();
const getCurrentIcon = () => {
switch (appearance) {
case 'dark':
return <Moon className="h-5 w-5" />;
case 'light':
return <Sun className="h-5 w-5" />;
default:
return <Monitor className="h-5 w-5" />;
}
};
return (
<div className={className} {...props}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-9 w-9 rounded-md"
>
{getCurrentIcon()}
<span className="sr-only">{__('Toggle theme')}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => updateAppearance('light')}>
<span className="flex items-center gap-2">
<Sun className="h-5 w-5" />
{__('Light')}
</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => updateAppearance('dark')}>
<span className="flex items-center gap-2">
<Moon className="h-5 w-5" />
{__('Dark')}
</span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => updateAppearance('system')}
>
<span className="flex items-center gap-2">
<Monitor className="h-5 w-5" />
{__('System')}
</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}

View File

@ -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 (
<Card className={className}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
{title}
</CardTitle>
{Icon && <Icon className="size-4 text-muted-foreground" />}
</CardHeader>
<CardContent>
<div className="h-8 w-32 animate-pulse rounded bg-gray-200 dark:bg-gray-700" />
<div className="mt-1 h-4 w-24 animate-pulse rounded bg-gray-200 dark:bg-gray-700" />
</CardContent>
</Card>
);
}
return (
<Card className={className}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">{title}</CardTitle>
{Icon && <Icon className="size-4 text-muted-foreground" />}
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{value}</div>
{(description || trend) && (
<div className="flex items-center text-xs text-muted-foreground">
{trend && (
<span
className={cn(
'mr-2 flex items-center font-medium',
trend.value > 0
? 'text-green-600'
: trend.value < 0
? 'text-red-600'
: '',
)}
>
{trend.value > 0 ? (
<ArrowUpIcon className="mr-1 size-3" />
) : trend.value < 0 ? (
<ArrowDownIcon className="mr-1 size-3" />
) : null}
{Math.abs(trend.value).toFixed(1)}%
</span>
)}
{trend?.label || description}
</div>
)}
</CardContent>
</Card>
);
}

View File

@ -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<HTMLVideoElement>(null);
const darkVideoRef = useRef<HTMLVideoElement>(null);
const containerRef = useRef<HTMLDivElement>(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 (
<div
ref={containerRef}
className={cn('relative', className)}
onMouseEnter={() => setIsHovering(true)}
onMouseLeave={() => setIsHovering(false)}
>
<div className="rounded-lg border border-border/60 bg-[#FDFDFC] shadow dark:bg-[#0a0a0a]">
<video
ref={lightVideoRef}
src={lightSrc}
muted
playsInline
onEnded={handleEnded}
className="h-full w-full rounded-lg dark:hidden"
/>
<video
ref={darkVideoRef}
src={darkSrc}
muted
playsInline
onEnded={handleEnded}
className="hidden h-full w-full rounded-lg dark:block"
/>
</div>
{/* Replay button - visible on hover when video has ended */}
{hasEnded && (
<button
onClick={handleReplay}
className={cn(
'absolute inset-0 z-20 flex cursor-pointer items-center justify-center rounded-2xl bg-black/30 transition-opacity duration-200 dark:bg-black/50',
isHovering ? 'opacity-100' : 'opacity-0',
)}
aria-label="Replay video"
>
<div className="flex size-16 items-center justify-center rounded-full bg-white/90 shadow-lg transition-transform hover:scale-110 dark:bg-[#161615]/90">
<RotateCcwIcon className="size-7 text-[#1b1b18] dark:text-[#EDEDEC]" />
</div>
</button>
)}
</div>
);
}

View File

@ -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 <RefreshCw className="h-4 w-4 animate-spin" />;
}
if (syncStatus === 'error') {
return <CloudAlert className="h-4 w-4" />;
}
if (!isOnline) {
return <CloudOff className="h-4 w-4" />;
}
return <CloudCheck className="h-4 w-4" />;
};
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 (
<DropdownMenu open={isMenuOpen} onOpenChange={setIsMenuOpen}>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className={`relative ${isMenuOpen ? 'bg-accent' : ''} ${syncStatus === 'error' || !isOnline ? 'bg-red-100 dark:bg-red-900' : ''}`}
>
{getIcon()}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel className="font-normal">
<p className="text-xs font-medium">{getStatusText()}</p>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={(e) => {
e.preventDefault();
handleSyncNow();
}}
disabled={syncStatus === 'syncing' || !isOnline}
>
<RefreshCw
className={`mr-2 h-4 w-4 ${syncStatus === 'syncing' ? 'animate-spin' : ''}`}
/>
{__('Sync now')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@ -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 (
<tr className="bg-muted/50">
<td
colSpan={colSpan}
className="px-4 py-2 text-sm font-normal text-muted-foreground"
>
{format(parsedDate, formatString)}
</td>
</tr>
);
}

View File

@ -862,7 +862,7 @@ export function TransactionList({
consoleDebug('=== Re-evaluation complete ===');
}
},
[categories, accounts, banks, updateTransaction, automationRules],
[updateTransaction],
);
async function handleBulkReEvaluateRules() {

View File

@ -179,7 +179,7 @@ export const AmountInput = React.forwardRef<HTMLInputElement, AmountInputProps>(
setDisplayValue(formatCurrency(value, locale));
}
}
}, [value, isFocused]);
}, [value, isFocused, locale]);
const handleFocus = () => {
setIsFocused(true);

View File

@ -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<typeof AvatarPrimitive.Root>) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
className={cn(
"relative flex size-8 shrink-0 overflow-hidden rounded-full",
className
)}
{...props}
/>
)
return (
<AvatarPrimitive.Root
data-slot="avatar"
className={cn(
'relative flex size-8 shrink-0 overflow-hidden rounded-full',
className,
)}
{...props}
/>
);
}
function AvatarImage({
className,
...props
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn("aspect-square size-full", className)}
{...props}
/>
)
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn('aspect-square size-full', className)}
{...props}
/>
);
}
function AvatarFallback({
className,
...props
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
"bg-muted flex size-full items-center justify-center rounded-full",
className
)}
{...props}
/>
)
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
'bg-muted flex size-full items-center justify-center rounded-full',
className,
)}
{...props}
/>
);
}
export { Avatar, AvatarImage, AvatarFallback }
export { Avatar, AvatarImage, AvatarFallback };

View File

@ -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;

View File

@ -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 <IconComponent className={className} />;
}

View File

@ -1,99 +0,0 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
const InputGroup = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
className={cn(
'relative flex w-full items-stretch overflow-hidden rounded-lg border border-input bg-background focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2',
className,
)}
{...props}
/>
);
});
InputGroup.displayName = 'InputGroup';
const InputGroupAddon = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & {
align?: 'inline-start' | 'inline-end' | 'block-end';
}
>(({ className, align = 'inline-start', ...props }, ref) => {
return (
<div
ref={ref}
className={cn(
'flex items-center justify-center bg-muted px-3 text-sm text-muted-foreground',
align === 'inline-end' && 'order-last',
align === 'block-end' && 'order-last w-full border-t',
className,
)}
{...props}
/>
);
});
InputGroupAddon.displayName = 'InputGroupAddon';
const InputGroupText = React.forwardRef<
HTMLSpanElement,
React.HTMLAttributes<HTMLSpanElement>
>(({ className, ...props }, ref) => {
return (
<span
ref={ref}
className={cn('whitespace-nowrap', className)}
{...props}
/>
);
});
InputGroupText.displayName = 'InputGroupText';
const InputGroupInput = React.forwardRef<
HTMLInputElement,
React.InputHTMLAttributes<HTMLInputElement>
>(({ className, type = 'text', ...props }, ref) => {
return (
<input
type={type}
className={cn(
'flex h-10 w-full flex-1 border-0 bg-transparent px-3 py-2 text-base ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
className,
)}
ref={ref}
{...props}
/>
);
});
InputGroupInput.displayName = 'InputGroupInput';
const InputGroupTextarea = React.forwardRef<
HTMLTextAreaElement,
React.TextareaHTMLAttributes<HTMLTextAreaElement>
>(({ className, ...props }, ref) => {
return (
<textarea
className={cn(
'flex min-h-[80px] w-full flex-1 border-0 bg-transparent px-3 py-2 text-base ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
className,
)}
ref={ref}
{...props}
/>
);
});
InputGroupTextarea.displayName = 'InputGroupTextarea';
export {
InputGroup,
InputGroupAddon,
InputGroupInput,
InputGroupText,
InputGroupTextarea,
};

View File

@ -1,168 +0,0 @@
import * as React from "react"
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu"
import { cva } from "class-variance-authority"
import { ChevronDownIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function NavigationMenu({
className,
children,
viewport = true,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Root> & {
viewport?: boolean
}) {
return (
<NavigationMenuPrimitive.Root
data-slot="navigation-menu"
data-viewport={viewport}
className={cn(
"group/navigation-menu relative flex max-w-max flex-1 items-center justify-center",
className
)}
{...props}
>
{children}
{viewport && <NavigationMenuViewport />}
</NavigationMenuPrimitive.Root>
)
}
function NavigationMenuList({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.List>) {
return (
<NavigationMenuPrimitive.List
data-slot="navigation-menu-list"
className={cn(
"group flex flex-1 list-none items-center justify-center gap-1",
className
)}
{...props}
/>
)
}
function NavigationMenuItem({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Item>) {
return (
<NavigationMenuPrimitive.Item
data-slot="navigation-menu-item"
className={cn("relative", className)}
{...props}
/>
)
}
const navigationMenuTriggerStyle = cva(
"group inline-flex h-9 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground disabled:pointer-events-none disabled:opacity-50 data-[active=true]:bg-accent/50 data-[state=open]:bg-accent/50 data-[active=true]:text-accent-foreground ring-ring/10 dark:ring-ring/20 dark:outline-ring/40 outline-ring/50 transition-[color,box-shadow] focus-visible:ring-4 focus-visible:outline-1"
)
function NavigationMenuTrigger({
className,
children,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Trigger>) {
return (
<NavigationMenuPrimitive.Trigger
data-slot="navigation-menu-trigger"
className={cn(navigationMenuTriggerStyle(), "group", className)}
{...props}
>
{children}{" "}
<ChevronDownIcon
className="relative top-[1px] ml-1 size-3 transition duration-300 group-data-[state=open]:rotate-180"
aria-hidden="true"
/>
</NavigationMenuPrimitive.Trigger>
)
}
function NavigationMenuContent({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Content>) {
return (
<NavigationMenuPrimitive.Content
data-slot="navigation-menu-content"
className={cn(
"data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 top-0 left-0 w-full p-2 pr-2.5 md:absolute md:w-auto",
"group-data-[viewport=false]/navigation-menu:bg-popover group-data-[viewport=false]/navigation-menu:text-popover-foreground group-data-[viewport=false]/navigation-menu:data-[state=open]:animate-in group-data-[viewport=false]/navigation-menu:data-[state=closed]:animate-out group-data-[viewport=false]/navigation-menu:data-[state=closed]:zoom-out-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:zoom-in-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:fade-in-0 group-data-[viewport=false]/navigation-menu:data-[state=closed]:fade-out-0 group-data-[viewport=false]/navigation-menu:top-full group-data-[viewport=false]/navigation-menu:mt-1.5 group-data-[viewport=false]/navigation-menu:overflow-hidden group-data-[viewport=false]/navigation-menu:rounded-md group-data-[viewport=false]/navigation-menu:border group-data-[viewport=false]/navigation-menu:shadow group-data-[viewport=false]/navigation-menu:duration-200 **:data-[slot=navigation-menu-link]:focus:ring-0 **:data-[slot=navigation-menu-link]:focus:outline-none",
className
)}
{...props}
/>
)
}
function NavigationMenuViewport({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Viewport>) {
return (
<div
className={cn(
"absolute top-full left-0 isolate z-50 flex justify-center"
)}
>
<NavigationMenuPrimitive.Viewport
data-slot="navigation-menu-viewport"
className={cn(
"origin-top-center bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border shadow md:w-[var(--radix-navigation-menu-viewport-width)]",
className
)}
{...props}
/>
</div>
)
}
function NavigationMenuLink({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Link>) {
return (
<NavigationMenuPrimitive.Link
data-slot="navigation-menu-link"
className={cn(
"hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground data-[active=true]:bg-accent/50 data-[active=true]:text-accent-foreground ring-ring/10 dark:ring-ring/20 dark:outline-ring/40 outline-ring/50 [&_svg:not([class*='text-'])]:text-muted-foreground flex flex-col gap-1 rounded-sm p-2 text-sm transition-[color,box-shadow] focus-visible:ring-4 focus-visible:outline-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function NavigationMenuIndicator({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Indicator>) {
return (
<NavigationMenuPrimitive.Indicator
data-slot="navigation-menu-indicator"
className={cn(
"data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden",
className
)}
{...props}
>
<div className="bg-border relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm shadow-md" />
</NavigationMenuPrimitive.Indicator>
)
}
export {
NavigationMenu,
NavigationMenuList,
NavigationMenuItem,
NavigationMenuContent,
NavigationMenuTrigger,
NavigationMenuLink,
NavigationMenuIndicator,
NavigationMenuViewport,
navigationMenuTriggerStyle,
}

View File

@ -1,20 +0,0 @@
import { useId } from 'react';
interface PlaceholderPatternProps {
className?: string;
}
export function PlaceholderPattern({ className }: PlaceholderPatternProps) {
const patternId = useId();
return (
<svg className={className} fill="none">
<defs>
<pattern id={patternId} x="0" y="0" width="10" height="10" patternUnits="userSpaceOnUse">
<path d="M-3 13 15-5M-5 5l18-18M-1 21 17 3"></path>
</pattern>
</defs>
<rect stroke="none" fill={`url(#${patternId})`} width="100%" height="100%"></rect>
</svg>
);
}

View File

@ -2,7 +2,7 @@ import { useLocale } from '@/hooks/use-locale';
import { Account, AccountType, Bank } from '@/types/account';
import { Category } from '@/types/category';
import { format, subDays, subMonths } from 'date-fns';
import { useEffect, useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
export interface NetWorthEvolutionAccount {
id: string;
@ -116,7 +116,7 @@ export function useDashboardData(): DashboardData & { refetch: () => void } {
});
const [isLoading, setIsLoading] = useState(true);
const fetchData = async () => {
const fetchData = useCallback(async () => {
setIsLoading(true);
try {
const now = new Date();
@ -157,11 +157,11 @@ export function useDashboardData(): DashboardData & { refetch: () => void } {
} finally {
setIsLoading(false);
}
};
}, [locale]);
useEffect(() => {
fetchData();
}, []);
}, [fetchData]);
return { ...data, isLoading, refetch: fetchData };
}

View File

@ -0,0 +1,183 @@
/**
* Orphan component detection
*
* Every file under resources/js/components/ must be imported (directly or via
* a barrel re-export) somewhere in the codebase. If a component is never
* referenced it is dead code and this test will fail.
*
* How it works
* ------------
* 1. Collect all component files (the candidates).
* 2. Collect all source files that may consume components (pages, layouts,
* hooks, contexts, lib, app entry points, and other components that import
* siblings).
* 3. For each candidate, check every consumer file for a reference using:
* a) The @/ alias path (e.g. "@/components/foo/bar")
* b) The relative path from that consumer's directory to the component
* (e.g. "./bar", "../foo/bar", "./import-balances/bar")
* c) The barrel directory path if an index.* exists in the component's
* top-level subdirectory (e.g. "@/components/charts")
* 4. Fail if no consumer references the component.
*
* Exclusions
* ----------
* - index.ts / index.tsx barrel files are skipped as standalone candidates.
* - *.test.ts / *.test.tsx files are excluded from both candidates and
* consumers so test imports don't hide real orphans.
*/
import { readFileSync, readdirSync, statSync } from 'fs';
import { dirname, extname, join, relative, resolve } from 'path';
import { describe, expect, it } from 'vitest';
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
// __dirname is resources/js/lib — go up one level to reach resources/js
const jsRoot = resolve(__dirname, '..');
const componentsRoot = join(jsRoot, 'components');
/** Recursively collect all files matching the given extensions. */
function collectFiles(dir: string, extensions: string[]): string[] {
const results: string[] = [];
for (const entry of readdirSync(dir)) {
const fullPath = join(dir, entry);
const stat = statSync(fullPath);
if (stat.isDirectory()) {
results.push(...collectFiles(fullPath, extensions));
} else if (extensions.includes(extname(entry))) {
results.push(fullPath);
}
}
return results;
}
/** Return the `@/...` alias import path (no extension) for an absolute path. */
function toAliasPath(absolutePath: string): string {
const rel = relative(jsRoot, absolutePath);
return '@/' + rel.replace(/\.(tsx?|jsx?)$/, '');
}
/**
* Return all import strings that would constitute a valid reference to
* `componentFile` from within `consumerFile`.
*/
function importStrings(componentFile: string, consumerFile: string): string[] {
const aliasPath = toAliasPath(componentFile); // "@/components/foo/bar"
const consumerDir = dirname(consumerFile);
// Relative path from consumer's directory to component (no extension)
const rel = relative(consumerDir, componentFile).replace(
/\.(tsx?|jsx?)$/,
'',
);
// Ensure it starts with ./ or ../
const relPath = rel.startsWith('.') ? rel : './' + rel;
const variants = (p: string) => [
`'${p}'`,
`"${p}"`,
`'${p}.ts'`,
`"${p}.ts"`,
`'${p}.tsx'`,
`"${p}.tsx"`,
];
return [...variants(aliasPath), ...variants(relPath)];
}
// ---------------------------------------------------------------------------
// Build the candidate list (component files, excluding barrel index files)
// ---------------------------------------------------------------------------
const allComponentFiles = collectFiles(componentsRoot, ['.ts', '.tsx']).filter(
(f) => !f.endsWith('.test.ts') && !f.endsWith('.test.tsx'),
);
const candidateFiles = allComponentFiles.filter((f) => {
const base = f.split('/').pop()!;
return base !== 'index.ts' && base !== 'index.tsx';
});
// ---------------------------------------------------------------------------
// Build consumer list — everything in resources/js except test files
// ---------------------------------------------------------------------------
const allSourceFiles = collectFiles(jsRoot, ['.ts', '.tsx']).filter(
(f) => !f.endsWith('.test.ts') && !f.endsWith('.test.tsx'),
);
// Pre-read all consumer file contents paired with their path
const consumers: { path: string; content: string }[] = allSourceFiles.map(
(f) => ({ path: f, content: readFileSync(f, 'utf-8') }),
);
// ---------------------------------------------------------------------------
// Pre-compute barrel directories so we can accept directory-level imports
// ---------------------------------------------------------------------------
const barrelDirs = new Set<string>();
for (const f of allComponentFiles) {
const base = f.split('/').pop()!;
if (base === 'index.ts' || base === 'index.tsx') {
barrelDirs.add(dirname(f));
}
}
// ---------------------------------------------------------------------------
// Test
// ---------------------------------------------------------------------------
describe('Orphan component detection', () => {
it('every component must be imported somewhere in the codebase', () => {
const orphans: string[] = [];
for (const componentFile of candidateFiles) {
const componentDir = dirname(componentFile);
const isUsed = consumers.some(({ path: consumerPath, content }) => {
// Check alias + relative import patterns for this consumer
const patterns = importStrings(componentFile, consumerPath);
// If the component sits inside a barrel directory, also accept
// an import of that directory (alias or relative)
if (barrelDirs.has(componentDir)) {
const barrelAlias = toAliasPath(
join(componentDir, 'index'),
).replace(/\/index$/, '');
const relBarrel = relative(
dirname(consumerPath),
componentDir,
);
const relBarrelPath = relBarrel.startsWith('.')
? relBarrel
: './' + relBarrel;
patterns.push(
`'${barrelAlias}'`,
`"${barrelAlias}"`,
`'${relBarrelPath}'`,
`"${relBarrelPath}"`,
);
}
return patterns.some((p) => content.includes(p));
});
if (!isUsed) {
orphans.push(toAliasPath(componentFile));
}
}
if (orphans.length > 0) {
const list = orphans.map((o) => ` - ${o}`).join('\n');
expect.fail(
`Found ${orphans.length} orphan component(s) that are never imported:\n\n${list}\n\nRemove unused components or add them to the codebase.`,
);
}
});
});

View File

@ -47,11 +47,15 @@ export default function Dashboard() {
useEncryptionKey();
const [showUnlockDialog, setShowUnlockDialog] = useState(false);
const netWorthEvolution = props.netWorthEvolution ?? {
data: [],
accounts: {},
currency_code: 'USD',
};
const netWorthEvolution = useMemo(
() =>
props.netWorthEvolution ?? {
data: [],
accounts: {},
currency_code: 'USD',
},
[props.netWorthEvolution],
);
const accountMetrics = useMemo(
() => deriveAccountMetrics(netWorthEvolution, locale),