Compare commits

7 Commits
python ... main

Author SHA1 Message Date
jerick
60fc0cd8df Update package-lock.json for next-themes
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 18:11:26 +00:00
jerick
4809edf73a Add dark mode toggle via next-themes
Uses next-themes with system default. Toggle button in sidebar
switches between light/dark and persists to localStorage.
CSS variables for dark mode were already defined in globals.css.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 18:10:22 +00:00
jerick
4a0f036d01 Add duplicate pattern check when adding budget rules
Client side catches it immediately (case-insensitive match against
existing rules) and shows an inline error. API also rejects with 409
as the authoritative guard.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 18:01:45 +00:00
jerick
0014db3aea Make budget rules list scrollable when it overflows
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 17:57:14 +00:00
jerick
73e8f51936 Fix transaction pagination resetting to page 1 on Next click
The search debounce useEffect had `push` in its deps. Since useRouter()
returns a new instance each render, `push` was recreated on every
navigation, re-triggering the effect which called push({ search })
after 400ms — always deleting the page param and snapping back to 1.

Replace the useEffect debounce with a ref-based timer in the handler
so the debounce only fires when the user actually types.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 17:46:54 +00:00
jerick
344a4b8a46 Fix Discover CC invertAmountSign: positive CSV amount = DEBIT (charge)
The prior fix had it backwards — invertAmountSign: true made positive
amounts CREDIT, so charges were stored with the wrong type. Discover CC
positive amounts are charges (money out), which should be DEBIT.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 17:33:43 +00:00
jerick
dc45f489a6 Fix budget spend showing negative when TRANSFER transactions assigned
TRANSFER type fell into the ELSE branch of the spend CASE expression,
treating transfers as negative spend like refunds. Explicitly handle
each type: DEBIT=positive, CREDIT=negative, TRANSFER=0.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 17:25:42 +00:00
11 changed files with 79 additions and 24 deletions

11
package-lock.json generated
View File

