Fix month-browsable expense history
This commit is contained in:
@@ -18,6 +18,13 @@ The system SHALL allow the user to list recorded expenses and delete a specific
|
|||||||
- **WHEN** the user requests expenses for the app
|
- **WHEN** the user requests expenses for the app
|
||||||
- **THEN** the system returns stored expenses in a stable order with their recorded fields
|
- **THEN** the system returns stored expenses in a stable order with their recorded fields
|
||||||
|
|
||||||
|
### Requirement: User can browse expense history by month
|
||||||
|
The system SHALL allow the user to select a `YYYY-MM` month when reviewing expense history and SHALL return the expenses recorded for that month.
|
||||||
|
|
||||||
|
#### Scenario: Prior month entries are visible
|
||||||
|
- **WHEN** the user selects February 2026 in the add-expense history view
|
||||||
|
- **THEN** the system shows the expenses recorded in February 2026 and exposes delete actions for deletable entries in that month
|
||||||
|
|
||||||
#### Scenario: Expense is deleted
|
#### Scenario: Expense is deleted
|
||||||
- **WHEN** the user deletes an existing expense
|
- **WHEN** the user deletes an existing expense
|
||||||
- **THEN** the system removes that expense and it no longer appears in future listings or aggregates
|
- **THEN** the system removes that expense and it no longer appears in future listings or aggregates
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useMemo, useState, type FormEvent } from "react";
|
import { useCallback, useEffect, useMemo, useState, type FormEvent } from "react";
|
||||||
|
|
||||||
import { getCategoryLabel, type CategoryValue } from "@/lib/categories";
|
import { getCategoryLabel, type CategoryValue } from "@/lib/categories";
|
||||||
import { getCurrentMonthKey, getLocalToday } from "@/lib/date";
|
import { getCurrentMonthKey, getLocalToday, getMonthLabel } from "@/lib/date";
|
||||||
import { formatCurrencyFromCents } from "@/lib/money";
|
import { formatCurrencyFromCents } from "@/lib/money";
|
||||||
|
|
||||||
type SuggestionResponse = {
|
type SuggestionResponse = {
|
||||||
@@ -34,6 +34,7 @@ type Props = {
|
|||||||
|
|
||||||
export function ExpenseWorkspace({ categoryOptions }: Props) {
|
export function ExpenseWorkspace({ categoryOptions }: Props) {
|
||||||
const [expenses, setExpenses] = useState<ExpenseRecord[]>([]);
|
const [expenses, setExpenses] = useState<ExpenseRecord[]>([]);
|
||||||
|
const [selectedMonth, setSelectedMonth] = useState("");
|
||||||
const [formState, setFormState] = useState<{
|
const [formState, setFormState] = useState<{
|
||||||
title: string;
|
title: string;
|
||||||
amount: string;
|
amount: string;
|
||||||
@@ -53,23 +54,33 @@ export function ExpenseWorkspace({ categoryOptions }: Props) {
|
|||||||
const [lastSuggestedMerchant, setLastSuggestedMerchant] = useState("");
|
const [lastSuggestedMerchant, setLastSuggestedMerchant] = useState("");
|
||||||
const [suggestedCategory, setSuggestedCategory] = useState<CategoryValue | null>(null);
|
const [suggestedCategory, setSuggestedCategory] = useState<CategoryValue | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
const loadExpenses = useCallback(async (month: string) => {
|
||||||
async function loadExpenses() {
|
|
||||||
const month = getCurrentMonthKey();
|
|
||||||
const response = await fetch(`/expenses?month=${month}`, { cache: "no-store" });
|
const response = await fetch(`/expenses?month=${month}`, { cache: "no-store" });
|
||||||
const payload = (await response.json().catch(() => null)) as { expenses?: ExpenseRecord[] } | null;
|
const payload = (await response.json().catch(() => null)) as { expenses?: ExpenseRecord[] } | null;
|
||||||
setExpenses(payload?.expenses ?? []);
|
setExpenses(payload?.expenses ?? []);
|
||||||
}
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
const timeoutId = window.setTimeout(() => {
|
const timeoutId = window.setTimeout(() => {
|
||||||
|
setSelectedMonth(getCurrentMonthKey());
|
||||||
setFormState((current) => (current.date ? current : { ...current, date: getLocalToday() }));
|
setFormState((current) => (current.date ? current : { ...current, date: getLocalToday() }));
|
||||||
}, 0);
|
}, 0);
|
||||||
|
|
||||||
void loadExpenses();
|
|
||||||
|
|
||||||
return () => window.clearTimeout(timeoutId);
|
return () => window.clearTimeout(timeoutId);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedMonth) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeoutId = window.setTimeout(() => {
|
||||||
|
void loadExpenses(selectedMonth);
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
return () => window.clearTimeout(timeoutId);
|
||||||
|
}, [loadExpenses, selectedMonth]);
|
||||||
|
|
||||||
const totalSpent = useMemo(
|
const totalSpent = useMemo(
|
||||||
() => expenses.reduce((sum, expense) => sum + expense.amountCents, 0),
|
() => expenses.reduce((sum, expense) => sum + expense.amountCents, 0),
|
||||||
[expenses],
|
[expenses],
|
||||||
@@ -188,6 +199,10 @@ export function ExpenseWorkspace({ categoryOptions }: Props) {
|
|||||||
setExpenses((current) => [payload.expense, ...current]);
|
setExpenses((current) => [payload.expense, ...current]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (selectedMonth) {
|
||||||
|
await loadExpenses(selectedMonth);
|
||||||
|
}
|
||||||
|
|
||||||
setFormState((current) => ({ ...current, title: "", amount: "" }));
|
setFormState((current) => ({ ...current, title: "", amount: "" }));
|
||||||
setSuggestionMessage(null);
|
setSuggestionMessage(null);
|
||||||
setNeedsSuggestionConfirmation(false);
|
setNeedsSuggestionConfirmation(false);
|
||||||
@@ -208,6 +223,11 @@ export function ExpenseWorkspace({ categoryOptions }: Props) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (selectedMonth) {
|
||||||
|
await loadExpenses(selectedMonth);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setExpenses((current) => current.filter((expense) => expense.id !== id));
|
setExpenses((current) => current.filter((expense) => expense.id !== id));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -327,15 +347,31 @@ export function ExpenseWorkspace({ categoryOptions }: Props) {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="rounded-[2rem] border border-stone-200 bg-[#fffaf2] p-6 shadow-[0_24px_60px_rgba(120,90,50,0.08)] dark:border-stone-700 dark:bg-stone-900/60">
|
<section className="rounded-[2rem] border border-stone-200 bg-[#fffaf2] p-6 shadow-[0_24px_60px_rgba(120,90,50,0.08)] dark:border-stone-700 dark:bg-stone-900/60">
|
||||||
<div className="mb-5">
|
<div className="mb-5 flex flex-wrap items-end justify-between gap-4">
|
||||||
|
<div>
|
||||||
<p className="text-sm font-semibold uppercase tracking-[0.24em] text-stone-500 dark:text-stone-400">Recent entries</p>
|
<p className="text-sm font-semibold uppercase tracking-[0.24em] text-stone-500 dark:text-stone-400">Recent entries</p>
|
||||||
<h2 className="mt-2 text-2xl font-semibold text-stone-950 dark:text-white">Expense history</h2>
|
<h2 className="mt-2 text-2xl font-semibold text-stone-950 dark:text-white">Expense history</h2>
|
||||||
|
{selectedMonth ? (
|
||||||
|
<p className="mt-2 text-sm text-stone-600 dark:text-stone-400">
|
||||||
|
Showing {getMonthLabel(selectedMonth)} entries.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<label className="grid gap-2 text-sm font-medium text-stone-700 dark:text-stone-300">
|
||||||
|
Month
|
||||||
|
<input
|
||||||
|
type="month"
|
||||||
|
value={selectedMonth}
|
||||||
|
onChange={(event) => setSelectedMonth(event.target.value)}
|
||||||
|
className="rounded-2xl border border-stone-300 bg-stone-50 px-3 py-2 text-sm font-medium text-stone-700 outline-none transition focus:border-stone-900 dark:border-stone-600 dark:bg-stone-800 dark:text-stone-300 dark:focus:border-stone-400"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{expenses.length === 0 ? (
|
{expenses.length === 0 ? (
|
||||||
<div className="rounded-3xl border border-dashed border-stone-300 px-4 py-6 text-sm text-stone-600 dark:border-stone-600 dark:text-stone-400">
|
<div className="rounded-3xl border border-dashed border-stone-300 px-4 py-6 text-sm text-stone-600 dark:border-stone-600 dark:text-stone-400">
|
||||||
No expenses yet. Add your first entry to start the month.
|
{selectedMonth ? `No expenses recorded for ${getMonthLabel(selectedMonth)} yet.` : "No expenses yet. Add your first entry to start the month."}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
expenses.map((expense) => (
|
expenses.map((expense) => (
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ vi.mock("@/lib/db", () => {
|
|||||||
db: {
|
db: {
|
||||||
expense: { findMany: vi.fn() },
|
expense: { findMany: vi.fn() },
|
||||||
paycheck: { findMany: vi.fn() },
|
paycheck: { findMany: vi.fn() },
|
||||||
|
recurringExpense: { findMany: vi.fn().mockResolvedValue([]) },
|
||||||
paySchedule: { findFirst: vi.fn().mockResolvedValue(null) },
|
paySchedule: { findFirst: vi.fn().mockResolvedValue(null) },
|
||||||
monthlyInsight,
|
monthlyInsight,
|
||||||
},
|
},
|
||||||
@@ -35,6 +36,7 @@ describe("generateMonthlyInsight", () => {
|
|||||||
|
|
||||||
vi.mocked(db.expense.findMany).mockResolvedValue([]);
|
vi.mocked(db.expense.findMany).mockResolvedValue([]);
|
||||||
vi.mocked(db.paycheck.findMany).mockResolvedValue([]);
|
vi.mocked(db.paycheck.findMany).mockResolvedValue([]);
|
||||||
|
vi.mocked(db.recurringExpense.findMany).mockResolvedValue([]);
|
||||||
|
|
||||||
const result = await generateMonthlyInsight("2026-03");
|
const result = await generateMonthlyInsight("2026-03");
|
||||||
|
|
||||||
@@ -72,6 +74,7 @@ describe("generateMonthlyInsight", () => {
|
|||||||
createdAt: new Date("2026-03-01T10:00:00.000Z"),
|
createdAt: new Date("2026-03-01T10:00:00.000Z"),
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
vi.mocked(db.recurringExpense.findMany).mockResolvedValue([]);
|
||||||
|
|
||||||
vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
@@ -120,6 +123,7 @@ describe("generateMonthlyInsight", () => {
|
|||||||
createdAt: new Date("2026-03-01T10:00:00.000Z"),
|
createdAt: new Date("2026-03-01T10:00:00.000Z"),
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
vi.mocked(db.recurringExpense.findMany).mockResolvedValue([]);
|
||||||
|
|
||||||
vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
|
|||||||
Reference in New Issue
Block a user