Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions app/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,17 @@
<meta property="og:title" content="FinMind - AI-Powered Financial Intelligence" />
<meta property="og:description" content="Smart budget tracking and bill management with AI-powered insights to help you save more and stress less." />
<meta property="og:type" content="website" />
<script>
(function () {
var stored = localStorage.getItem('finmind-ui-theme');
var valid = ['dark', 'light', 'system'];
var theme = valid.indexOf(stored) !== -1 ? stored : 'system';
var resolved = theme === 'system'
? (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
: theme;
document.documentElement.classList.add(resolved);
})();
</script>
</head>

<body>
Expand Down
3 changes: 3 additions & 0 deletions app/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Toaster as Sonner } from "@/components/ui/sonner";
import { TooltipProvider } from "@/components/ui/tooltip";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { BrowserRouter, Routes, Route } from "react-router-dom";
import { ThemeProvider } from "@/hooks/use-theme";
import { Layout } from "./components/layout/Layout";
import { Dashboard } from "./pages/Dashboard";
import { Budgets } from "./pages/Budgets";
Expand All @@ -27,6 +28,7 @@ const queryClient = new QueryClient({
});

const App = () => (
<ThemeProvider defaultTheme="system" storageKey="finmind-ui-theme">
<QueryClientProvider client={queryClient}>
<TooltipProvider>
<Toaster />
Expand Down Expand Up @@ -100,6 +102,7 @@ const App = () => (
</BrowserRouter>
</TooltipProvider>
</QueryClientProvider>
</ThemeProvider>
);

export default App;
10 changes: 6 additions & 4 deletions app/src/__tests__/Navbar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,16 @@ import React from 'react';
import { render, screen } from '@testing-library/react';
import { BrowserRouter } from 'react-router-dom';
import { Navbar } from '@/components/layout/Navbar';
import { ThemeProvider } from '@/hooks/use-theme';

// Mock toast
jest.mock('@/components/ui/use-toast', () => ({ useToast: () => ({ toast: jest.fn() }) }));

const renderNav = () => render(
<BrowserRouter>
<Navbar />
</BrowserRouter>
<ThemeProvider defaultTheme="light" storageKey="test-theme">
<BrowserRouter>
<Navbar />
</BrowserRouter>
</ThemeProvider>
);

describe('Navbar auth state', () => {
Expand Down
125 changes: 125 additions & 0 deletions app/src/__tests__/useTheme.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import React from 'react';
import { render, screen, fireEvent, act } from '@testing-library/react';
import { ThemeProvider, useTheme } from '@/hooks/use-theme';

const STORAGE_KEY = 'finmind-ui-theme';

function ThemeDisplay() {
const { theme, setTheme } = useTheme();
return (
<div>
<span data-testid="theme">{theme}</span>
<button onClick={() => setTheme('dark')}>Set Dark</button>
<button onClick={() => setTheme('light')}>Set Light</button>
<button onClick={() => setTheme('system')}>Set System</button>
</div>
);
}

const renderWithProvider = (defaultTheme?: 'dark' | 'light' | 'system') =>
render(
<ThemeProvider defaultTheme={defaultTheme ?? 'system'} storageKey={STORAGE_KEY}>
<ThemeDisplay />
</ThemeProvider>
);

describe('useTheme', () => {
beforeEach(() => {
localStorage.clear();
document.documentElement.classList.remove('dark', 'light');
});

it('defaults to system when no stored value', () => {
renderWithProvider();
expect(screen.getByTestId('theme').textContent).toBe('system');
});

it('reads persisted theme from localStorage', () => {
localStorage.setItem(STORAGE_KEY, 'dark');
renderWithProvider();
expect(screen.getByTestId('theme').textContent).toBe('dark');
});

it('applies dark class to documentElement when theme is dark', () => {
renderWithProvider('dark');
expect(document.documentElement.classList.contains('dark')).toBe(true);
expect(document.documentElement.classList.contains('light')).toBe(false);
});

it('applies light class to documentElement when theme is light', () => {
renderWithProvider('light');
expect(document.documentElement.classList.contains('light')).toBe(true);
expect(document.documentElement.classList.contains('dark')).toBe(false);
});

it('persists theme change to localStorage', () => {
renderWithProvider();
act(() => {
fireEvent.click(screen.getByText('Set Dark'));
});
expect(localStorage.getItem(STORAGE_KEY)).toBe('dark');
expect(screen.getByTestId('theme').textContent).toBe('dark');
});

it('switches from dark to light and updates class', () => {
renderWithProvider('dark');
act(() => {
fireEvent.click(screen.getByText('Set Light'));
});
expect(document.documentElement.classList.contains('light')).toBe(true);
expect(document.documentElement.classList.contains('dark')).toBe(false);
});

it('returns a stable setTheme function across renders', () => {
const { rerender } = renderWithProvider('light');
const firstRender = screen.getByText('Set Dark');
rerender(
<ThemeProvider defaultTheme="light" storageKey={STORAGE_KEY}>
<ThemeDisplay />
</ThemeProvider>
);
expect(screen.getByText('Set Dark')).toBe(firstRender);
});

it('ignores invalid localStorage values and falls back to defaultTheme', () => {
localStorage.setItem(STORAGE_KEY, 'foobar');
renderWithProvider('light');
expect(screen.getByTestId('theme').textContent).toBe('light');
expect(document.documentElement.classList.contains('light')).toBe(true);
});

it('ignores empty string in localStorage and falls back to defaultTheme', () => {
localStorage.setItem(STORAGE_KEY, '');
renderWithProvider('dark');
expect(screen.getByTestId('theme').textContent).toBe('dark');
});

it('responds to system prefers-color-scheme change when theme is system', () => {
let changeHandler: (() => void) | null = null;
const mockMq = {
matches: false,
addEventListener: jest.fn((_event: string, handler: () => void) => {
changeHandler = handler;
}),
removeEventListener: jest.fn(),
};
jest.spyOn(window, 'matchMedia').mockReturnValue(mockMq as unknown as MediaQueryList);

renderWithProvider('system');

act(() => {
mockMq.matches = true;
if (changeHandler) changeHandler();
});

expect(document.documentElement.classList.contains('dark')).toBe(true);

(window.matchMedia as jest.Mock).mockRestore?.();
});

it('throws when used outside ThemeProvider', () => {
const consoleError = jest.spyOn(console, 'error').mockImplementation(() => {});
expect(() => render(<ThemeDisplay />)).toThrow('useTheme must be used within a ThemeProvider');
consoleError.mockRestore();
});
});
35 changes: 32 additions & 3 deletions app/src/components/layout/Navbar.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { useEffect, useState } from 'react';
import { Link, useLocation, useNavigate } from 'react-router-dom';
import { Button } from '@/components/ui/button';
import { Menu, X, TrendingUp, ShieldCheck } from 'lucide-react';
import { Menu, X, TrendingUp, ShieldCheck, Sun, Moon, Monitor } from 'lucide-react';
import { getToken, getRefreshToken, clearToken, clearRefreshToken } from '@/lib/auth';
import { useToast } from '@/components/ui/use-toast';
import { logout as logoutApi } from '@/api/auth';
import { useTheme } from '@/hooks/use-theme';

const navigation = [
{ name: 'Dashboard', href: '/dashboard' },
Expand All @@ -15,6 +16,30 @@ const navigation = [
{ name: 'Analytics', href: '/analytics' },
];

function ThemeToggle() {
const { theme, setTheme } = useTheme();

const cycle = () => {
if (theme === 'light') setTheme('dark');
else if (theme === 'dark') setTheme('system');
else setTheme('light');
};

const Icon = theme === 'dark' ? Moon : theme === 'light' ? Sun : Monitor;
const label = theme === 'dark' ? 'Dark' : theme === 'light' ? 'Light' : 'System';

return (
<button
onClick={cycle}
aria-label={`Theme: ${label}. Click to cycle.`}
className="flex items-center gap-1.5 rounded-full border border-border/70 bg-card/70 px-3 py-1 text-[11px] font-medium text-muted-foreground transition hover:border-border hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
<Icon className="h-3.5 w-3.5" aria-hidden="true" />
<span>{label}</span>
</button>
);
}

export function Navbar() {
const [isOpen, setIsOpen] = useState(false);
const [isAuthed, setIsAuthed] = useState<boolean>(!!getToken());
Expand Down Expand Up @@ -80,10 +105,11 @@ export function Navbar() {
</div>

<div className="hidden items-center gap-3 md:flex">
<div className="flex items-center gap-1 rounded-full border border-border/70 bg-white/70 px-3 py-1 text-[11px] text-muted-foreground">
<div className="flex items-center gap-1 rounded-full border border-border/70 bg-card/70 px-3 py-1 text-[11px] text-muted-foreground">
<ShieldCheck className="h-3.5 w-3.5 text-primary" />
Enterprise-grade security
</div>
<ThemeToggle />
{isAuthed ? (
<>
<Button variant="outline" size="sm" asChild>
Expand Down Expand Up @@ -114,7 +140,7 @@ export function Navbar() {

{isOpen && (
<div className="md:hidden pb-4">
<div className="space-y-2 rounded-2xl border border-border/60 bg-white/90 p-3 shadow-md">
<div className="space-y-2 rounded-2xl border border-border/60 bg-card/90 p-3 shadow-md">
{navigation.map((item) => {
const active = location.pathname === item.href;
return (
Expand All @@ -132,6 +158,9 @@ export function Navbar() {
</Link>
);
})}
<div className="pt-2">
<ThemeToggle />
</div>
<div className="grid grid-cols-2 gap-2 pt-2">
{isAuthed ? (
<>
Expand Down
90 changes: 90 additions & 0 deletions app/src/hooks/use-theme.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { createContext, useContext, useEffect, useMemo, useState } from 'react';
import type { ReactNode } from 'react';

type Theme = 'dark' | 'light' | 'system';

const VALID_THEMES: Theme[] = ['dark', 'light', 'system'];

function parseTheme(value: string | null, fallback: Theme): Theme {
return VALID_THEMES.includes(value as Theme) ? (value as Theme) : fallback;
}

interface ThemeProviderProps {
children: ReactNode;
defaultTheme?: Theme;
storageKey?: string;
}

interface ThemeProviderState {
theme: Theme;
setTheme: (theme: Theme) => void;
}

const ThemeProviderContext = createContext<ThemeProviderState | undefined>(undefined);

export function ThemeProvider({
children,
defaultTheme = 'system',
storageKey = 'finmind-ui-theme',
}: ThemeProviderProps) {
const [theme, setTheme] = useState<Theme>(() => {
try {
return parseTheme(localStorage.getItem(storageKey), defaultTheme);
} catch {
return defaultTheme;
}
});

useEffect(() => {
const root = window.document.documentElement;
root.classList.remove('light', 'dark');

if (theme === 'system') {
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light';
root.classList.add(systemTheme);
} else {
root.classList.add(theme);
}
}, [theme]);

// Listen for system theme changes when in 'system' mode
useEffect(() => {
if (theme !== 'system') return;
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handler = () => {
const root = window.document.documentElement;
root.classList.remove('light', 'dark');
root.classList.add(mediaQuery.matches ? 'dark' : 'light');
};
mediaQuery.addEventListener('change', handler);
return () => mediaQuery.removeEventListener('change', handler);
}, [theme]);

const value = useMemo(() => ({
theme,
setTheme: (newTheme: Theme) => {
try {
localStorage.setItem(storageKey, newTheme);
} catch {
// localStorage unavailable, continue without persistence
}
setTheme(newTheme);
},
}), [theme, storageKey]);

return (
<ThemeProviderContext.Provider value={value}>
{children}
</ThemeProviderContext.Provider>
);
}

export const useTheme = () => {
const context = useContext(ThemeProviderContext);
if (context === undefined) {
throw new Error('useTheme must be used within a ThemeProvider');
}
return context;
};
Loading