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
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,10 @@
.tr:last-child .td { border-bottom: none; }
.catBadge { display: inline-flex; padding: 2px 8px; border-radius: var(--radius-full); font-size: 11px; font-weight: 600; background: var(--color-bg-elevated); color: var(--color-text-muted); }
.amount { font-family: var(--family-mono); font-weight: 700; color: var(--color-text); }

/* Sortable header button — inherits the .th typography, adds a click target. */
.sortButton { display: inline-flex; align-items: center; gap: 4px; padding: 0; border: none; background: none; cursor: pointer; font: inherit; color: inherit; text-transform: inherit; letter-spacing: inherit; }
.sortButton:hover { color: var(--color-text-muted); }
.sortButton:focus-visible { outline: 2px solid var(--color-accent, var(--color-text)); outline-offset: 2px; border-radius: var(--radius-sm); }
.sortIcon { color: var(--color-text); }
.sortIconInactive { color: var(--color-text-subtle); opacity: 0.5; }
69 changes: 66 additions & 3 deletions app/blocks/expenses-tracker/one-time-expenses-table.tsx
Original file line number Diff line number Diff line change
@@ -1,25 +1,88 @@
import { useMemo, useState } from "react";
import { IconChevronUp, IconChevronDown, IconSelector } from "@tabler/icons-react";
import styles from "./one-time-expenses-table.module.css";

interface Props { className?: string; expenses?: any[]; }

// Only these columns are sortable (per issue #33).
type SortKey = "date" | "amount";
type SortDir = "asc" | "desc";

