From 9a7ec5a4cf8512b94ae6fbd25cc9a58314c97776 Mon Sep 17 00:00:00 2001
From: Marjose Darang <18107626+MarJose123@users.noreply.github.com>
Date: Wed, 5 Aug 2026 11:19:20 +0800
Subject: [PATCH 1/2] feat: account badges, balance card, and interactive
activity pie chart
- Remove the color badge mode from add/edit account forms (Logo + Custom only)
- Show the account badge (logo/initials) on dashboard recent transactions
- Wrap the dashboard total balance in a card with this month's expense/income
- Replace the weekly bar chart with an interactive earned/spent donut pie;
tapping a segment lists the per-account breakdown (badge, name, amount)
- Add getIncomeByAccount / getExpenseByAccount repo queries with tests
---
.../components/DashboardComponents.test.tsx | 121 ++++++++
.../repositories/transactions.test.ts | 107 +++++++
src/app/(app)/dashboard.tsx | 61 ++--
src/app/add-account.tsx | 139 ++-------
src/app/edit-account.tsx | 150 ++--------
src/components/dashboard/TransactionRow.tsx | 78 ++++-
.../dashboard/WeeklyActivityCard.tsx | 275 +++++++++++++-----
src/hooks/useDashboardData.ts | 12 +-
src/lib/db/repositories/transactions.ts | 103 ++++++-
src/types/hooks.ts | 7 +-
src/types/transaction.ts | 19 ++
11 files changed, 721 insertions(+), 351 deletions(-)
diff --git a/src/__tests__/components/DashboardComponents.test.tsx b/src/__tests__/components/DashboardComponents.test.tsx
index d209c60..9c1e5e3 100644
--- a/src/__tests__/components/DashboardComponents.test.tsx
+++ b/src/__tests__/components/DashboardComponents.test.tsx
@@ -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", () => ({
@@ -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,
@@ -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();
+ 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();
+ // Bundled logo asset resolves via require — the image element renders
+ // without the initials fallback.
+ expect(JSON.stringify(renderer.toJSON())).not.toContain("BS");
+ });
});
// ─── AccountSwitcher ───────────────────────────────────────────────────────
@@ -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(
+ ,
+ );
+ 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(
+ ,
+ );
+ 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(
+ ,
+ );
+ 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(
+ ,
+ );
+ 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(
+ ,
+ );
+ expect(JSON.stringify(renderer.toJSON())).toContain(HIDDEN_AMOUNT);
+ });
+
+ it("shows the empty state when there is no activity", () => {
+ const renderer = render();
+ const json = JSON.stringify(renderer.toJSON());
+ expect(json).toContain("No activity");
+ expect(json).toContain("₱0.00");
+ });
+});
diff --git a/src/__tests__/repositories/transactions.test.ts b/src/__tests__/repositories/transactions.test.ts
index 8390c6f..8254cec 100644
--- a/src/__tests__/repositories/transactions.test.ts
+++ b/src/__tests__/repositories/transactions.test.ts
@@ -17,6 +17,8 @@ import type { MonthRange } from "@/types";
import { mockDbInstance } from "@/__tests__/setup";
import {
deleteTransaction,
+ getExpenseByAccount,
+ getIncomeByAccount,
insertTransaction,
listRecentTransactions,
listAllTransactions,
@@ -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",
@@ -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 () => {
@@ -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");
+ });
+});
diff --git a/src/app/(app)/dashboard.tsx b/src/app/(app)/dashboard.tsx
index 43fe26d..13c3f71 100644
--- a/src/app/(app)/dashboard.tsx
+++ b/src/app/(app)/dashboard.tsx
@@ -112,7 +112,8 @@ export default function DashboardScreen() {
categories,
totalExpenseCents,
totalIncomeCents,
- dailyTotals,
+ spentByAccount,
+ earnedByAccount,
recentTransactions,
} = state.data;
@@ -206,7 +207,14 @@ export default function DashboardScreen() {
{hasAccounts ? (
<>
- {/* ── This month: expense & income card ── */}
+
+
+
+
+
+ {selectedAccount
+ ? `${selectedAccount.name} balance`
+ : "Total balance"}
+
+
+
+ {/* ── This month: expense & income (same card) ── */}
+
-
-
-
-
-
- {selectedAccount
- ? `${selectedAccount.name} balance`
- : "Total balance"}
-
-
-
-
-
-
{categories.length > 0 && (
;
// ─── constants ────────────────────────────────────────────────────────────────
+/** Fallback badge color for accounts created without a logo (schema NOT NULL). */
+const DEFAULT_ACCOUNT_COLOR = colors.brass;
+
const ACCOUNT_TYPES: {
type: AccountType;
label: string;
@@ -68,82 +71,23 @@ const ACCOUNT_TYPES: {
{ type: "investment", label: "Investment", icon: "trending-up" },
];
-const BADGE_COLORS = [
- colors.brass,
- colors.sage,
- colors.rust,
- colors.dustyBlue,
- colors.textSecondary,
- colors.ochre,
- colors.brassSoft,
- "#D48FB8",
- "#A78BDB",
- "#6FC9B8",
- "#F0C27A",
- "#8CA89B",
-] as const;
-
const BADGE_MODES: {
- key: "color" | "logo" | "custom";
+ key: "logo" | "custom";
label: string;
icon: LucideIconName;
}[] = [
- { key: "color", label: "Color", icon: "swatch-book" },
{ key: "logo", label: "Logo", icon: "building" },
{ key: "custom", label: "Custom", icon: "upload" },
];
// ─── helpers ─────────────────────────────────────────────────────────────────
-function getInitials(name: string): string {
- const words = name.trim().split(/\s+/);
- if (words.length === 1) return (words[0] ?? "").slice(0, 2).toUpperCase();
- return words
- .slice(0, 2)
- .map((w) => (w[0] ?? "").toUpperCase())
- .join("");
-}
-
function parseBalanceCents(text: string): number {
const stripped = text.replace(/[$,\s]/g, "");
const val = parseFloat(stripped);
return isNaN(val) ? 0 : Math.round(val * 100);
}
-// ─── color swatch ─────────────────────────────────────────────────────────────
-
-function ColorSwatch({
- color,
- selected,
- onPress,
-}: {
- color: string;
- selected: boolean;
- onPress: () => void;
-}) {
- return (
-
-
-
-
-
- );
-}
-
// ─── logo grid item ───────────────────────────────────────────────────────────
function LogoGridItem({
@@ -196,13 +140,10 @@ export default function AddAccountScreen() {
const colors = useColors();
const toast = useToast();
const [selectedType, setSelectedType] = useState("wallet");
- const [selectedColorIdx, setSelectedColorIdx] = useState(0);
const [isSaving, setIsSaving] = useState(false);
// Badge mode state
- const [badgeMode, setBadgeMode] = useState<"color" | "logo" | "custom">(
- "color",
- );
+ const [badgeMode, setBadgeMode] = useState<"logo" | "custom">("logo");
const [selectedLogoKey, setSelectedLogoKey] = useState(null);
const [customLogoUri, setCustomLogoUri] = useState(null);
const [isProcessing, setIsProcessing] = useState(false);
@@ -219,11 +160,6 @@ export default function AddAccountScreen() {
},
});
- const name = useWatch({ control, name: "name" });
- const selectedColor = BADGE_COLORS[selectedColorIdx] ?? colors.brass;
- const initials = getInitials(name);
- const hasName = name.trim().length > 0;
-
// Resolve the final logoKey to save and preview source
let resolvedLogoKey: string | null = null;
let previewSource: ReturnType | { uri: string } | null = null;
@@ -244,7 +180,7 @@ export default function AddAccountScreen() {
await insertAccount({
name: data.name.trim(),
type: selectedType,
- colorHex: selectedColor,
+ colorHex: DEFAULT_ACCOUNT_COLOR,
logoKey: resolvedLogoKey,
startingBalanceCents: parseBalanceCents(data.balance ?? "0.00"),
});
@@ -258,7 +194,7 @@ export default function AddAccountScreen() {
setIsSaving(false);
}
},
- [selectedType, selectedColor, resolvedLogoKey, toast],
+ [selectedType, resolvedLogoKey, toast],
);
const handleSave = handleSubmit(onSubmit, (fieldErrors) => {
@@ -339,7 +275,7 @@ export default function AddAccountScreen() {
}
}, [customLogoUri, toast]);
- const handleBadgeModeChange = (mode: "color" | "logo" | "custom") => {
+ const handleBadgeModeChange = (mode: "logo" | "custom") => {
setBadgeMode(mode);
if (mode !== "logo") setSelectedLogoKey(null);
if (mode !== "custom") setCustomLogoUri(null);
@@ -470,32 +406,22 @@ export default function AddAccountScreen() {
{/* Preview badge */}
-
- {previewSource && (
+ {previewSource ? (
+
- )}
- {badgeMode === "color" &&
- (hasName ? (
-
- {initials}
-
- ) : (
-
- ))}
-
+
+ ) : (
+
+
+
+ )}
{/* ── Mode pills ── */}
@@ -531,31 +457,6 @@ export default function AddAccountScreen() {
{/* ── Mode content ── */}
- {badgeMode === "color" && (
-
-
- {BADGE_COLORS.map((clr, idx) => (
-
- setSelectedColorIdx(idx)}
- />
-
- ))}
-
-
- )}
-
{badgeMode === "logo" && (
(w[0] ?? "").toUpperCase())
- .join("");
-}
-
-/** Returns the index of the closest matching BADGE_COLORS entry, or 0. */
-function findClosestColorIndex(hex: string): number {
- const target = hex.toLowerCase();
- const idx = BADGE_COLORS.findIndex((c) => c.toLowerCase() === target);
- return idx >= 0 ? idx : 0;
-}
-
-// ─── color swatch ─────────────────────────────────────────────────────────────
-
-function ColorSwatch({
- color,
- selected,
- onPress,
-}: {
- color: string;
- selected: boolean;
- onPress: () => void;
-}) {
- return (
-
-
-
-
-
- );
-}
-
// ─── logo grid item ───────────────────────────────────────────────────────────
function LogoGridItem({
@@ -200,10 +134,8 @@ export default function EditAccountScreen() {
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [selectedType, setSelectedType] = useState("wallet");
- const [selectedColorIdx, setSelectedColorIdx] = useState(0);
- const [badgeMode, setBadgeMode] = useState<"color" | "logo" | "custom">(
- "color",
- );
+ const [badgeMode, setBadgeMode] = useState<"logo" | "custom">("logo");
+ const [storedColorHex, setStoredColorHex] = useState(colors.brass);
const [selectedLogoKey, setSelectedLogoKey] = useState(null);
const [customLogoUri, setCustomLogoUri] = useState(null);
const [isProcessing, setIsProcessing] = useState(false);
@@ -236,6 +168,7 @@ export default function EditAccountScreen() {
reset({ name: account.name });
setSelectedType(account.type);
+ setStoredColorHex(account.colorHex);
// Determine badge mode from logoKey
if (account.logoKey && account.logoKey.startsWith("bank/")) {
@@ -247,8 +180,9 @@ export default function EditAccountScreen() {
const uri = `${documentDirectory}account-logos/${filename}`;
setCustomLogoUri(uri);
} else {
- setBadgeMode("color");
- setSelectedColorIdx(findClosestColorIndex(account.colorHex));
+ // No logo selected — keep the badge unset and let the user pick
+ // Logo or Custom. colorHex is preserved as a display fallback.
+ setBadgeMode("logo");
}
} catch (err) {
toast.error("Could not load account", {
@@ -264,11 +198,6 @@ export default function EditAccountScreen() {
// ── Derived preview ────────────────────────────────────────────────────
- const name = useWatch({ control, name: "name" });
- const selectedColor = BADGE_COLORS[selectedColorIdx] ?? colors.brass;
- const initials = getInitials(name);
- const hasName = name.trim().length > 0;
-
let resolvedLogoKey: string | null = null;
let previewSource: ReturnType | { uri: string } | null = null;
@@ -292,7 +221,7 @@ export default function EditAccountScreen() {
id,
name: data.name.trim(),
type: selectedType,
- colorHex: selectedColor,
+ colorHex: storedColorHex,
logoKey: resolvedLogoKey,
});
router.back();
@@ -305,7 +234,7 @@ export default function EditAccountScreen() {
setIsSaving(false);
}
},
- [id, selectedType, selectedColor, resolvedLogoKey, toast],
+ [id, selectedType, storedColorHex, resolvedLogoKey, toast],
);
const handleSave = handleSubmit(onSubmit, (fieldErrors) => {
@@ -381,7 +310,7 @@ export default function EditAccountScreen() {
}
}, [customLogoUri, toast]);
- const handleBadgeModeChange = (mode: "color" | "logo" | "custom") => {
+ const handleBadgeModeChange = (mode: "logo" | "custom") => {
setBadgeMode(mode);
if (mode !== "logo") setSelectedLogoKey(null);
if (mode !== "custom") setCustomLogoUri(null);
@@ -522,32 +451,22 @@ export default function EditAccountScreen() {
{/* Preview badge */}
-
- {previewSource && (
+ {previewSource ? (
+
- )}
- {badgeMode === "color" &&
- (hasName ? (
-
- {initials}
-
- ) : (
-
- ))}
-
+
+ ) : (
+
+
+
+ )}
{/* ── Mode pills ── */}
@@ -581,31 +500,6 @@ export default function EditAccountScreen() {
{/* ── Mode content ── */}
- {badgeMode === "color" && (
-
-
- {BADGE_COLORS.map((clr, idx) => (
-
- setSelectedColorIdx(idx)}
- />
-
- ))}
-
-
- )}
-
{badgeMode === "logo" && (
(w[0] ?? "").toUpperCase())
+ .join("");
+}
+
+/** Small account badge — bank/custom logo image, or color + initials fallback. */
+export function AccountBadge({
+ name,
+ logoKey,
+ colorHex,
+ size = 30,
+}: {
+ name: string;
+ logoKey: string | null;
+ colorHex: string;
+ size?: number;
+}) {
+ const colors = useColors();
+ const logoSrc = resolveLogoSrc(logoKey);
+ const radius = Math.round(size * 0.33);
+
+ if (logoSrc?.type === "bundled" && logoSrc.source) {
+ return (
+
+ );
+ }
+
+ if (logoSrc?.type === "uri" && logoSrc.uri) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+ {getInitials(name)}
+
+
+ );
+}
export function TransactionRow({
transaction,
@@ -37,6 +106,13 @@ export function TransactionRow({
borderTopColor: colors.hairline,
}}
>
+
+
+
-
-
- {label}
-
-
- );
+/** SVG path for a donut segment sweeping clockwise from start to end angle. */
+function donutArcPath(startAngle: number, endAngle: number): string {
+ const sweep = endAngle - startAngle;
+ const largeArc = sweep > 180 ? 1 : 0;
+ const outerStart = polar(CENTER, CENTER, RADIUS, startAngle);
+ const outerEnd = polar(CENTER, CENTER, RADIUS, endAngle);
+ const innerStart = polar(CENTER, CENTER, RADIUS - STROKE_WIDTH, startAngle);
+ const innerEnd = polar(CENTER, CENTER, RADIUS - STROKE_WIDTH, endAngle);
+
+ return [
+ `M ${outerStart.x} ${outerStart.y}`,
+ `A ${RADIUS} ${RADIUS} 0 ${largeArc} 1 ${outerEnd.x} ${outerEnd.y}`,
+ `L ${innerEnd.x} ${innerEnd.y}`,
+ `A ${RADIUS - STROKE_WIDTH} ${RADIUS - STROKE_WIDTH} 0 ${largeArc} 0 ${innerStart.x} ${innerStart.y}`,
+ "Z",
+ ].join(" ");
}
// ─── card ──────────────────────────────────────────────────────────────────────
+type SegmentKind = "earned" | "spent";
+
/**
- * Grouped bar chart of the last 7 days' income (sage) vs expense (brass),
- * scaled to the busiest day. Zero-value days render only the hairline baseline.
+ * Interactive donut chart of the last 7 days: one sage arc for earned
+ * (income), one brass arc for spent (expense). Tapping an arc lists the
+ * per-account breakdown for that category (badge, name, amount); tapping
+ * again returns to the totals.
*/
export function WeeklyActivityCard({
- dailyTotals,
+ earned,
+ spent,
+ hidden = false,
}: {
- dailyTotals: DailyTotals[];
+ earned: AccountAmountSlice[];
+ spent: AccountAmountSlice[];
+ /** When true, masks amounts (privacy eye toggle on the dashboard). */
+ hidden?: boolean;
}) {
const colors = useColors();
+ const [selectedKind, setSelectedKind] = useState(null);
- const max = dailyTotals.reduce(
- (m, d) => Math.max(m, d.expenseCents, d.incomeCents),
- 0,
- );
- const scale = max > 0 ? CHART_HEIGHT / max : 0;
+ const earnedTotal = earned.reduce((sum, s) => sum + s.amountCents, 0);
+ const spentTotal = spent.reduce((sum, s) => sum + s.amountCents, 0);
+ const grandTotal = earnedTotal + spentTotal;
- const groupWidth = BAR_WIDTH * 2 + BAR_GAP;
- const chartWidth =
- dailyTotals.length * groupWidth + (dailyTotals.length - 1) * GROUP_GAP;
+ const selected =
+ selectedKind === "earned"
+ ? earned
+ : selectedKind === "spent"
+ ? spent
+ : null;
+
+ const handlePress = (kind: SegmentKind) => {
+ setSelectedKind((current) => (current === kind ? null : kind));
+ };
+
+ // ── Segment angles (earned first from 12 o'clock, then spent) ───────────
+ const earnedFraction = grandTotal > 0 ? earnedTotal / grandTotal : 0;
+ const spentFraction = grandTotal > 0 ? spentTotal / grandTotal : 0;
+ const showEmptyRing = grandTotal <= 0;
+ const isSingleEarned = earnedTotal > 0 && spentTotal === 0;
+ const isSingleSpent = spentTotal > 0 && earnedTotal === 0;
+
+ const detailLabel =
+ selectedKind === "earned"
+ ? "Earned by account"
+ : selectedKind === "spent"
+ ? "Spent by account"
+ : "";
return (
-
-
-
-
+ {/* ── Detail: totals, or the tapped category's accounts ── */}
+
+ {selected ? (
+ <>
+
+ {detailLabel}
+
+ {selected.length === 0 ? (
+
+ Nothing this week
+
+ ) : (
+ selected.map((item) => (
+
+
+
+
+ {item.accountName}
+
+
+
+ {hidden ? HIDDEN_AMOUNT : formatCurrency(item.amountCents)}
+
+
+ ))
+ )}
+ >
+ ) : (
+
+
+
+ {showEmptyRing ? "No activity" : "Earned"}
+
+
+ {hidden ? HIDDEN_AMOUNT : formatCurrency(earnedTotal)}
+
+
+
+
+ Spent
+
+
+ {hidden ? HIDDEN_AMOUNT : formatCurrency(spentTotal)}
+
+
+
+ )}
);
diff --git a/src/hooks/useDashboardData.ts b/src/hooks/useDashboardData.ts
index 88cad97..472f527 100644
--- a/src/hooks/useDashboardData.ts
+++ b/src/hooks/useDashboardData.ts
@@ -19,7 +19,8 @@ import {
listTopExpenseCategories,
} from "@/lib/db/repositories/categories";
import {
- getDailyTotals,
+ getExpenseByAccount,
+ getIncomeByAccount,
listRecentTransactions,
} from "@/lib/db/repositories/transactions";
import type { DashboardData, DashboardState } from "@/types";
@@ -46,14 +47,16 @@ async function fetchDashboardData(
categories,
totalExpenseCents,
totalIncomeCents,
- dailyTotals,
+ spentByAccount,
+ earnedByAccount,
recentTransactions,
] = await Promise.all([
listAccountsWithBalances(),
listTopExpenseCategories(range, 0, accountId),
getTotalExpenseCents(range, accountId),
getTotalIncomeCents(range, accountId),
- getDailyTotals(weekRange, accountId),
+ getExpenseByAccount(weekRange, accountId),
+ getIncomeByAccount(weekRange, accountId),
listRecentTransactions(RECENT_TRANSACTIONS_LIMIT, accountId, range),
]);
@@ -62,7 +65,8 @@ async function fetchDashboardData(
categories,
totalExpenseCents,
totalIncomeCents,
- dailyTotals,
+ spentByAccount,
+ earnedByAccount,
recentTransactions,
};
}
diff --git a/src/lib/db/repositories/transactions.ts b/src/lib/db/repositories/transactions.ts
index d57dc30..639fd07 100644
--- a/src/lib/db/repositories/transactions.ts
+++ b/src/lib/db/repositories/transactions.ts
@@ -12,6 +12,7 @@
import * as ExpoCrypto from "expo-crypto";
import { getDb } from "../client";
import type {
+ AccountAmountSlice,
DailyTotals,
RecentTransaction,
TransactionLedgerItem,
@@ -30,6 +31,9 @@ interface RecentTransactionRow {
amount_cents: number | string;
occurred_at: number | string;
account_id: string;
+ account_name: string;
+ account_logo_key: string | null;
+ account_color_hex: string;
category_name: string | null;
category_icon: string | null;
category_kind: CategoryKind | null;
@@ -50,6 +54,14 @@ interface TransactionLedgerRow {
source: "manual" | "scan" | "import";
}
+interface AccountAmountRow {
+ account_id: string;
+ account_name: string;
+ account_logo_key: string | null;
+ account_color_hex: string;
+ total_cents: number | string;
+}
+
// ─── queries ──────────────────────────────────────────────────────────────────
export async function listRecentTransactions(
@@ -77,8 +89,11 @@ export async function listRecentTransactions(
const { rows } = await db.execute(
`SELECT t.id, t.merchant, t.amount_cents, t.occurred_at, t.account_id,
+ a.name AS account_name, a.logo_key AS account_logo_key,
+ a.color_hex AS account_color_hex,
c.name AS category_name, c.icon AS category_icon, c.kind AS category_kind
FROM transactions t
+ LEFT JOIN accounts a ON a.id = t.account_id
LEFT JOIN categories c ON c.id = t.category_id
${whereClause}
ORDER BY t.created_at DESC
@@ -92,6 +107,9 @@ export async function listRecentTransactions(
amountCents: Number(row.amount_cents),
occurredAt: Number(row.occurred_at),
accountId: row.account_id,
+ accountName: row.account_name,
+ accountLogoKey: row.account_logo_key ?? null,
+ accountColorHex: row.account_color_hex,
categoryName: row.category_name ?? null,
categoryIcon: row.category_icon ?? null,
categoryKind: row.category_kind ?? null,
@@ -202,8 +220,7 @@ export async function deleteTransaction(id: string): Promise {
/**
* Daily expense/income totals for every day in `range` (local timezone),
- * zero-filled so days without transactions still appear. Used by the
- * dashboard's weekly activity chart.
+ * zero-filled so days without transactions still appear.
*/
export async function getDailyTotals(
range: MonthRange,
@@ -257,3 +274,85 @@ export async function getDailyTotals(
}
return totals;
}
+
+/**
+ * Expense totals grouped by account for `range` (positive cents), ordered
+ * largest first. Feeds the "spent" side of the dashboard's interactive
+ * weekly activity pie chart. Scoped to a single account when `accountId`
+ * is provided.
+ */
+export async function getExpenseByAccount(
+ range: MonthRange,
+ accountId?: string,
+): Promise {
+ const db = await getDb();
+ const params: (string | number)[] = [range.start, range.end];
+ const accountClause = accountId ? "AND t.account_id = ?" : "";
+ if (accountId) params.push(accountId);
+
+ const { rows } = await db.execute(
+ `SELECT a.id AS account_id,
+ a.name AS account_name,
+ a.logo_key AS account_logo_key,
+ a.color_hex AS account_color_hex,
+ SUM(-t.amount_cents) AS total_cents
+ FROM transactions t
+ JOIN accounts a ON a.id = t.account_id
+ WHERE t.amount_cents < 0
+ AND t.occurred_at >= ?
+ AND t.occurred_at < ?
+ ${accountClause}
+ GROUP BY a.id
+ ORDER BY total_cents DESC;`,
+ params,
+ );
+
+ return (rows as unknown as AccountAmountRow[]).map((row) => ({
+ accountId: row.account_id,
+ accountName: row.account_name,
+ accountLogoKey: row.account_logo_key ?? null,
+ accountColorHex: row.account_color_hex,
+ amountCents: Number(row.total_cents),
+ }));
+}
+
+/**
+ * Income totals grouped by account for `range` (positive cents), ordered
+ * largest first. Feeds the "earned" side of the dashboard's interactive
+ * weekly activity pie chart. Scoped to a single account when `accountId`
+ * is provided.
+ */
+export async function getIncomeByAccount(
+ range: MonthRange,
+ accountId?: string,
+): Promise {
+ const db = await getDb();
+ const params: (string | number)[] = [range.start, range.end];
+ const accountClause = accountId ? "AND t.account_id = ?" : "";
+ if (accountId) params.push(accountId);
+
+ const { rows } = await db.execute(
+ `SELECT a.id AS account_id,
+ a.name AS account_name,
+ a.logo_key AS account_logo_key,
+ a.color_hex AS account_color_hex,
+ SUM(t.amount_cents) AS total_cents
+ FROM transactions t
+ JOIN accounts a ON a.id = t.account_id
+ WHERE t.amount_cents > 0
+ AND t.occurred_at >= ?
+ AND t.occurred_at < ?
+ ${accountClause}
+ GROUP BY a.id
+ ORDER BY total_cents DESC;`,
+ params,
+ );
+
+ return (rows as unknown as AccountAmountRow[]).map((row) => ({
+ accountId: row.account_id,
+ accountName: row.account_name,
+ accountLogoKey: row.account_logo_key ?? null,
+ accountColorHex: row.account_color_hex,
+ amountCents: Number(row.total_cents),
+ }));
+}
diff --git a/src/types/hooks.ts b/src/types/hooks.ts
index 3d77ee5..835b7ae 100644
--- a/src/types/hooks.ts
+++ b/src/types/hooks.ts
@@ -14,7 +14,7 @@
import type { AccountWithBalance } from "./account";
import type { Category, CategorySpend } from "./category";
import type {
- DailyTotals,
+ AccountAmountSlice,
RecentTransaction,
TransactionLedgerItem,
} from "./transaction";
@@ -53,7 +53,10 @@ export interface DashboardData {
categories: CategorySpend[];
totalExpenseCents: number;
totalIncomeCents: number;
- dailyTotals: DailyTotals[];
+ /** Per-account expense totals for the last 7 days (pie "spent" side). */
+ spentByAccount: AccountAmountSlice[];
+ /** Per-account income totals for the last 7 days (pie "earned" side). */
+ earnedByAccount: AccountAmountSlice[];
recentTransactions: RecentTransaction[];
}
diff --git a/src/types/transaction.ts b/src/types/transaction.ts
index 5f9e0b7..8102971 100644
--- a/src/types/transaction.ts
+++ b/src/types/transaction.ts
@@ -27,11 +27,30 @@ export interface RecentTransaction {
amountCents: number;
occurredAt: number;
accountId: string;
+ /** Account name — used for the badge initials fallback. */
+ accountName: string;
+ /** Account logo key ("bank/…" or "custom/…"), resolved via resolveLogoSrc(). */
+ accountLogoKey: string | null;
+ /** Account badge color — fallback background when there is no logo. */
+ accountColorHex: string;
categoryName: string | null;
categoryIcon: string | null;
categoryKind: CategoryKind | null;
}
+/**
+ * Per-account income or expense total for a date range — one row of the
+ * dashboard's weekly activity pie chart detail. `amountCents` is positive
+ * (absolute amount).
+ */
+export interface AccountAmountSlice {
+ accountId: string;
+ accountName: string;
+ accountLogoKey: string | null;
+ accountColorHex: string;
+ amountCents: number;
+}
+
/**
* Full transaction row for the ledger view — includes account name and note
* in addition to the fields exposed by RecentTransaction.
From e6433e2bc49365d363f3c770530aca5ffde01cf7 Mon Sep 17 00:00:00 2001
From: Marjose Darang <18107626+MarJose123@users.noreply.github.com>
Date: Wed, 5 Aug 2026 11:19:20 +0800
Subject: [PATCH 2/2] feat: account badges, balance card, and interactive
activity pie chart
- Remove the color badge mode from add/edit account forms (Logo + Custom only)
- Show the account badge (logo/initials) on dashboard recent transactions
- Wrap the dashboard total balance in a card with this month's expense/income
- Replace the weekly bar chart with an interactive earned/spent donut pie;
tapping a segment lists the per-account breakdown (badge, name, amount)
- Add getIncomeByAccount / getExpenseByAccount repo queries with tests
---
.../components/DashboardComponents.test.tsx | 121 ++++++++
.../repositories/transactions.test.ts | 107 +++++++
src/app/(app)/dashboard.tsx | 61 ++--
src/app/add-account.tsx | 139 ++-------
src/app/edit-account.tsx | 150 ++--------
src/components/dashboard/TransactionRow.tsx | 78 ++++-
.../dashboard/WeeklyActivityCard.tsx | 275 +++++++++++++-----
src/hooks/useDashboardData.ts | 12 +-
src/lib/db/repositories/transactions.ts | 103 ++++++-
src/types/hooks.ts | 7 +-
src/types/transaction.ts | 19 ++
11 files changed, 721 insertions(+), 351 deletions(-)
diff --git a/src/__tests__/components/DashboardComponents.test.tsx b/src/__tests__/components/DashboardComponents.test.tsx
index d209c60..9c1e5e3 100644
--- a/src/__tests__/components/DashboardComponents.test.tsx
+++ b/src/__tests__/components/DashboardComponents.test.tsx
@@ -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", () => ({
@@ -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,
@@ -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();
+ 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();
+ // Bundled logo asset resolves via require — the image element renders
+ // without the initials fallback.
+ expect(JSON.stringify(renderer.toJSON())).not.toContain("BS");
+ });
});
// ─── AccountSwitcher ───────────────────────────────────────────────────────
@@ -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(
+ ,
+ );
+ 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(
+ ,
+ );
+ 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(
+ ,
+ );
+ 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(
+ ,
+ );
+ 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(
+ ,
+ );
+ expect(JSON.stringify(renderer.toJSON())).toContain(HIDDEN_AMOUNT);
+ });
+
+ it("shows the empty state when there is no activity", () => {
+ const renderer = render();
+ const json = JSON.stringify(renderer.toJSON());
+ expect(json).toContain("No activity");
+ expect(json).toContain("₱0.00");
+ });
+});
diff --git a/src/__tests__/repositories/transactions.test.ts b/src/__tests__/repositories/transactions.test.ts
index 8390c6f..8254cec 100644
--- a/src/__tests__/repositories/transactions.test.ts
+++ b/src/__tests__/repositories/transactions.test.ts
@@ -17,6 +17,8 @@ import type { MonthRange } from "@/types";
import { mockDbInstance } from "@/__tests__/setup";
import {
deleteTransaction,
+ getExpenseByAccount,
+ getIncomeByAccount,
insertTransaction,
listRecentTransactions,
listAllTransactions,
@@ -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",
@@ -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 () => {
@@ -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");
+ });
+});
diff --git a/src/app/(app)/dashboard.tsx b/src/app/(app)/dashboard.tsx
index 43fe26d..13c3f71 100644
--- a/src/app/(app)/dashboard.tsx
+++ b/src/app/(app)/dashboard.tsx
@@ -112,7 +112,8 @@ export default function DashboardScreen() {
categories,
totalExpenseCents,
totalIncomeCents,
- dailyTotals,
+ spentByAccount,
+ earnedByAccount,
recentTransactions,
} = state.data;
@@ -206,7 +207,14 @@ export default function DashboardScreen() {
{hasAccounts ? (
<>
- {/* ── This month: expense & income card ── */}
+
+
+
+
+
+ {selectedAccount
+ ? `${selectedAccount.name} balance`
+ : "Total balance"}
+
+
+
+ {/* ── This month: expense & income (same card) ── */}
+
-
-
-
-
-
- {selectedAccount
- ? `${selectedAccount.name} balance`
- : "Total balance"}
-
-
-
-
-
-
{categories.length > 0 && (
;
// ─── constants ────────────────────────────────────────────────────────────────
+/** Fallback badge color for accounts created without a logo (schema NOT NULL). */
+const DEFAULT_ACCOUNT_COLOR = colors.brass;
+
const ACCOUNT_TYPES: {
type: AccountType;
label: string;
@@ -68,82 +71,23 @@ const ACCOUNT_TYPES: {
{ type: "investment", label: "Investment", icon: "trending-up" },
];
-const BADGE_COLORS = [
- colors.brass,
- colors.sage,
- colors.rust,
- colors.dustyBlue,
- colors.textSecondary,
- colors.ochre,
- colors.brassSoft,
- "#D48FB8",
- "#A78BDB",
- "#6FC9B8",
- "#F0C27A",
- "#8CA89B",
-] as const;
-
const BADGE_MODES: {
- key: "color" | "logo" | "custom";
+ key: "logo" | "custom";
label: string;
icon: LucideIconName;
}[] = [
- { key: "color", label: "Color", icon: "swatch-book" },
{ key: "logo", label: "Logo", icon: "building" },
{ key: "custom", label: "Custom", icon: "upload" },
];
// ─── helpers ─────────────────────────────────────────────────────────────────
-function getInitials(name: string): string {
- const words = name.trim().split(/\s+/);
- if (words.length === 1) return (words[0] ?? "").slice(0, 2).toUpperCase();
- return words
- .slice(0, 2)
- .map((w) => (w[0] ?? "").toUpperCase())
- .join("");
-}
-
function parseBalanceCents(text: string): number {
const stripped = text.replace(/[$,\s]/g, "");
const val = parseFloat(stripped);
return isNaN(val) ? 0 : Math.round(val * 100);
}
-// ─── color swatch ─────────────────────────────────────────────────────────────
-
-function ColorSwatch({
- color,
- selected,
- onPress,
-}: {
- color: string;
- selected: boolean;
- onPress: () => void;
-}) {
- return (
-
-
-
-
-
- );
-}
-
// ─── logo grid item ───────────────────────────────────────────────────────────
function LogoGridItem({
@@ -196,13 +140,10 @@ export default function AddAccountScreen() {
const colors = useColors();
const toast = useToast();
const [selectedType, setSelectedType] = useState("wallet");
- const [selectedColorIdx, setSelectedColorIdx] = useState(0);
const [isSaving, setIsSaving] = useState(false);
// Badge mode state
- const [badgeMode, setBadgeMode] = useState<"color" | "logo" | "custom">(
- "color",
- );
+ const [badgeMode, setBadgeMode] = useState<"logo" | "custom">("logo");
const [selectedLogoKey, setSelectedLogoKey] = useState(null);
const [customLogoUri, setCustomLogoUri] = useState(null);
const [isProcessing, setIsProcessing] = useState(false);
@@ -219,11 +160,6 @@ export default function AddAccountScreen() {
},
});
- const name = useWatch({ control, name: "name" });
- const selectedColor = BADGE_COLORS[selectedColorIdx] ?? colors.brass;
- const initials = getInitials(name);
- const hasName = name.trim().length > 0;
-
// Resolve the final logoKey to save and preview source
let resolvedLogoKey: string | null = null;
let previewSource: ReturnType | { uri: string } | null = null;
@@ -244,7 +180,7 @@ export default function AddAccountScreen() {
await insertAccount({
name: data.name.trim(),
type: selectedType,
- colorHex: selectedColor,
+ colorHex: DEFAULT_ACCOUNT_COLOR,
logoKey: resolvedLogoKey,
startingBalanceCents: parseBalanceCents(data.balance ?? "0.00"),
});
@@ -258,7 +194,7 @@ export default function AddAccountScreen() {
setIsSaving(false);
}
},
- [selectedType, selectedColor, resolvedLogoKey, toast],
+ [selectedType, resolvedLogoKey, toast],
);
const handleSave = handleSubmit(onSubmit, (fieldErrors) => {
@@ -339,7 +275,7 @@ export default function AddAccountScreen() {
}
}, [customLogoUri, toast]);
- const handleBadgeModeChange = (mode: "color" | "logo" | "custom") => {
+ const handleBadgeModeChange = (mode: "logo" | "custom") => {
setBadgeMode(mode);
if (mode !== "logo") setSelectedLogoKey(null);
if (mode !== "custom") setCustomLogoUri(null);
@@ -470,32 +406,22 @@ export default function AddAccountScreen() {
{/* Preview badge */}
-
- {previewSource && (
+ {previewSource ? (
+
- )}
- {badgeMode === "color" &&
- (hasName ? (
-
- {initials}
-
- ) : (
-
- ))}
-
+
+ ) : (
+
+
+
+ )}
{/* ── Mode pills ── */}
@@ -531,31 +457,6 @@ export default function AddAccountScreen() {
{/* ── Mode content ── */}
- {badgeMode === "color" && (
-
-
- {BADGE_COLORS.map((clr, idx) => (
-
- setSelectedColorIdx(idx)}
- />
-
- ))}
-
-
- )}
-
{badgeMode === "logo" && (
(w[0] ?? "").toUpperCase())
- .join("");
-}
-
-/** Returns the index of the closest matching BADGE_COLORS entry, or 0. */
-function findClosestColorIndex(hex: string): number {
- const target = hex.toLowerCase();
- const idx = BADGE_COLORS.findIndex((c) => c.toLowerCase() === target);
- return idx >= 0 ? idx : 0;
-}
-
-// ─── color swatch ─────────────────────────────────────────────────────────────
-
-function ColorSwatch({
- color,
- selected,
- onPress,
-}: {
- color: string;
- selected: boolean;
- onPress: () => void;
-}) {
- return (
-
-
-
-
-
- );
-}
-
// ─── logo grid item ───────────────────────────────────────────────────────────
function LogoGridItem({
@@ -200,10 +134,8 @@ export default function EditAccountScreen() {
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [selectedType, setSelectedType] = useState("wallet");
- const [selectedColorIdx, setSelectedColorIdx] = useState(0);
- const [badgeMode, setBadgeMode] = useState<"color" | "logo" | "custom">(
- "color",
- );
+ const [badgeMode, setBadgeMode] = useState<"logo" | "custom">("logo");
+ const [storedColorHex, setStoredColorHex] = useState(colors.brass);
const [selectedLogoKey, setSelectedLogoKey] = useState(null);
const [customLogoUri, setCustomLogoUri] = useState(null);
const [isProcessing, setIsProcessing] = useState(false);
@@ -236,6 +168,7 @@ export default function EditAccountScreen() {
reset({ name: account.name });
setSelectedType(account.type);
+ setStoredColorHex(account.colorHex);
// Determine badge mode from logoKey
if (account.logoKey && account.logoKey.startsWith("bank/")) {
@@ -247,8 +180,9 @@ export default function EditAccountScreen() {
const uri = `${documentDirectory}account-logos/${filename}`;
setCustomLogoUri(uri);
} else {
- setBadgeMode("color");
- setSelectedColorIdx(findClosestColorIndex(account.colorHex));
+ // No logo selected — keep the badge unset and let the user pick
+ // Logo or Custom. colorHex is preserved as a display fallback.
+ setBadgeMode("logo");
}
} catch (err) {
toast.error("Could not load account", {
@@ -264,11 +198,6 @@ export default function EditAccountScreen() {
// ── Derived preview ────────────────────────────────────────────────────
- const name = useWatch({ control, name: "name" });
- const selectedColor = BADGE_COLORS[selectedColorIdx] ?? colors.brass;
- const initials = getInitials(name);
- const hasName = name.trim().length > 0;
-
let resolvedLogoKey: string | null = null;
let previewSource: ReturnType | { uri: string } | null = null;
@@ -292,7 +221,7 @@ export default function EditAccountScreen() {
id,
name: data.name.trim(),
type: selectedType,
- colorHex: selectedColor,
+ colorHex: storedColorHex,
logoKey: resolvedLogoKey,
});
router.back();
@@ -305,7 +234,7 @@ export default function EditAccountScreen() {
setIsSaving(false);
}
},
- [id, selectedType, selectedColor, resolvedLogoKey, toast],
+ [id, selectedType, storedColorHex, resolvedLogoKey, toast],
);
const handleSave = handleSubmit(onSubmit, (fieldErrors) => {
@@ -381,7 +310,7 @@ export default function EditAccountScreen() {
}
}, [customLogoUri, toast]);
- const handleBadgeModeChange = (mode: "color" | "logo" | "custom") => {
+ const handleBadgeModeChange = (mode: "logo" | "custom") => {
setBadgeMode(mode);
if (mode !== "logo") setSelectedLogoKey(null);
if (mode !== "custom") setCustomLogoUri(null);
@@ -522,32 +451,22 @@ export default function EditAccountScreen() {
{/* Preview badge */}
-
- {previewSource && (
+ {previewSource ? (
+
- )}
- {badgeMode === "color" &&
- (hasName ? (
-
- {initials}
-
- ) : (
-
- ))}
-
+
+ ) : (
+
+
+
+ )}
{/* ── Mode pills ── */}
@@ -581,31 +500,6 @@ export default function EditAccountScreen() {
{/* ── Mode content ── */}
- {badgeMode === "color" && (
-
-
- {BADGE_COLORS.map((clr, idx) => (
-
- setSelectedColorIdx(idx)}
- />
-
- ))}
-
-
- )}
-
{badgeMode === "logo" && (
(w[0] ?? "").toUpperCase())
+ .join("");
+}
+
+/** Small account badge — bank/custom logo image, or color + initials fallback. */
+export function AccountBadge({
+ name,
+ logoKey,
+ colorHex,
+ size = 30,
+}: {
+ name: string;
+ logoKey: string | null;
+ colorHex: string;
+ size?: number;
+}) {
+ const colors = useColors();
+ const logoSrc = resolveLogoSrc(logoKey);
+ const radius = Math.round(size * 0.33);
+
+ if (logoSrc?.type === "bundled" && logoSrc.source) {
+ return (
+
+ );
+ }
+
+ if (logoSrc?.type === "uri" && logoSrc.uri) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+ {getInitials(name)}
+
+
+ );
+}
export function TransactionRow({
transaction,
@@ -37,6 +106,13 @@ export function TransactionRow({
borderTopColor: colors.hairline,
}}
>
+
+
+
-
-
- {label}
-
-
- );
+/** SVG path for a donut segment sweeping clockwise from start to end angle. */
+function donutArcPath(startAngle: number, endAngle: number): string {
+ const sweep = endAngle - startAngle;
+ const largeArc = sweep > 180 ? 1 : 0;
+ const outerStart = polar(CENTER, CENTER, RADIUS, startAngle);
+ const outerEnd = polar(CENTER, CENTER, RADIUS, endAngle);
+ const innerStart = polar(CENTER, CENTER, RADIUS - STROKE_WIDTH, startAngle);
+ const innerEnd = polar(CENTER, CENTER, RADIUS - STROKE_WIDTH, endAngle);
+
+ return [
+ `M ${outerStart.x} ${outerStart.y}`,
+ `A ${RADIUS} ${RADIUS} 0 ${largeArc} 1 ${outerEnd.x} ${outerEnd.y}`,
+ `L ${innerEnd.x} ${innerEnd.y}`,
+ `A ${RADIUS - STROKE_WIDTH} ${RADIUS - STROKE_WIDTH} 0 ${largeArc} 0 ${innerStart.x} ${innerStart.y}`,
+ "Z",
+ ].join(" ");
}
// ─── card ──────────────────────────────────────────────────────────────────────
+type SegmentKind = "earned" | "spent";
+
/**
- * Grouped bar chart of the last 7 days' income (sage) vs expense (brass),
- * scaled to the busiest day. Zero-value days render only the hairline baseline.
+ * Interactive donut chart of the last 7 days: one sage arc for earned
+ * (income), one brass arc for spent (expense). Tapping an arc lists the
+ * per-account breakdown for that category (badge, name, amount); tapping
+ * again returns to the totals.
*/
export function WeeklyActivityCard({
- dailyTotals,
+ earned,
+ spent,
+ hidden = false,
}: {
- dailyTotals: DailyTotals[];
+ earned: AccountAmountSlice[];
+ spent: AccountAmountSlice[];
+ /** When true, masks amounts (privacy eye toggle on the dashboard). */
+ hidden?: boolean;
}) {
const colors = useColors();
+ const [selectedKind, setSelectedKind] = useState(null);
- const max = dailyTotals.reduce(
- (m, d) => Math.max(m, d.expenseCents, d.incomeCents),
- 0,
- );
- const scale = max > 0 ? CHART_HEIGHT / max : 0;
+ const earnedTotal = earned.reduce((sum, s) => sum + s.amountCents, 0);
+ const spentTotal = spent.reduce((sum, s) => sum + s.amountCents, 0);
+ const grandTotal = earnedTotal + spentTotal;
- const groupWidth = BAR_WIDTH * 2 + BAR_GAP;
- const chartWidth =
- dailyTotals.length * groupWidth + (dailyTotals.length - 1) * GROUP_GAP;
+ const selected =
+ selectedKind === "earned"
+ ? earned
+ : selectedKind === "spent"
+ ? spent
+ : null;
+
+ const handlePress = (kind: SegmentKind) => {
+ setSelectedKind((current) => (current === kind ? null : kind));
+ };
+
+ // ── Segment angles (earned first from 12 o'clock, then spent) ───────────
+ const earnedFraction = grandTotal > 0 ? earnedTotal / grandTotal : 0;
+ const spentFraction = grandTotal > 0 ? spentTotal / grandTotal : 0;
+ const showEmptyRing = grandTotal <= 0;
+ const isSingleEarned = earnedTotal > 0 && spentTotal === 0;
+ const isSingleSpent = spentTotal > 0 && earnedTotal === 0;
+
+ const detailLabel =
+ selectedKind === "earned"
+ ? "Earned by account"
+ : selectedKind === "spent"
+ ? "Spent by account"
+ : "";
return (
-
-
-
-
+ {/* ── Detail: totals, or the tapped category's accounts ── */}
+
+ {selected ? (
+ <>
+
+ {detailLabel}
+
+ {selected.length === 0 ? (
+
+ Nothing this week
+
+ ) : (
+ selected.map((item) => (
+
+
+
+
+ {item.accountName}
+
+
+
+ {hidden ? HIDDEN_AMOUNT : formatCurrency(item.amountCents)}
+
+
+ ))
+ )}
+ >
+ ) : (
+
+
+
+ {showEmptyRing ? "No activity" : "Earned"}
+
+
+ {hidden ? HIDDEN_AMOUNT : formatCurrency(earnedTotal)}
+
+
+
+
+ Spent
+
+
+ {hidden ? HIDDEN_AMOUNT : formatCurrency(spentTotal)}
+
+
+
+ )}
);
diff --git a/src/hooks/useDashboardData.ts b/src/hooks/useDashboardData.ts
index 88cad97..472f527 100644
--- a/src/hooks/useDashboardData.ts
+++ b/src/hooks/useDashboardData.ts
@@ -19,7 +19,8 @@ import {
listTopExpenseCategories,
} from "@/lib/db/repositories/categories";
import {
- getDailyTotals,
+ getExpenseByAccount,
+ getIncomeByAccount,
listRecentTransactions,
} from "@/lib/db/repositories/transactions";
import type { DashboardData, DashboardState } from "@/types";
@@ -46,14 +47,16 @@ async function fetchDashboardData(
categories,
totalExpenseCents,
totalIncomeCents,
- dailyTotals,
+ spentByAccount,
+ earnedByAccount,
recentTransactions,
] = await Promise.all([
listAccountsWithBalances(),
listTopExpenseCategories(range, 0, accountId),
getTotalExpenseCents(range, accountId),
getTotalIncomeCents(range, accountId),
- getDailyTotals(weekRange, accountId),
+ getExpenseByAccount(weekRange, accountId),
+ getIncomeByAccount(weekRange, accountId),
listRecentTransactions(RECENT_TRANSACTIONS_LIMIT, accountId, range),
]);
@@ -62,7 +65,8 @@ async function fetchDashboardData(
categories,
totalExpenseCents,
totalIncomeCents,
- dailyTotals,
+ spentByAccount,
+ earnedByAccount,
recentTransactions,
};
}
diff --git a/src/lib/db/repositories/transactions.ts b/src/lib/db/repositories/transactions.ts
index d57dc30..639fd07 100644
--- a/src/lib/db/repositories/transactions.ts
+++ b/src/lib/db/repositories/transactions.ts
@@ -12,6 +12,7 @@
import * as ExpoCrypto from "expo-crypto";
import { getDb } from "../client";
import type {
+ AccountAmountSlice,
DailyTotals,
RecentTransaction,
TransactionLedgerItem,
@@ -30,6 +31,9 @@ interface RecentTransactionRow {
amount_cents: number | string;
occurred_at: number | string;
account_id: string;
+ account_name: string;
+ account_logo_key: string | null;
+ account_color_hex: string;
category_name: string | null;
category_icon: string | null;
category_kind: CategoryKind | null;
@@ -50,6 +54,14 @@ interface TransactionLedgerRow {
source: "manual" | "scan" | "import";
}
+interface AccountAmountRow {
+ account_id: string;
+ account_name: string;
+ account_logo_key: string | null;
+ account_color_hex: string;
+ total_cents: number | string;
+}
+
// ─── queries ──────────────────────────────────────────────────────────────────
export async function listRecentTransactions(
@@ -77,8 +89,11 @@ export async function listRecentTransactions(
const { rows } = await db.execute(
`SELECT t.id, t.merchant, t.amount_cents, t.occurred_at, t.account_id,
+ a.name AS account_name, a.logo_key AS account_logo_key,
+ a.color_hex AS account_color_hex,
c.name AS category_name, c.icon AS category_icon, c.kind AS category_kind
FROM transactions t
+ LEFT JOIN accounts a ON a.id = t.account_id
LEFT JOIN categories c ON c.id = t.category_id
${whereClause}
ORDER BY t.created_at DESC
@@ -92,6 +107,9 @@ export async function listRecentTransactions(
amountCents: Number(row.amount_cents),
occurredAt: Number(row.occurred_at),
accountId: row.account_id,
+ accountName: row.account_name,
+ accountLogoKey: row.account_logo_key ?? null,
+ accountColorHex: row.account_color_hex,
categoryName: row.category_name ?? null,
categoryIcon: row.category_icon ?? null,
categoryKind: row.category_kind ?? null,
@@ -202,8 +220,7 @@ export async function deleteTransaction(id: string): Promise {
/**
* Daily expense/income totals for every day in `range` (local timezone),
- * zero-filled so days without transactions still appear. Used by the
- * dashboard's weekly activity chart.
+ * zero-filled so days without transactions still appear.
*/
export async function getDailyTotals(
range: MonthRange,
@@ -257,3 +274,85 @@ export async function getDailyTotals(
}
return totals;
}
+
+/**
+ * Expense totals grouped by account for `range` (positive cents), ordered
+ * largest first. Feeds the "spent" side of the dashboard's interactive
+ * weekly activity pie chart. Scoped to a single account when `accountId`
+ * is provided.
+ */
+export async function getExpenseByAccount(
+ range: MonthRange,
+ accountId?: string,
+): Promise {
+ const db = await getDb();
+ const params: (string | number)[] = [range.start, range.end];
+ const accountClause = accountId ? "AND t.account_id = ?" : "";
+ if (accountId) params.push(accountId);
+
+ const { rows } = await db.execute(
+ `SELECT a.id AS account_id,
+ a.name AS account_name,
+ a.logo_key AS account_logo_key,
+ a.color_hex AS account_color_hex,
+ SUM(-t.amount_cents) AS total_cents
+ FROM transactions t
+ JOIN accounts a ON a.id = t.account_id
+ WHERE t.amount_cents < 0
+ AND t.occurred_at >= ?
+ AND t.occurred_at < ?
+ ${accountClause}
+ GROUP BY a.id
+ ORDER BY total_cents DESC;`,
+ params,
+ );
+
+ return (rows as unknown as AccountAmountRow[]).map((row) => ({
+ accountId: row.account_id,
+ accountName: row.account_name,
+ accountLogoKey: row.account_logo_key ?? null,
+ accountColorHex: row.account_color_hex,
+ amountCents: Number(row.total_cents),
+ }));
+}
+
+/**
+ * Income totals grouped by account for `range` (positive cents), ordered
+ * largest first. Feeds the "earned" side of the dashboard's interactive
+ * weekly activity pie chart. Scoped to a single account when `accountId`
+ * is provided.
+ */
+export async function getIncomeByAccount(
+ range: MonthRange,
+ accountId?: string,
+): Promise {
+ const db = await getDb();
+ const params: (string | number)[] = [range.start, range.end];
+ const accountClause = accountId ? "AND t.account_id = ?" : "";
+ if (accountId) params.push(accountId);
+
+ const { rows } = await db.execute(
+ `SELECT a.id AS account_id,
+ a.name AS account_name,
+ a.logo_key AS account_logo_key,
+ a.color_hex AS account_color_hex,
+ SUM(t.amount_cents) AS total_cents
+ FROM transactions t
+ JOIN accounts a ON a.id = t.account_id
+ WHERE t.amount_cents > 0
+ AND t.occurred_at >= ?
+ AND t.occurred_at < ?
+ ${accountClause}
+ GROUP BY a.id
+ ORDER BY total_cents DESC;`,
+ params,
+ );
+
+ return (rows as unknown as AccountAmountRow[]).map((row) => ({
+ accountId: row.account_id,
+ accountName: row.account_name,
+ accountLogoKey: row.account_logo_key ?? null,
+ accountColorHex: row.account_color_hex,
+ amountCents: Number(row.total_cents),
+ }));
+}
diff --git a/src/types/hooks.ts b/src/types/hooks.ts
index 3d77ee5..835b7ae 100644
--- a/src/types/hooks.ts
+++ b/src/types/hooks.ts
@@ -14,7 +14,7 @@
import type { AccountWithBalance } from "./account";
import type { Category, CategorySpend } from "./category";
import type {
- DailyTotals,
+ AccountAmountSlice,
RecentTransaction,
TransactionLedgerItem,
} from "./transaction";
@@ -53,7 +53,10 @@ export interface DashboardData {
categories: CategorySpend[];
totalExpenseCents: number;
totalIncomeCents: number;
- dailyTotals: DailyTotals[];
+ /** Per-account expense totals for the last 7 days (pie "spent" side). */
+ spentByAccount: AccountAmountSlice[];
+ /** Per-account income totals for the last 7 days (pie "earned" side). */
+ earnedByAccount: AccountAmountSlice[];
recentTransactions: RecentTransaction[];
}
diff --git a/src/types/transaction.ts b/src/types/transaction.ts
index 5f9e0b7..8102971 100644
--- a/src/types/transaction.ts
+++ b/src/types/transaction.ts
@@ -27,11 +27,30 @@ export interface RecentTransaction {
amountCents: number;
occurredAt: number;
accountId: string;
+ /** Account name — used for the badge initials fallback. */
+ accountName: string;
+ /** Account logo key ("bank/…" or "custom/…"), resolved via resolveLogoSrc(). */
+ accountLogoKey: string | null;
+ /** Account badge color — fallback background when there is no logo. */
+ accountColorHex: string;
categoryName: string | null;
categoryIcon: string | null;
categoryKind: CategoryKind | null;
}
+/**
+ * Per-account income or expense total for a date range — one row of the
+ * dashboard's weekly activity pie chart detail. `amountCents` is positive
+ * (absolute amount).
+ */
+export interface AccountAmountSlice {
+ accountId: string;
+ accountName: string;
+ accountLogoKey: string | null;
+ accountColorHex: string;
+ amountCents: number;
+}
+
/**
* Full transaction row for the ledger view — includes account name and note
* in addition to the fields exposed by RecentTransaction.