Skip to content
Merged
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
121 changes: 121 additions & 0 deletions src/__tests__/components/DashboardComponents.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ import { TransactionRow } from "@/components/dashboard/TransactionRow";
import { AccountSwitcher } from "@/components/dashboard/AccountSwitcher";
import { EmptyAccountsCard } from "@/components/dashboard/EmptyAccountsCard";
import { CategoryRingCard } from "@/components/dashboard/CategoryRingCard";
import { WeeklyActivityCard } from "@/components/dashboard/WeeklyActivityCard";
import { HIDDEN_AMOUNT } from "@/lib/format";

// Mock nativewind
jest.mock("nativewind", () => ({
Expand Down Expand Up @@ -95,6 +97,9 @@ describe("TransactionRow", () => {
amountCents: -15000,
occurredAt: Date.now() - 3600000,
accountId: "acc-1",
accountName: "BPI Savings",
accountLogoKey: null,
accountColorHex: "#C87B54",
categoryName: "Dining",
categoryIcon: "🍽",
categoryKind: "expense" as const,
Expand Down Expand Up @@ -126,6 +131,23 @@ describe("TransactionRow", () => {
const json = JSON.stringify(renderer.toJSON());
expect(json).toContain("5,000");
});

it("renders the account badge (initials fallback when no logo)", () => {
const renderer = render(<TransactionRow transaction={baseTx} />);
const json = JSON.stringify(renderer.toJSON());
// Initials of "BPI Savings" → "BS"
expect(json).toContain("BS");
// Badge fallback background color
expect(json).toContain("#C87B54");
});

it("renders the account logo image when a logoKey is present", () => {
const withLogo = { ...baseTx, accountLogoKey: "bank/bpi.png" };
const renderer = render(<TransactionRow transaction={withLogo} />);
// Bundled logo asset resolves via require — the image element renders
// without the initials fallback.
expect(JSON.stringify(renderer.toJSON())).not.toContain("BS");
});
});

// ─── AccountSwitcher ───────────────────────────────────────────────────────
Expand Down Expand Up @@ -211,3 +233,102 @@ describe("CategoryRingCard", () => {
expect(JSON.stringify(renderer.toJSON())).toContain("\u2014");
});
});

// ─── WeeklyActivityCard ─────────────────────────────────────────────────────

describe("WeeklyActivityCard", () => {
const earned = [
{
accountId: "acc-1",
accountName: "BPI",
accountLogoKey: "bank/bpi.png",
accountColorHex: "#C87B54",
amountCents: 30000,
},
];
const spent = [
{
accountId: "acc-2",
accountName: "Wallet",
accountLogoKey: null,
accountColorHex: "#7FA06B",
amountCents: 10000,
},
{
accountId: "acc-3",
accountName: "GCash",
accountLogoKey: "bank/gcash.png",
accountColorHex: "#6B8D58",
amountCents: 5000,
},
];

it("shows earned and spent totals by default", () => {
const renderer = render(
<WeeklyActivityCard earned={earned} spent={spent} />,
);
const json = JSON.stringify(renderer.toJSON());
expect(json).toContain("Earned");
expect(json).toContain("Spent");
expect(json).toContain("₱300.00");
expect(json).toContain("₱150.00");
});

it("lists accounts with badges and amounts when the spent arc is tapped", () => {
const renderer = render(
<WeeklyActivityCard earned={earned} spent={spent} />,
);
act(() => {
renderer.root.findByProps({ testID: "segment-spent" }).props.onPress();
});
const json = JSON.stringify(renderer.toJSON());
expect(json).toContain("Spent by account");
expect(json).toContain("Wallet");
expect(json).toContain("GCash");
expect(json).toContain("₱100.00");
expect(json).toContain("₱50.00");
expect(json).not.toContain("Earned");
});

it("lists accounts when the earned arc is tapped", () => {
const renderer = render(
<WeeklyActivityCard earned={earned} spent={spent} />,
);
act(() => {
renderer.root.findByProps({ testID: "segment-earned" }).props.onPress();
});
const json = JSON.stringify(renderer.toJSON());
expect(json).toContain("Earned by account");
expect(json).toContain("BPI");
expect(json).toContain("₱300.00");
});

it("tapping the same segment again returns to the totals", () => {
const renderer = render(
<WeeklyActivityCard earned={earned} spent={spent} />,
);
act(() => {
renderer.root.findByProps({ testID: "segment-earned" }).props.onPress();
});
act(() => {
renderer.root.findByProps({ testID: "segment-earned" }).props.onPress();
});
const json = JSON.stringify(renderer.toJSON());
expect(json).toContain("Earned");
expect(json).toContain("Spent");
});

it("masks amounts when hidden", () => {
const renderer = render(
<WeeklyActivityCard earned={earned} spent={spent} hidden />,
);
expect(JSON.stringify(renderer.toJSON())).toContain(HIDDEN_AMOUNT);
});

it("shows the empty state when there is no activity", () => {
const renderer = render(<WeeklyActivityCard earned={[]} spent={[]} />);
const json = JSON.stringify(renderer.toJSON());
expect(json).toContain("No activity");
expect(json).toContain("₱0.00");
});
});
107 changes: 107 additions & 0 deletions src/__tests__/repositories/transactions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ import type { MonthRange } from "@/types";
import { mockDbInstance } from "@/__tests__/setup";
import {
deleteTransaction,
getExpenseByAccount,
getIncomeByAccount,
insertTransaction,
listRecentTransactions,
listAllTransactions,
Expand Down Expand Up @@ -112,6 +114,9 @@ describe("listRecentTransactions", () => {
amount_cents: -15000,
occurred_at: 1700000000000,
account_id: "acc-1",
account_name: "BPI Savings",
account_logo_key: "bank/bpi.png",
account_color_hex: "#C87B54",
category_name: "Dining",
category_icon: "🍽",
category_kind: "expense",
Expand All @@ -124,6 +129,9 @@ describe("listRecentTransactions", () => {
expect(txs[0].merchant).toBe("Starbucks");
expect(txs[0].amountCents).toBe(-15000);
expect(txs[0].categoryName).toBe("Dining");
expect(txs[0].accountName).toBe("BPI Savings");
expect(txs[0].accountLogoKey).toBe("bank/bpi.png");
expect(txs[0].accountColorHex).toBe("#C87B54");
});

it("filters by accountId when provided", async () => {
Expand Down Expand Up @@ -233,3 +241,102 @@ describe("listAllTransactions", () => {
expect(txs[0].note).toBe("My note");
});
});

describe("getExpenseByAccount", () => {
it("groups expense by account and maps badge data", async () => {
mockDbInstance.execute.mockResolvedValue({
rows: [
{
account_id: "acc-1",
account_name: "BPI",
account_logo_key: "bank/bpi.png",
account_color_hex: "#C87B54",
total_cents: 25000,
},
{
account_id: "acc-2",
account_name: "Wallet",
account_logo_key: null,
account_color_hex: "#7FA06B",
total_cents: 5000,
},
],
});

const range: MonthRange = { start: 1000, end: 2000 };
const slices = await getExpenseByAccount(range);

expect(slices).toHaveLength(2);
expect(slices[0]).toEqual({
accountId: "acc-1",
accountName: "BPI",
accountLogoKey: "bank/bpi.png",
accountColorHex: "#C87B54",
amountCents: 25000,
});
expect(slices[1].accountLogoKey).toBeNull();

const sql = mockDbInstance.execute.mock.calls[0][0] as string;
expect(sql).toContain("SUM(-t.amount_cents)");
expect(sql).toContain("GROUP BY a.id");
expect(sql).toContain("t.amount_cents < 0");
});

it("scopes to a single account when accountId is provided", async () => {
mockDbInstance.execute.mockResolvedValue({ rows: [] });

const range: MonthRange = { start: 1000, end: 2000 };
await getExpenseByAccount(range, "acc-1");

const params = mockDbInstance.execute.mock.calls[0][1] as (
string | number
)[];
expect(params).toContain(1000);
expect(params).toContain(2000);
expect(params).toContain("acc-1");
});
});

describe("getIncomeByAccount", () => {
it("returns income totals grouped by account", async () => {
mockDbInstance.execute.mockResolvedValue({
rows: [
{
account_id: "acc-1",
account_name: "BPI",
account_logo_key: null,
account_color_hex: "#C87B54",
total_cents: 42000,
},
],
});

const range: MonthRange = { start: 1000, end: 2000 };
const slices = await getIncomeByAccount(range);

expect(slices).toHaveLength(1);
expect(slices[0]).toEqual({
accountId: "acc-1",
accountName: "BPI",
accountLogoKey: null,
accountColorHex: "#C87B54",
amountCents: 42000,
});

const sql = mockDbInstance.execute.mock.calls[0][0] as string;
expect(sql).toContain("SUM(t.amount_cents)");
expect(sql).toContain("t.amount_cents > 0");
});

it("scopes to a single account when accountId is provided", async () => {
mockDbInstance.execute.mockResolvedValue({ rows: [] });

const range: MonthRange = { start: 1000, end: 2000 };
await getIncomeByAccount(range, "acc-1");

const params = mockDbInstance.execute.mock.calls[0][1] as (
string | number
)[];
expect(params).toContain("acc-1");
});
});
61 changes: 34 additions & 27 deletions src/app/(app)/dashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,8 @@ export default function DashboardScreen() {
categories,
totalExpenseCents,
totalIncomeCents,
dailyTotals,
spentByAccount,
earnedByAccount,
recentTransactions,
} = state.data;

Expand Down Expand Up @@ -206,7 +207,14 @@ export default function DashboardScreen() {

{hasAccounts ? (
<>
{/* ── This month: expense & income card ── */}
<Animated.View entering={FadeInDown.delay(200).duration(450)}>
<AccountSwitcher
accounts={accounts}
selectedAccountId={selectedAccountId}
onSelect={setSelectedAccountId}
/>
</Animated.View>

<Animated.View entering={FadeInDown.delay(140).duration(450)}>
<View
className="mb-5 rounded-2xl border p-5"
Expand All @@ -215,6 +223,27 @@ export default function DashboardScreen() {
borderColor: colors.hairline,
}}
>
<Text
className="mb-1.5 text-[13px] "
style={{
color: colors.textSecondary,
}}
>
{selectedAccount
? `${selectedAccount.name} balance`
: "Total balance"}
</Text>
<AnimatedBalance
cents={totalBalanceCents}
hidden={amountsHidden}
className="font-fraunces-medium text-[48px] leading-[52px] text-text-primary"
/>

{/* ── This month: expense & income (same card) ── */}
<View
className="my-4 h-px"
style={{ backgroundColor: colors.hairline }}
/>
<Text
className="font-mono text-[10.5px] uppercase tracking-[0.08em]"
style={{ color: colors.textSecondary }}
Expand Down Expand Up @@ -258,36 +287,14 @@ export default function DashboardScreen() {
</View>
</Animated.View>

<Animated.View entering={FadeInDown.delay(200).duration(450)}>
<AccountSwitcher
accounts={accounts}
selectedAccountId={selectedAccountId}
onSelect={setSelectedAccountId}
/>
</Animated.View>

<Animated.View entering={FadeInDown.delay(260).duration(450)}>
<Text
className="mb-1.5 text-[13px] "
style={{
color: colors.textSecondary,
}}
>
{selectedAccount
? `${selectedAccount.name} balance`
: "Total balance"}
</Text>
<AnimatedBalance
cents={totalBalanceCents}
<WeeklyActivityCard
earned={earnedByAccount}
spent={spentByAccount}
hidden={amountsHidden}
className="mb-[26px] font-fraunces-medium text-[48px] leading-[52px] text-text-primary"
/>
</Animated.View>

<Animated.View entering={FadeInDown.delay(320).duration(450)}>
<WeeklyActivityCard dailyTotals={dailyTotals} />
</Animated.View>

{categories.length > 0 && (
<ScrollView
horizontal
Expand Down
Loading