@@ -17,6 +17,7 @@
"lucide-react": "^1.8.0",
"next": "16.2.4",
"next-auth": "^5.0.0-beta.31",
"next-themes": "^0.4.6",
"papaparse": "^5.5.3",
"pg": "^8.20.0",
"react": "19.2.4",
@@ -8831,6 +8832,16 @@
}
}
},
"node_modules/next-themes": {
"version": "0.4.6",
"resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.6.tgz",
"integrity": "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc"
}
},
"node_modules/next/node_modules/postcss": {
"version": "8.4.31",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",

View File

@@ -18,6 +18,7 @@
"lucide-react": "^1.8.0",
"next": "16.2.4",
"next-auth": "^5.0.0-beta.31",
"next-themes": "^0.4.6",
"papaparse": "^5.5.3",
"pg": "^8.20.0",
"react": "19.2.4",

View File

@@ -27,7 +27,7 @@ export default async function BudgetsPage({ searchParams }: { searchParams: Sear
}),
prisma.$queryRaw<{ budgetId: string; total: bigint }[]>`
SELECT t."budgetId",
COALESCE(SUM(CASE WHEN t.type = 'DEBIT' THEN t."amountCents" ELSE -t."amountCents" END), 0)::bigint AS total
COALESCE(SUM(CASE WHEN t.type = 'DEBIT' THEN t."amountCents" WHEN t.type = 'CREDIT' THEN -t."amountCents" ELSE 0 END), 0)::bigint AS total
FROM "Transaction" t
JOIN "Account" a ON t."accountId" = a.id
WHERE a."userId" = ${userId}

View File

@@ -43,7 +43,7 @@ export default async function DashboardPage({ searchParams }: { searchParams: Se
}),
prisma.$queryRaw<{ budgetId: string; total: bigint }[]>`
SELECT t."budgetId",
COALESCE(SUM(CASE WHEN t.type = 'DEBIT' THEN t."amountCents" ELSE -t."amountCents" END), 0)::bigint AS total
COALESCE(SUM(CASE WHEN t.type = 'DEBIT' THEN t."amountCents" WHEN t.type = 'CREDIT' THEN -t."amountCents" ELSE 0 END), 0)::bigint AS total
FROM "Transaction" t
JOIN "Account" a ON t."accountId" = a.id
WHERE a."userId" = ${userId}

View File

@@ -29,6 +29,11 @@ export async function POST(req: Request) {
const budget = await prisma.budget.findFirst({ where: { id: budgetId, userId: session.user.id } })
if (!budget) return NextResponse.json({ error: 'Budget not found' }, { status: 404 })
const existing = await prisma.budgetRule.findFirst({
where: { budgetId, pattern: { equals: pattern, mode: 'insensitive' } },
})
if (existing) return NextResponse.json({ error: 'Duplicate rule pattern' }, { status: 409 })
const rule = await prisma.budgetRule.create({
data: { userId: session.user.id, budgetId, pattern },
select: { id: true, budgetId: true, pattern: true },

View File

@@ -1,6 +1,7 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { ThemeProvider } from "@/components/layout/ThemeProvider";
const geistSans = Geist({
variable: "--font-geist-sans",
@@ -27,7 +28,9 @@ export default function RootLayout({
lang="en"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
>
<body className="min-h-full flex flex-col">{children}</body>
<body className="min-h-full flex flex-col">
<ThemeProvider>{children}</ThemeProvider>
</body>
</html>
);
}

View File

@@ -24,10 +24,14 @@ export function BudgetRulesDialog({ open, onOpenChange, budgetId, budgetName, ru
const router = useRouter()
const [pattern, setPattern] = useState('')
const [adding, setAdding] = useState(false)
const [dupError, setDupError] = useState(false)
async function handleAdd(e: React.FormEvent) {
e.preventDefault()
if (!pattern.trim()) return
const isDup = rules.some((r) => r.pattern.toLowerCase() === pattern.trim().toLowerCase())
if (isDup) { setDupError(true); return }
setDupError(false)
setAdding(true)
await fetch('/api/budget-rules', {
method: 'POST',
@@ -36,6 +40,7 @@ export function BudgetRulesDialog({ open, onOpenChange, budgetId, budgetName, ru
})
setPattern('')
setAdding(false)
setDupError(false)
router.refresh()
}
@@ -59,7 +64,7 @@ export function BudgetRulesDialog({ open, onOpenChange, budgetId, budgetName, ru
{rules.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-2">No rules yet.</p>
) : (
<ul className="space-y-1.5">
<ul className="space-y-1.5 max-h-64 overflow-y-auto pr-1">
{rules.map((rule) => (
<li
key={rule.id}
@@ -78,17 +83,22 @@ export function BudgetRulesDialog({ open, onOpenChange, budgetId, budgetName, ru
</ul>
)}
<form onSubmit={handleAdd} className="flex gap-2">
<Input
placeholder="e.g. Amazon, Netflix, Starbucks"
value={pattern}
onChange={(e) => setPattern(e.target.value)}
className="flex-1"
/>
<Button type="submit" disabled={adding || !pattern.trim()} size="sm">
<Plus className="h-4 w-4 mr-1" />
Add
</Button>
<form onSubmit={handleAdd} className="space-y-1.5">
<div className="flex gap-2">
<Input
placeholder="e.g. Amazon, Netflix, Starbucks"
value={pattern}
onChange={(e) => { setPattern(e.target.value); setDupError(false) }}
className={dupError ? 'flex-1 border-destructive focus-visible:ring-destructive' : 'flex-1'}
/>
<Button type="submit" disabled={adding || !pattern.trim()} size="sm">
<Plus className="h-4 w-4 mr-1" />
Add
</Button>
</div>
{dupError && (
<p className="text-xs text-destructive">That pattern already exists for this budget.</p>
)}
</form>
</DialogContent>
</Dialog>

View File

@@ -3,10 +3,11 @@
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { signOut } from 'next-auth/react'
import { useTheme } from 'next-themes'
import { cn } from '@/lib/utils'
import {
LayoutDashboard, CreditCard, ArrowLeftRight,
Upload, PiggyBank, TrendingUp, LogOut,
Upload, PiggyBank, TrendingUp, LogOut, Sun, Moon,
} from 'lucide-react'
import { Separator } from '@/components/ui/separator'
@@ -21,6 +22,7 @@ const navItems = [
export function Sidebar() {
const pathname = usePathname()
const { resolvedTheme, setTheme } = useTheme()
return (
<aside className="flex w-56 shrink-0 flex-col border-r bg-card">
@@ -46,7 +48,15 @@ export function Sidebar() {
))}
</nav>
<Separator />
<div className="p-2">
<div className="p-2 space-y-1">
<button
onClick={() => setTheme(resolvedTheme === 'dark' ? 'light' : 'dark')}
className="flex w-full items-center gap-3 rounded-md px-3 py-2 text-sm font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
>
{resolvedTheme === 'dark'
? <><Sun className="h-4 w-4 shrink-0" />Light mode</>
: <><Moon className="h-4 w-4 shrink-0" />Dark mode</>}
</button>
<button
onClick={() => signOut({ callbackUrl: '/login' })}
className="flex w-full items-center gap-3 rounded-md px-3 py-2 text-sm font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"

View File

@@ -0,0 +1,11 @@
'use client'
import { ThemeProvider as NextThemesProvider } from 'next-themes'
export function ThemeProvider({ children }: { children: React.ReactNode }) {
return (
<NextThemesProvider attribute="class" defaultTheme="system" enableSystem disableTransitionOnChange>
{children}
</NextThemesProvider>
)
}

View File

@@ -35,11 +35,15 @@ export function TransactionFilters({ accounts }: { accounts: AccountOption[] })
[pathname, router],
)
// Debounce search → URL
useEffect(() => {
const t = setTimeout(() => push({ search }), 400)
return () => clearTimeout(t)
}, [search, push])
const pushRef = useRef(push)
useEffect(() => { pushRef.current = push }, [push])
const searchTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
function handleSearchChange(value: string) {
setSearch(value)
if (searchTimer.current) clearTimeout(searchTimer.current)
searchTimer.current = setTimeout(() => pushRef.current({ search: value }), 400)
}
function reset() {
setSearch('')
@@ -107,7 +111,7 @@ export function TransactionFilters({ accounts }: { accounts: AccountOption[] })
className="h-8 text-sm"
placeholder="Search descriptions…"
value={search}
onChange={(e) => setSearch(e.target.value)}
onChange={(e) => handleSearchChange(e.target.value)}
/>
</div>

View File

@@ -40,7 +40,7 @@ export const bankProfiles: BankProfile[] = [
dateColumn: 'Trans. Date',
descriptionColumn: 'Description',
amountColumn: 'Amount',
invertAmountSign: true, // negative = DEBIT (charge), positive = CREDIT (payment/refund)
invertAmountSign: false, // positive = DEBIT (charge), negative = CREDIT (payment/refund)
categoryColumn: 'Category',
detectColumns: ['Trans. Date', 'Post Date', 'Description', 'Amount', 'Category'],
},