export function OneTimeExpensesTable({ className, expenses = [] }: Props) {
// Default to date descending so the initial view matches the loader's
// `orderBy: { date: "desc" }` ordering — nothing jumps on first render.
const [sortKey, setSortKey] = useState<SortKey>("date");
const [sortDir, setSortDir] = useState<SortDir>("desc");

function handleSort(key: SortKey) {
if (key === sortKey) {
// Same column clicked again → flip direction.
setSortDir(prev => (prev === "asc" ? "desc" : "asc"));
} else {
// New column → select it, start ascending.
setSortKey(key);
setSortDir("asc");
}
}

const sortedExpenses = useMemo(() => {
// Copy first — never mutate the prop array in place.
return [...expenses].sort((a, b) => {
let comparison = 0;

if (sortKey === "amount") {
// `amount` is a Prisma Decimal serialized as a string.
comparison = Number(a.amount) - Number(b.amount);
} else {
// `date` is an ISO date string.
comparison = new Date(a.date).getTime() - new Date(b.date).getTime();
}

return sortDir === "asc" ? comparison : -comparison;
});
}, [expenses, sortKey, sortDir]);

if (expenses.length === 0) {
return <div className={[styles.wrap, className].filter(Boolean).join(" ")}><p style={{padding: '1rem', color: 'var(--color-text-secondary)'}}>No one-time expenses logged yet.</p></div>;
}

// Renders a clickable header with the correct sort indicator + aria-sort.
function SortableHeader({ label, columnKey }: { label: string; columnKey: SortKey }) {
const isActive = sortKey === columnKey;
return (
<th
className={styles.th}
aria-sort={isActive ? (sortDir === "asc" ? "ascending" : "descending") : "none"}
>
<button
type="button"
className={styles.sortButton}
onClick={() => handleSort(columnKey)}
>
<span>{label}</span>
{isActive
? (sortDir === "asc"
? <IconChevronUp size={14} className={styles.sortIcon} />
: <IconChevronDown size={14} className={styles.sortIcon} />)
: <IconSelector size={14} className={styles.sortIconInactive} />}
</button>
</th>
);
}

return (
<div className={[styles.wrap, className].filter(Boolean).join(" ")}>
<table className={styles.table}>
<thead>
<tr>
<th className={styles.th}>Date</th>
<SortableHeader label="Date" columnKey="date" />
<th className={styles.th}>Description</th>
<th className={styles.th}>Category</th>
<th className={styles.th}>Amount</th>
<SortableHeader label="Amount" columnKey="amount" />
</tr>
</thead>
<tbody>
{expenses.map(e => (
{sortedExpenses.map(e => (
<tr key={e.id} className={styles.tr}>
<td className={styles.td}>{new Date(e.date).toLocaleDateString()}</td>
<td className={styles.td}>{e.description || "—"}</td>
Expand Down
62 changes: 25 additions & 37 deletions app/routes/expenses-tracker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,17 +18,22 @@ export async function loader({ request }: Route.LoaderArgs) {
const { supabase } = getSupabaseServerClient(request);
const { data: { user } } = await supabase.auth.getUser();

if (!user) return { expenses: [], recurring: [], totalPages: 0, oneTimeTotal: 0 };
if (!user) return { expenses: [], recurring: [], totalPages: 0, oneTimeTotal: 0, sort: "date", dir: "desc" };

const url = new URL(request.url);
const page = Math.max(1, Number(url.searchParams.get("page")) || 1);
const pageSize = Number(url.searchParams.get("pageSize")) || 10;

const allowedSort = ["date", "amount", "description", "type"];
const sortParam = url.searchParams.get("sort") || "date";
const sort = allowedSort.includes(sortParam) ? sortParam : "date";
const dir = url.searchParams.get("dir") === "asc" ? "asc" : "desc";

const [totalExpenses, expenses, recurring, sumResult] = await Promise.all([
prisma.expense.count({ where: { userId: user.id } }),
prisma.expense.findMany({
where: { userId: user.id },
orderBy: { date: "desc" },
orderBy: { [sort]: dir },
skip: (page - 1) * pageSize,
take: pageSize,
}),
Expand All @@ -46,7 +51,9 @@ export async function loader({ request }: Route.LoaderArgs) {
expenses,
recurring,
totalPages: Math.ceil(totalExpenses / pageSize),
oneTimeTotal: Number(sumResult._sum.amount || 0)
oneTimeTotal: Number(sumResult._sum.amount || 0),
sort,
dir,
};
}

Expand All @@ -59,22 +66,16 @@ export async function action({ request }: Route.ActionArgs) {
const intent = formData.get("intent");

if (intent === "toggle") {
const id = formData.get("id") as string;
const isActive = formData.get("isActive") === "true";

await prisma.recurringExpense.updateMany({
where: {
id,
userId:user.id
},
data: {
isActive,
},
});
return {ok: true,intent};


}
const id = formData.get("id") as string;
const isActive = formData.get("isActive") === "true";

await prisma.recurringExpense.updateMany({
where: { id, userId: user.id },
data: { isActive },
});
return { ok: true, intent };
}

if (intent === "create") {
const isRecurring = formData.get("isRecurring") === "on";
const type = formData.get("type") as any;
Expand All @@ -85,24 +86,11 @@ export async function action({ request }: Route.ActionArgs) {
if (isRecurring) {
const dayOfMonth = Number(formData.get("dayOfMonth")) || 1;
await prisma.recurringExpense.create({
data: {
userId: user.id,
type,
amount,
description,
dayOfMonth,
isActive: true
}
data: { userId: user.id, type, amount, description, dayOfMonth, isActive: true }
});
} else {
await prisma.expense.create({
data: {
userId: user.id,
type,
amount,
description,
date,
}
data: { userId: user.id, type, amount, description, date }
});
}
}
Expand All @@ -111,7 +99,7 @@ export async function action({ request }: Route.ActionArgs) {
}

export default function ExpensesTrackerPage() {
const { expenses, recurring, totalPages, oneTimeTotal } = useLoaderData<typeof loader>();
const { expenses, recurring, totalPages, oneTimeTotal, sort, dir } = useLoaderData<typeof loader>();
const actionData = useActionData<typeof action>();
const [showAddExpense, setShowAddExpense] = useState(false);

Expand All @@ -123,13 +111,13 @@ export default function ExpensesTrackerPage() {
}
}
}, [actionData]);

return (
<div className={styles.page}>
<ExpensesHeader onAddExpense={() => setShowAddExpense(true)} />
<ExpensesSummary expenses={expenses} recurring={recurring} oneTimeTotal={oneTimeTotal} />
<RecurringExpensesSection recurring={recurring} />
<OneTimeExpensesTable expenses={expenses} />
<OneTimeExpensesTable expenses={expenses} sort={sort} dir={dir} />
<Pagination totalPages={totalPages} />
{showAddExpense && <AddExpenseModal onClose={() => setShowAddExpense(false)} />}
</div>
Expand Down
Loading