refactor(ts): convert the app shell to TSX (B7)

App.tsx (routing + QueryClient), main.tsx (entry; index.html updated to
main.tsx), useAuth.tsx (typed AuthContextValue/User + MeResponse; uses the safe
useContext()||defaults pattern so no `never` trap), and the layout components
(Layout, Sidebar, NavPill, BrandBlock — NavItem type, LucideIcon-typed nav
config, casts for the untyped overdue/simplefin API responses). TS caught a dead
prop: App passed mainContentId to TrackerPage, which takes no params — removed.

typecheck 0, lint 0 errors (39 warns), build green, 17/17 e2e probe.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
null 2026-07-04 19:49:57 -05:00
parent c2097efc49
commit 609de2a946
9 changed files with 140 additions and 81 deletions

View File

@ -1,5 +1,4 @@
import { lazy, Suspense, useId, type ReactNode } from 'react';
import { lazy, Suspense, useId } from 'react';
import { Routes, Route, Navigate, useLocation } from 'react-router-dom'; import { Routes, Route, Navigate, useLocation } from 'react-router-dom';
import { useAuth } from '@/hooks/useAuth'; import { useAuth } from '@/hooks/useAuth';
import Layout from '@/components/layout/Layout'; import Layout from '@/components/layout/Layout';
@ -61,7 +60,7 @@ const PayoffPage = lazy(() => import('@/pages/PayoffPage'));
const SpendingPage = lazy(() => import('@/pages/SpendingPage')); const SpendingPage = lazy(() => import('@/pages/SpendingPage'));
const BankTransactionsPage = lazy(() => import('@/pages/BankTransactionsPage')); const BankTransactionsPage = lazy(() => import('@/pages/BankTransactionsPage'));
function RequireAuth({ children, role }) { function RequireAuth({ children, role }: { children: ReactNode; role?: string }): ReactNode {
const { user, singleUserMode } = useAuth(); const { user, singleUserMode } = useAuth();
const location = useLocation(); const location = useLocation();
@ -96,7 +95,7 @@ function RequireAuth({ children, role }) {
return children; return children;
} }
function AdminShell({ children }) { function AdminShell({ children }: { children: ReactNode }) {
const location = useLocation(); const location = useLocation();
return ( return (
@ -220,7 +219,7 @@ export default function App() {
</RequireAuth> </RequireAuth>
} }
> >
<Route index element={<ErrorBoundary><Suspense fallback={<PageLoader />}><TrackerPage mainContentId={mainContentId} /></Suspense></ErrorBoundary>} /> <Route index element={<ErrorBoundary><Suspense fallback={<PageLoader />}><TrackerPage /></Suspense></ErrorBoundary>} />
<Route path="calendar" element={<ErrorBoundary><Suspense fallback={<PageLoader />}><CalendarPage /></Suspense></ErrorBoundary>} /> <Route path="calendar" element={<ErrorBoundary><Suspense fallback={<PageLoader />}><CalendarPage /></Suspense></ErrorBoundary>} />
<Route path="summary" element={<ErrorBoundary><Suspense fallback={<PageLoader />}><SummaryPage /></Suspense></ErrorBoundary>} /> <Route path="summary" element={<ErrorBoundary><Suspense fallback={<PageLoader />}><SummaryPage /></Suspense></ErrorBoundary>} />
<Route path="bills" element={<ErrorBoundary><Suspense fallback={<PageLoader />}><BillsPage /></Suspense></ErrorBoundary>} /> <Route path="bills" element={<ErrorBoundary><Suspense fallback={<PageLoader />}><BillsPage /></Suspense></ErrorBoundary>} />

View File

@ -1,8 +1,7 @@
import React, { useMemo } from 'react'; import React, { useMemo } from 'react';
import { NavLink } from 'react-router-dom'; import { NavLink } from 'react-router-dom';
import { cn } from '@/lib/utils';
export const BrandBlock = React.memo(function BrandBlock({ adminMode = false }) { export const BrandBlock = React.memo(function BrandBlock({ adminMode = false }: { adminMode?: boolean }) {
const logoSrc = useMemo(() => '/img/logo.png', []); const logoSrc = useMemo(() => '/img/logo.png', []);
return ( return (

View File

@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { Link, Outlet, useLocation } from 'react-router-dom'; import { Link, Outlet, useLocation } from 'react-router-dom';
import AppNavigation from './Sidebar'; import AppNavigation from './Sidebar';
import { api } from '@/api'; import { api } from '@/api';
@ -9,7 +9,7 @@ function SimplefinBadge() {
useEffect(() => { useEffect(() => {
api.simplefinStatus() api.simplefinStatus()
.then(d => setEnabled(!!d.enabled)) .then(d => setEnabled(!!(d as { enabled?: boolean }).enabled))
.catch(err => console.error('[Layout] simplefinStatus check failed', err)); .catch(err => console.error('[Layout] simplefinStatus check failed', err));
}, []); }, []);
@ -23,7 +23,7 @@ function SimplefinBadge() {
); );
} }
export default function Layout({ mainContentId }) { export default function Layout({ mainContentId }: { mainContentId?: string }) {
const location = useLocation(); const location = useLocation();
return ( return (

View File

@ -1,9 +1,24 @@
import React, { useMemo } from 'react'; import React, { useMemo } from 'react';
import { NavLink } from 'react-router-dom'; import { NavLink } from 'react-router-dom';
import { type LucideIcon } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
export const NavPill = React.memo(function NavPill({ item, onNavigate, badge, badgeNames = [] }) { export interface NavItem {
icon: LucideIcon;
to: string;
end?: boolean;
label: string;
}
interface NavPillProps {
item: NavItem;
onNavigate?: () => void;
badge?: number;
badgeNames?: string[];
}
export const NavPill = React.memo(function NavPill({ item, onNavigate, badge = 0, badgeNames = [] }: NavPillProps) {
const Icon = useMemo(() => item.icon, [item.icon]); const Icon = useMemo(() => item.icon, [item.icon]);
const to = useMemo(() => item.to, [item.to]); const to = useMemo(() => item.to, [item.to]);
const end = useMemo(() => item.end, [item.end]); const end = useMemo(() => item.end, [item.end]);

View File

@ -3,7 +3,7 @@ import { NavLink, useLocation, useNavigate } from 'react-router-dom';
import { import {
Activity, BarChart3, Calculator, CalendarDays, ChevronDown, ClipboardCheck, ClipboardList, Database, Info, LayoutGrid, LogOut, Map, Menu, Receipt, Activity, BarChart3, Calculator, CalendarDays, ChevronDown, ClipboardCheck, ClipboardList, Database, Info, LayoutGrid, LogOut, Map, Menu, Receipt,
Search, Settings, ShieldCheck, Tag, TrendingDown, User, X, Search, Settings, ShieldCheck, Tag, TrendingDown, User, X,
Landmark, Repeat, ShoppingCart, Landmark, Repeat, ShoppingCart, type LucideIcon,
} from 'lucide-react'; } from 'lucide-react';
import { api } from '@/api'; import { api } from '@/api';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
@ -23,19 +23,28 @@ import {
import { NavPill } from './NavPill'; import { NavPill } from './NavPill';
import { BrandBlock } from './BrandBlock'; import { BrandBlock } from './BrandBlock';
const userNavItems = [ interface SidebarNavItem {
to: string;
icon: LucideIcon;
label: string;
end?: boolean;
simplefinOnly?: boolean;
accountToolsOnly?: boolean;
}
const userNavItems: SidebarNavItem[] = [
{ to: '/calendar', icon: CalendarDays, label: 'Calendar' }, { to: '/calendar', icon: CalendarDays, label: 'Calendar' },
{ to: '/analytics', icon: BarChart3, label: 'Analytics' }, { to: '/analytics', icon: BarChart3, label: 'Analytics' },
]; ];
const adminNavItems = [ const adminNavItems: SidebarNavItem[] = [
{ to: '/', icon: LayoutGrid, label: 'Tracker', end: true }, { to: '/', icon: LayoutGrid, label: 'Tracker', end: true },
{ to: '/admin', icon: ShieldCheck, label: 'Admin Panel', end: true }, { to: '/admin', icon: ShieldCheck, label: 'Admin Panel', end: true },
{ to: '/admin/status', icon: Activity, label: 'System Status' }, { to: '/admin/status', icon: Activity, label: 'System Status' },
{ to: '/roadmap', icon: Map, label: 'Roadmap' }, { to: '/roadmap', icon: Map, label: 'Roadmap' },
]; ];
const trackerItems = [ const trackerItems: SidebarNavItem[] = [
{ to: '/', icon: LayoutGrid, label: 'Overview', end: true }, { to: '/', icon: LayoutGrid, label: 'Overview', end: true },
{ to: '/summary', icon: ClipboardList, label: 'Summary' }, { to: '/summary', icon: ClipboardList, label: 'Summary' },
{ to: '/bills', icon: Receipt, label: 'Bills' }, { to: '/bills', icon: Receipt, label: 'Bills' },
@ -49,7 +58,14 @@ const trackerItems = [
{ to: '/data', icon: Database, label: 'Data', accountToolsOnly: true }, { to: '/data', icon: Database, label: 'Data', accountToolsOnly: true },
]; ];
function TrackerMenu({ onNavigate, badge, badgeNames = [], items = trackerItems }) { interface TrackerMenuProps {
onNavigate?: () => void;
badge?: number;
badgeNames?: string[];
items?: SidebarNavItem[];
}
function TrackerMenu({ onNavigate, badge = 0, badgeNames = [], items = trackerItems }: TrackerMenuProps) {
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
const isTrackerActive = useMemo(() => items.some(item => ( const isTrackerActive = useMemo(() => items.some(item => (
@ -110,7 +126,7 @@ function TrackerMenu({ onNavigate, badge, badgeNames = [], items = trackerItems
); );
} }
function UserMenu({ adminMode = false }) { function UserMenu({ adminMode = false }: { adminMode?: boolean }) {
const { user, logout } = useAuth(); const { user, logout } = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
const name = useMemo(() => const name = useMemo(() =>
@ -121,7 +137,7 @@ function UserMenu({ adminMode = false }) {
const userRole = useMemo(() => user?.role, [user]); const userRole = useMemo(() => user?.role, [user]);
const handleLogout = async () => { const handleLogout = async () => {
try { await logout(); } catch {} try { await logout(); } catch { /* ignore */ }
navigate('/login', { replace: true }); navigate('/login', { replace: true });
}; };
@ -188,14 +204,15 @@ function UserMenu({ adminMode = false }) {
); );
} }
export default function Sidebar({ adminMode = false }) { export default function Sidebar({ adminMode = false }: { adminMode?: boolean; mainContentId?: string }) {
const [mobileOpen, setMobileOpen] = useState(false); const [mobileOpen, setMobileOpen] = useState(false);
const { user } = useAuth(); const { user } = useAuth();
const items = useMemo(() => adminMode ? adminNavItems : userNavItems, [adminMode]); const items = useMemo(() => adminMode ? adminNavItems : userNavItems, [adminMode]);
const [simplefinReady, setSimplefinReady] = useState(false); const [simplefinReady, setSimplefinReady] = useState(false);
const { data: overdueData } = useOverdueCount(); const { data: overdueData } = useOverdueCount();
const overdueCount = (!adminMode && overdueData?.count) ? overdueData.count : 0; const od = overdueData as { count?: number; names?: string[] } | undefined;
const overdueNames = (!adminMode && overdueData?.names) ? overdueData.names : []; const overdueCount = !adminMode ? (od?.count ?? 0) : 0;
const overdueNames = !adminMode ? (od?.names ?? []) : [];
const accountToolsAllowed = !user?.is_default_admin; const accountToolsAllowed = !user?.is_default_admin;
const trackerMenuItems = useMemo( const trackerMenuItems = useMemo(
() => trackerItems.filter(item => ( () => trackerItems.filter(item => (
@ -210,7 +227,8 @@ export default function Sidebar({ adminMode = false }) {
let cancelled = false; let cancelled = false;
api.simplefinStatus() api.simplefinStatus()
.then(status => { .then(status => {
if (!cancelled) setSimplefinReady(Boolean(status?.enabled && status?.has_connections)); const s = status as { enabled?: boolean; has_connections?: boolean };
if (!cancelled) setSimplefinReady(Boolean(s?.enabled && s?.has_connections));
}) })
.catch(() => { .catch(() => {
if (!cancelled) setSimplefinReady(false); if (!cancelled) setSimplefinReady(false);

View File

@ -1,54 +0,0 @@
import { createContext, useContext, useEffect, useState } from 'react';
import { api } from '@/api';
const AuthContext = createContext(null);
export function AuthProvider({ children }) {
const [user, setUser] = useState(undefined); // undefined = loading
const [singleUserMode, setSUM] = useState(false);
const [hasNewVersion, setHasNewVersion] = useState(false);
function applyMeResponse(d) {
setUser(d.user);
setSUM(d.single_user_mode || false);
setHasNewVersion(!!d.has_new_version);
}
useEffect(() => {
api.authMode().then(d => {
if (d.auth_mode === 'single') setSUM(true);
}).catch(err => console.error('[useAuth] authMode check failed', err));
api.me().then(applyMeResponse).catch(() => setUser(null));
}, []);
const logout = async () => {
await api.logout();
setUser(null);
setSUM(false);
setHasNewVersion(false);
};
const refresh = () => {
api.me().then(applyMeResponse).catch(() => {
setUser(null);
setSUM(false);
});
};
return (
<AuthContext.Provider value={{
user, singleUserMode, setUser, logout, refresh,
hasNewVersion, setHasNewVersion,
}}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
return useContext(AuthContext) || {
user: null, setUser: () => {}, logout: () => {}, refresh: () => {},
singleUserMode: false, hasNewVersion: false, setHasNewVersion: () => {},
};
}

82
client/hooks/useAuth.tsx Normal file
View File

@ -0,0 +1,82 @@
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react';
import { api } from '@/api';
export interface User {
id: number;
username?: string;
role?: string;
display_name?: string;
displayName?: string;
name?: string;
is_default_admin?: boolean;
[key: string]: unknown;
}
interface MeResponse {
user?: User | null;
single_user_mode?: boolean;
has_new_version?: boolean;
}
interface AuthContextValue {
user: User | null | undefined; // undefined = loading
singleUserMode: boolean;
setUser: (user: User | null | undefined) => void;
logout: () => void | Promise<void>;
refresh: () => void;
hasNewVersion: boolean;
setHasNewVersion: (v: boolean) => void;
}
const AuthContext = createContext<AuthContextValue | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null | undefined>(undefined); // undefined = loading
const [singleUserMode, setSUM] = useState(false);
const [hasNewVersion, setHasNewVersion] = useState(false);
function applyMeResponse(raw: unknown) {
const d = raw as MeResponse;
setUser(d.user);
setSUM(d.single_user_mode || false);
setHasNewVersion(!!d.has_new_version);
}
useEffect(() => {
api.authMode().then(d => {
if ((d as { auth_mode?: string }).auth_mode === 'single') setSUM(true);
}).catch(err => console.error('[useAuth] authMode check failed', err));
api.me().then(applyMeResponse).catch(() => setUser(null));
}, []);
const logout = async () => {
await api.logout();
setUser(null);
setSUM(false);
setHasNewVersion(false);
};
const refresh = () => {
api.me().then(applyMeResponse).catch(() => {
setUser(null);
setSUM(false);
});
};
return (
<AuthContext.Provider value={{
user, singleUserMode, setUser, logout, refresh,
hasNewVersion, setHasNewVersion,
}}>
{children}
</AuthContext.Provider>
);
}
export function useAuth(): AuthContextValue {
return useContext(AuthContext) || {
user: null, setUser: () => {}, logout: () => {}, refresh: () => {},
singleUserMode: false, hasNewVersion: false, setHasNewVersion: () => {},
};
}

View File

@ -8,7 +8,7 @@ import { ThemeProvider } from './contexts/ThemeContext';
import 'sonner/dist/styles.css'; import 'sonner/dist/styles.css';
import './index.css'; import './index.css';
ReactDOM.createRoot(document.getElementById('root')).render( ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode> <React.StrictMode>
<ThemeProvider> <ThemeProvider>
<BrowserRouter> <BrowserRouter>

View File

@ -14,6 +14,6 @@
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
<script type="module" src="/client/main.jsx"></script> <script type="module" src="/client/main.tsx"></script>
</body> </body>
</html> </html>