+
{attr.traitType}
+
{attr.value}
{attr.rarityPct !== undefined && (
{attr.rarityPct.toFixed(1)}% have this
)}
diff --git a/src/lib/budget.test.ts b/src/lib/budget.test.ts
index e149ed6..063cbcd 100644
--- a/src/lib/budget.test.ts
+++ b/src/lib/budget.test.ts
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
-import { computeBudgetBreakdown } from "./budget";
+import { computeBudgetBreakdown, convertXlmToUsd, formatUtcDate } from "./budget";
import type { Transaction } from "./client";
const txs: Transaction[] = [
@@ -59,3 +59,28 @@ describe("computeBudgetBreakdown", () => {
);
});
});
+
+describe("formatUtcDate", () => {
+ it("formats the UTC calendar date, not the local one", () => {
+ // Local-time formatting would print Dec 31, 2025 in timezones west of UTC.
+ expect(formatUtcDate("2026-01-01T00:00:00Z")).toBe("Jan 1, 2026");
+ expect(formatUtcDate("2026-03-15T23:59:59Z")).toBe("Mar 15, 2026");
+ });
+
+ it("returns a placeholder for invalid input", () => {
+ expect(formatUtcDate("not-a-date")).toBe("—");
+ });
+});
+
+describe("convertXlmToUsd", () => {
+ it("multiplies by the rate and rounds to cents", () => {
+ expect(convertXlmToUsd(1000, 0.12)).toBe(120);
+ expect(convertXlmToUsd(12.345, 0.12)).toBe(1.48);
+ });
+
+ it("returns 0 for invalid or negative input", () => {
+ expect(convertXlmToUsd(Number.NaN, 0.12)).toBe(0);
+ expect(convertXlmToUsd(-5, 0.12)).toBe(0);
+ expect(convertXlmToUsd(10, -1)).toBe(0);
+ });
+});
diff --git a/src/lib/budget.ts b/src/lib/budget.ts
index 9e0119b..bc70ba2 100644
--- a/src/lib/budget.ts
+++ b/src/lib/budget.ts
@@ -37,3 +37,24 @@ export function computeBudgetBreakdown(
.sort((a, b) => b.amount - a.amount),
};
}
+
+/**
+ * Format an ISO timestamp as a calendar date in UTC ("Jan 1, 2026"). Always
+ * UTC so the output never shifts a day for users west of Greenwich.
+ */
+export function formatUtcDate(iso: string): string {
+ const date = new Date(iso);
+ if (Number.isNaN(date.getTime())) return "—";
+ return new Intl.DateTimeFormat("en-US", {
+ timeZone: "UTC",
+ year: "numeric",
+ month: "short",
+ day: "numeric",
+ }).format(date);
+}
+
+/** Convert an XLM amount to USD at `rate` (USD per 1 XLM), rounded to cents. */
+export function convertXlmToUsd(xlm: number, rate: number): number {
+ if (!Number.isFinite(xlm) || !Number.isFinite(rate) || xlm < 0 || rate < 0) return 0;
+ return Math.round(xlm * rate * 100) / 100;
+}
diff --git a/src/screens/BudgetScreen.test.tsx b/src/screens/BudgetScreen.test.tsx
index e1951ba..be520d7 100644
--- a/src/screens/BudgetScreen.test.tsx
+++ b/src/screens/BudgetScreen.test.tsx
@@ -1,5 +1,5 @@
import { act,fireEvent, render, screen } from "@testing-library/react";
-import { describe, expect, it, vi } from "vitest";
+import { afterEach, describe, expect, it, vi } from "vitest";
import { BudgetScreen } from "./BudgetScreen";
@@ -86,4 +86,70 @@ describe("BudgetScreen", () => {
expect(screen.getByText("Limit Exceeded")).toBeInTheDocument();
expect(screen.getByTestId("budget-alert-banner")).toHaveTextContent(/Budget exceeded/);
});
+ describe("reset, clear and conversion", () => {
+ afterEach(() => vi.useRealTimers());
+
+ it("shows the period start date in UTC", () => {
+ render(
);
+ expect(screen.getByTestId("period-start")).toHaveTextContent("Period started Jan 1, 2026");
+ });
+
+ it("resets spending to zero and stamps the reset date in UTC regardless of local timezone", () => {
+ // 00:30 UTC is still the previous day for any timezone west of UTC.
+ vi.useFakeTimers({ toFake: ["Date"] });
+ vi.setSystemTime(new Date("2026-03-15T00:30:00Z"));
+
+ render(
);
+ expect(screen.getByText(/85\.3% of budget utilized/)).toBeInTheDocument();
+
+ fireEvent.click(screen.getByRole("button", { name: "Reset budget" }));
+
+ expect(screen.getByText(/0\.0% of budget utilized/)).toBeInTheDocument();
+ expect(screen.getByTestId("period-start")).toHaveTextContent("Period started Mar 15, 2026");
+ // Limit and settings are untouched by a reset
+ expect(screen.getByLabelText("Set Budget Limit ($)")).toHaveValue(1500);
+ expect(screen.getByTestId("budget-limit-display")).toHaveTextContent("/ $1500");
+ });
+
+ it("clears the budget limit and asks for a new one", () => {
+ render(
);
+
+ fireEvent.click(screen.getByRole("button", { name: "Clear budget" }));
+
+ expect(screen.getByLabelText("Set Budget Limit ($)")).toHaveValue(null);
+ expect(screen.getByText("Budget limit is required.")).toBeInTheDocument();
+ expect(screen.getByTestId("budget-limit-display")).toHaveTextContent("No limit set");
+ expect(screen.getByText(/0\.0% of budget utilized/)).toBeInTheDocument();
+ expect(screen.queryByTestId("budget-alert-banner")).not.toBeInTheDocument();
+ });
+
+ it("accepts a new limit after the budget was cleared", () => {
+ render(
);
+ fireEvent.click(screen.getByRole("button", { name: "Clear budget" }));
+
+ fireEvent.change(screen.getByLabelText("Set Budget Limit ($)"), { target: { value: "2000" } });
+
+ expect(screen.queryByText("Budget limit is required.")).not.toBeInTheDocument();
+ expect(screen.getByTestId("budget-limit-display")).toHaveTextContent("/ $2000");
+ expect(screen.getByText(/64\.0% of budget utilized/)).toBeInTheDocument();
+ });
+
+ it("converts XLM to USD at the configured rate", () => {
+ render(
);
+ const result = screen.getByTestId("xlm-usd-result");
+ expect(result).toHaveTextContent("≈ $0.00 USD");
+
+ fireEvent.change(screen.getByLabelText("Convert XLM to USD"), { target: { value: "1000" } });
+ expect(result).toHaveTextContent("≈ $120.00 USD (1 XLM = $0.12)");
+
+ fireEvent.change(screen.getByLabelText("Convert XLM to USD"), { target: { value: "12.345" } });
+ expect(result).toHaveTextContent("≈ $1.48 USD");
+ });
+
+ it("treats negative XLM amounts as zero", () => {
+ render(
);
+ fireEvent.change(screen.getByLabelText("Convert XLM to USD"), { target: { value: "-50" } });
+ expect(screen.getByTestId("xlm-usd-result")).toHaveTextContent("≈ $0.00 USD");
+ });
+ });
});
diff --git a/src/screens/BudgetScreen.tsx b/src/screens/BudgetScreen.tsx
index 1022d78..e2260d8 100644
--- a/src/screens/BudgetScreen.tsx
+++ b/src/screens/BudgetScreen.tsx
@@ -10,7 +10,7 @@ import {
CardTitle,
} from "@/components/ui/Card";
import { Input } from "@/components/ui/Input";
-import { computeBudgetBreakdown } from "@/lib/budget";
+import { computeBudgetBreakdown, convertXlmToUsd, formatUtcDate } from "@/lib/budget";
type AssetBreakdown = {
asset: string;
@@ -30,6 +30,12 @@ const CHART_COLORS = [
/** Spending at or above this share of the limit is flagged as critical. */
const CRITICAL_THRESHOLD = 90;
+/** Mock XLM price in USD; replace with a live price feed. */
+const XLM_USD_RATE = 0.12;
+
+const DEFAULT_LIMIT = 1500;
+const DEFAULT_PERIOD_START = "2026-01-01T00:00:00Z";
+
/** Returns an inline error message for an invalid budget limit, or null when valid. */
function validateBudgetLimit(raw: string): string | null {
if (raw.trim() === "") return "Budget limit is required.";
@@ -44,12 +50,15 @@ export function BudgetScreen() {
const [period, setPeriod] = useState<"daily" | "weekly" | "monthly">(
"monthly",
);
- const [budgetLimitInput, setBudgetLimitInput] = useState("1500");
- const [lastValidLimit, setLastValidLimit] = useState(1500);
+ const [budgetLimitInput, setBudgetLimitInput] = useState(String(DEFAULT_LIMIT));
+ const [lastValidLimit, setLastValidLimit] = useState(DEFAULT_LIMIT);
const budgetLimitError = validateBudgetLimit(budgetLimitInput);
// Invalid input never reaches the calculations; keep the last valid limit.
const budgetLimit = lastValidLimit;
- const [currentSpent] = useState(1280);
+ const [currentSpent, setCurrentSpent] = useState(1280);
+ const [periodStart, setPeriodStart] = useState(DEFAULT_PERIOD_START);
+ const [xlmAmountInput, setXlmAmountInput] = useState("");
+ const xlmAsUsd = convertXlmToUsd(Number(xlmAmountInput), XLM_USD_RATE);
const [warningThreshold, setWarningThreshold] = useState(80); // 80% default
const [lockTransactions, setLockTransactions] = useState(false);
@@ -105,13 +114,26 @@ export function BudgetScreen() {
}));
// Calculations
- const percentageUsed = (currentSpent / budgetLimit) * 100;
+ const hasBudget = budgetLimit > 0;
+ const percentageUsed = hasBudget ? (currentSpent / budgetLimit) * 100 : 0;
const isOverBudget = percentageUsed >= 100;
const isCriticalLimit =
percentageUsed >= CRITICAL_THRESHOLD && !isOverBudget;
const isApproachingLimit =
percentageUsed >= warningThreshold && percentageUsed < CRITICAL_THRESHOLD;
+ /** Start a fresh period: zero out spending and stamp the reset time (UTC). */
+ const handleResetBudget = () => {
+ setCurrentSpent(0);
+ setPeriodStart(new Date().toISOString());
+ };
+
+ /** Remove the budget limit entirely; the user must enter a new one. */
+ const handleClearBudget = () => {
+ setBudgetLimitInput("");
+ setLastValidLimit(0);
+ };
+
// Export report simulation
const [exportMessage, setExportMessage] = useState("");
@@ -158,8 +180,13 @@ export function BudgetScreen() {
${currentSpent}
-
/ ${budgetLimit}
+
+ {hasBudget ? `/ $${budgetLimit}` : "No limit set"}
+