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 } from 'react';
import { lazy, Suspense, useId, type ReactNode } from 'react';
import { Routes, Route, Navigate, useLocation } from 'react-router-dom';
import { useAuth } from '@/hooks/useAuth';
import Layout from '@/components/layout/Layout';
@ -61,7 +60,7 @@ const PayoffPage = lazy(() => import('@/pages/PayoffPage'));
const SpendingPage = lazy(() => import('@/pages/SpendingPage'));
const BankTransactionsPage = lazy(() => import('@/pages/BankTransactionsPage'));
function RequireAuth({ children, role }) {
function RequireAuth({ children, role }: { children: ReactNode; role?: string }): ReactNode {
const { user, singleUserMode } = useAuth();
const location = useLocation();
@ -96,7 +95,7 @@ function RequireAuth({ children, role }) {
return children;
}
function AdminShell({ children }) {
function AdminShell({ children }: { children: ReactNode }) {
const location = useLocation();
return (
@ -220,7 +219,7 @@ export default function App() {
</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="summary" element={<ErrorBoundary><Suspense fallback={<PageLoader />}><SummaryPage /></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 { 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', []);
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 AppNavigation from './Sidebar';
import { api } from '@/api';
@ -9,7 +9,7 @@ function SimplefinBadge() {
useEffect(() => {
api.simplefinStatus()
.then(d => setEnabled(!!d.enabled))
.then(d => setEnabled(!!(d as { enabled?: boolean }).enabled))
.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();
return (

View File

@ -1,9 +1,24 @@
import React, { useMemo } from 'react';
import { NavLink } from 'react-router-dom';
import { type LucideIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
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 to = useMemo(() => item.to, [item.to]);
const end = useMemo(() => item.end, [item.end]);

View File

@ -3,7 +3,7 @@ import { NavLink, useLocation, useNavigate } from 'react-router-dom';
import {
Activity, BarChart3, Calculator, CalendarDays, ChevronDown, ClipboardCheck, ClipboardList, Database, Info, LayoutGrid, LogOut, Map, Menu, Receipt,
Search, Settings, ShieldCheck, Tag, TrendingDown, User, X,
Landmark, Repeat, ShoppingCart,
Landmark, Repeat, ShoppingCart, type LucideIcon,
} from 'lucide-react';
import { api } from '@/api';
import { cn } from '@/lib/utils';
@ -23,19 +23,28 @@ import {
import { NavPill } from './NavPill';
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: '/analytics', icon: BarChart3, label: 'Analytics' },
];
const adminNavItems = [
const adminNavItems: SidebarNavItem[] = [
{ to: '/', icon: LayoutGrid, label: 'Tracker', end: true },
{ to: '/admin', icon: ShieldCheck, label: 'Admin Panel', end: true },
{ to: '/admin/status', icon: Activity, label: 'System Status' },
{ to: '/roadmap', icon: Map, label: 'Roadmap' },
];
const trackerItems = [
const trackerItems: SidebarNavItem[] = [
{ to: '/', icon: LayoutGrid, label: 'Overview', end: true },
{ to: '/summary', icon: ClipboardList, label: 'Summary' },
{ to: '/bills', icon: Receipt, label: 'Bills' },
@ -49,7 +58,14 @@ const trackerItems = [
{ 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 navigate = useNavigate();
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 navigate = useNavigate();
const name = useMemo(() =>
@ -121,7 +137,7 @@ function UserMenu({ adminMode = false }) {
const userRole = useMemo(() => user?.role, [user]);
const handleLogout = async () => {
try { await logout(); } catch {}
try { await logout(); } catch { /* ignore */ }
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 { user } = useAuth();
const items = useMemo(() => adminMode ? adminNavItems : userNavItems, [adminMode]);
const [simplefinReady, setSimplefinReady] = useState(false);
const { data: overdueData } = useOverdueCount();
const overdueCount = (!adminMode && overdueData?.count) ? overdueData.count : 0;
const overdueNames = (!adminMode && overdueData?.names) ? overdueData.names : [];
const od = overdueData as { count?: number; names?: string[] } | undefined;
const overdueCount = !adminMode ? (od?.count ?? 0) : 0;
const overdueNames = !adminMode ? (od?.names ?? []) : [];
const accountToolsAllowed = !user?.is_default_admin;
const trackerMenuItems = useMemo(
() => trackerItems.filter(item => (
@ -210,7 +227,8 @@ export default function Sidebar({ adminMode = false }) {
let cancelled = false;
api.simplefinStatus()
.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(() => {
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 './index.css';
ReactDOM.createRoot(document.getElementById('root')).render(
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<ThemeProvider>
<BrowserRouter>

View File

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