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:
parent
152b186c10
commit
2a1286e98a
|
|
@ -1,3 +0,0 @@
|
|||
export default function AppMobileNav() {
|
||||
return 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<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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -862,7 +862,7 @@ export function TransactionList({
|
|||
consoleDebug('=== Re-evaluation complete ===');
|
||||
}
|
||||
},
|
||||
[categories, accounts, banks, updateTransaction, automationRules],
|
||||
[updateTransaction],
|
||||
);
|
||||
|
||||
async function handleBulkReEvaluateRules() {
|
||||
|
|
|
|||
|
|
@ -179,7 +179,7 @@ export const AmountInput = React.forwardRef<HTMLInputElement, AmountInputProps>(
|
|||
setDisplayValue(formatCurrency(value, locale));
|
||||
}
|
||||
}
|
||||
}, [value, isFocused]);
|
||||
}, [value, isFocused, locale]);
|
||||
|
||||
const handleFocus = () => {
|
||||
setIsFocused(true);
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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} />;
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.`,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
Loading…
Reference in New Issue