diff --git a/src/components/GovernanceDashboard.test.tsx b/src/components/GovernanceDashboard.test.tsx index d9d62ee..a4c9c97 100644 --- a/src/components/GovernanceDashboard.test.tsx +++ b/src/components/GovernanceDashboard.test.tsx @@ -47,7 +47,10 @@ function makeClient(overrides?: { delegate?: unknown; voteError?: string | null; delegateError?: string | null; + revokeError?: string | null; + voteThrows?: string; }) { + let currentDelegate = overrides?.delegate ?? null; const invokeContract = vi.fn(async (params: InvokeParams) => { switch (params.method) { case "get_proposals": @@ -58,11 +61,19 @@ function makeClient(overrides?: { }; case "get_delegate": return { - data: overrides?.delegate ?? null, + data: currentDelegate, error: null, status: "success" as const, }; + case "revoke_delegation": + if (!overrides?.revokeError) currentDelegate = null; + return { + data: overrides?.revokeError ? null : { hash: "tx_revoke" }, + error: overrides?.revokeError ?? null, + status: overrides?.revokeError ? ("error" as const) : ("success" as const), + }; case "vote": + if (overrides?.voteThrows) throw new Error(overrides.voteThrows); return { data: overrides?.voteError ? null : { hash: "tx_vote" }, error: overrides?.voteError ?? null, @@ -316,4 +327,162 @@ describe("GovernanceDashboard", () => { ); }); }); + // ─── Closed / executed proposals ─────────────────────────────────────────── + describe("closed proposals", () => { + const withStatus = (id: string, status: string): GovernanceProposal => ({ + id, + title: `Proposal ${status}`, + description: `A ${status} proposal.`, + status, + votesFor: 10, + votesAgainst: 5, + votesAbstain: 1, + }); + + it.each(["closed", "executed"])("disables every vote button for a %s proposal", async (status) => { + const { client, invokeContract } = makeClient({ proposals: [withStatus("9", status)] }); + mockSorokit(client); + + render(); + + const card = (await screen.findByText(`Proposal ${status}`)).closest('[role="listitem"]') as HTMLElement; + expect(within(card).getByText(status)).toBeInTheDocument(); + expect(within(card).getByRole("button", { name: "Vote Yes" })).toBeDisabled(); + expect(within(card).getByRole("button", { name: "Vote No" })).toBeDisabled(); + expect(within(card).getByRole("button", { name: "Vote Abstain" })).toBeDisabled(); + expect(within(card).getByTestId("voting-closed-note")).toHaveTextContent("Voting closed"); + + fireEvent.click(within(card).getByRole("button", { name: "Vote Yes" })); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + expect(invokeContract).not.toHaveBeenCalledWith(expect.objectContaining({ method: "vote" })); + }); + + it("keeps voting enabled on an active proposal listed next to a closed one", async () => { + const { client } = makeClient({ + proposals: [PROPOSALS[0], withStatus("9", "closed")], + }); + mockSorokit(client); + + render(); + + await screen.findByText("Proposal closed"); + const [active, closed] = screen.getAllByRole("listitem"); + expect(within(active).getByRole("button", { name: "Vote Yes" })).toBeEnabled(); + expect(within(active).queryByTestId("voting-closed-note")).not.toBeInTheDocument(); + expect(within(closed).getByRole("button", { name: "Vote Yes" })).toBeDisabled(); + }); + }); + + // ─── Vote failure feedback ───────────────────────────────────────────────── + describe("vote failures", () => { + async function openConfirm(client: SorokitClient) { + mockSorokit(client); + render(); + await screen.findByText("Increase validator rewards"); + fireEvent.click(within(screen.getAllByRole("listitem")[0]).getByRole("button", { name: "Vote Yes" })); + return screen.findByRole("dialog"); + } + + it("shows an error banner, no success notice, and allows retrying", async () => { + const { client, invokeContract } = makeClient({ voteError: "tx_failed" }); + const dialog = await openConfirm(client); + + fireEvent.click(within(dialog).getByRole("button", { name: /confirm vote/i })); + + await waitFor(() => expect(within(dialog).getByRole("alert")).toBeInTheDocument()); + expect(screen.queryByRole("status")).not.toBeInTheDocument(); + expect(within(dialog).getByRole("button", { name: /confirm vote/i })).toBeEnabled(); + + fireEvent.click(within(dialog).getByRole("button", { name: /confirm vote/i })); + await waitFor(() => + expect(invokeContract.mock.calls.filter(([p]) => p.method === "vote")).toHaveLength(2), + ); + }); + + it("shows an error banner when the contract invocation throws", async () => { + const { client } = makeClient({ voteThrows: "network down" }); + const dialog = await openConfirm(client); + + fireEvent.click(within(dialog).getByRole("button", { name: /confirm vote/i })); + + await waitFor(() => expect(within(dialog).getByRole("alert")).toBeInTheDocument()); + expect(screen.getByRole("dialog")).toBeInTheDocument(); + expect(screen.queryByRole("status")).not.toBeInTheDocument(); + expect(within(dialog).getByRole("button", { name: /confirm vote/i })).toBeEnabled(); + }); + + it("clears the error when the dialog is cancelled and reopened", async () => { + const { client } = makeClient({ voteError: "tx_failed" }); + const dialog = await openConfirm(client); + + fireEvent.click(within(dialog).getByRole("button", { name: /confirm vote/i })); + await waitFor(() => expect(within(dialog).getByRole("alert")).toBeInTheDocument()); + + fireEvent.click(within(dialog).getByRole("button", { name: /cancel/i })); + await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument()); + + fireEvent.click(within(screen.getAllByRole("listitem")[0]).getByRole("button", { name: "Vote No" })); + const reopened = await screen.findByRole("dialog"); + expect(within(reopened).queryByRole("alert")).not.toBeInTheDocument(); + }); + }); + + // ─── Delegation revocation ───────────────────────────────────────────────── + describe("delegation revocation", () => { + it("hides the revoke button when there is no delegate", async () => { + const { client } = makeClient({ delegate: null }); + mockSorokit(client); + + render(); + + await screen.findByTestId("current-delegate"); + expect(screen.queryByRole("button", { name: /revoke delegation/i })).not.toBeInTheDocument(); + }); + + it("revokes the current delegate and returns to self-voting", async () => { + const user = userEvent.setup(); + const { client, invokeContract } = makeClient({ delegate: VALID_ADDRESS }); + mockSorokit(client); + + render(); + + await waitFor(() => + expect(screen.getByTestId("current-delegate")).toHaveAttribute("title", VALID_ADDRESS), + ); + await user.click(screen.getByRole("button", { name: /revoke delegation/i })); + + await waitFor(() => + expect(invokeContract).toHaveBeenCalledWith( + expect.objectContaining({ + method: "revoke_delegation", + sourceAccount: VALID_ADDRESS, + }), + ), + ); + await waitFor(() => + expect(screen.getByRole("status")).toHaveTextContent(/delegation revoked/i), + ); + await waitFor(() => + expect(screen.getByTestId("current-delegate")).toHaveTextContent(/none/i), + ); + expect(screen.queryByRole("button", { name: /revoke delegation/i })).not.toBeInTheDocument(); + }); + + it("shows an error and keeps the delegate when revocation fails", async () => { + const user = userEvent.setup(); + const { client } = makeClient({ delegate: VALID_ADDRESS, revokeError: "tx_failed" }); + mockSorokit(client); + + render(); + + await waitFor(() => + expect(screen.getByTestId("current-delegate")).toHaveAttribute("title", VALID_ADDRESS), + ); + await user.click(screen.getByRole("button", { name: /revoke delegation/i })); + + await waitFor(() => expect(screen.getByRole("alert")).toBeInTheDocument()); + expect(screen.getByTestId("current-delegate")).toHaveAttribute("title", VALID_ADDRESS); + expect(screen.queryByRole("status")).not.toBeInTheDocument(); + }); + }); }); diff --git a/src/components/GovernanceDashboard.tsx b/src/components/GovernanceDashboard.tsx index 7602222..f8e7514 100644 --- a/src/components/GovernanceDashboard.tsx +++ b/src/components/GovernanceDashboard.tsx @@ -44,8 +44,21 @@ export type ProposalStatus = | "rejected" | "pending" | "executed" + | "closed" | (string & {}); +/** Statuses after which voting is over and no new votes can be cast. */ +const VOTING_CLOSED_STATUSES: ReadonlySet = new Set([ + "closed", + "executed", + "passed", + "rejected", +]); + +export function isVotingClosed(status: ProposalStatus): boolean { + return VOTING_CLOSED_STATUSES.has(status); +} + export interface GovernanceProposal { id: string; title: string; @@ -63,7 +76,7 @@ export type VoteChoice = "yes" | "no" | "abstain"; export interface GovernanceDashboardProps { /** * Governance contract ID that exposes `get_proposals`, `get_delegate`, - * `vote`, and `delegate` methods. Defaults to a placeholder so the panel is + * `vote`, `delegate`, and `revoke_delegation` methods. Defaults to a placeholder so the panel is * usable in demos before a real contract is wired up. */ contractId?: string; @@ -133,6 +146,7 @@ function statusVariant( return "primary"; case "pending": return "warning"; + case "closed": default: return "default"; } @@ -173,7 +187,9 @@ interface ProposalCardProps { onVote: (proposal: GovernanceProposal, choice: VoteChoice) => void; } -function ProposalCard({ proposal, disabled, onVote }: ProposalCardProps) { +function ProposalCard({ proposal, disabled: walletDisabled, onVote }: ProposalCardProps) { + const votingClosed = isVotingClosed(proposal.status); + const disabled = walletDisabled || votingClosed; return (
Vote Abstain + {votingClosed && ( + + Voting closed + + )}
); @@ -334,6 +355,7 @@ interface DelegationPanelProps { submitting: boolean; error: string | null; onDelegate: (target: string) => void; + onRevoke: () => void; } function DelegationPanel({ @@ -342,6 +364,7 @@ function DelegationPanel({ submitting, error, onDelegate, + onRevoke, }: DelegationPanelProps) { const [value, setValue] = useState(""); const trimmed = value.trim(); @@ -395,6 +418,17 @@ function DelegationPanel({ > {submitting ? "Delegating…" : "Delegate votes"} + {currentDelegate && ( + + )} ); @@ -498,12 +532,17 @@ export function GovernanceDashboard({ setVoting(true); setVoteError(null); - const { error } = await client.soroban.invokeContract({ - contractId, - method: "vote", - args: [pendingProposal.id, pendingChoice], - sourceAccount: address ?? undefined, - }); + let error: string | null; + try { + ({ error } = await client.soroban.invokeContract({ + contractId, + method: "vote", + args: [pendingProposal.id, pendingChoice], + sourceAccount: address ?? undefined, + })); + } catch (e) { + error = e instanceof Error ? e.message : "Unknown error"; + } setVoting(false); @@ -547,6 +586,30 @@ export function GovernanceDashboard({ [client, contractId, address], ); + const revokeDelegation = useCallback(async () => { + if (!client) return; + setDelegating(true); + setDelegateError(null); + + const { error } = await client.soroban.invokeContract({ + contractId, + method: "revoke_delegation", + args: [], + sourceAccount: address ?? undefined, + }); + + setDelegating(false); + + if (error) { + setDelegateError(friendlyError(error)); + return; + } + + setDelegate(null); + setNotice("Delegation revoked. You are voting for yourself again."); + setReloadKey((k) => k + 1); + }, [client, contractId, address]); + return (
@@ -627,6 +690,7 @@ export function GovernanceDashboard({ submitting={delegating} error={delegateError} onDelegate={submitDelegation} + onRevoke={() => void revokeDelegation()} /> {/* Vote confirmation */} diff --git a/src/components/NFTGallery.test.tsx b/src/components/NFTGallery.test.tsx index 75f2252..7834b8d 100644 --- a/src/components/NFTGallery.test.tsx +++ b/src/components/NFTGallery.test.tsx @@ -396,6 +396,91 @@ describe("NFTCard", () => { }); }); +// ─── NFTCard — IPFS gateway resolution & fallback ──────────────────────────── + +describe("NFTCard — IPFS gateway resolution", () => { + const CID_V0 = "QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG"; + const CID_V1 = "bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi"; + + const renderCard = (image: string | undefined) => + render( + + ); + + beforeEach(() => vi.clearAllMocks()); + + it("converts an ipfs:// CIDv1 URI to the primary gateway URL", () => { + renderCard(`ipfs://${CID_V1}`); + expect(screen.getByAltText("Cool Cat #1")).toHaveAttribute("src", `https://ipfs.io/ipfs/${CID_V1}`); + }); + + it("converts an ipfs:// CIDv0 URI with a nested path", () => { + renderCard(`ipfs://${CID_V0}/images/1.png`); + expect(screen.getByAltText("Cool Cat #1")).toHaveAttribute( + "src", + `https://ipfs.io/ipfs/${CID_V0}/images/1.png` + ); + }); + + it("handles the legacy ipfs://ipfs/ form without duplicating the prefix", () => { + renderCard(`ipfs://ipfs/${CID_V1}/cat.png`); + expect(screen.getByAltText("Cool Cat #1")).toHaveAttribute( + "src", + `https://ipfs.io/ipfs/${CID_V1}/cat.png` + ); + }); + + it("does not rewrite regular https image URLs", () => { + renderCard("https://example.com/cat1.png"); + expect(screen.getByAltText("Cool Cat #1")).toHaveAttribute("src", "https://example.com/cat1.png"); + }); + + it("does not retry other gateways for a failing non-IPFS image", () => { + renderCard("https://example.com/missing.png"); + fireEvent.error(screen.getByAltText("Cool Cat #1")); + expect(screen.queryByAltText("Cool Cat #1")).not.toBeInTheDocument(); + expect(screen.getByText("No image")).toBeInTheDocument(); + }); + + it("falls back to the placeholder after every gateway fails (404 / network error)", () => { + renderCard(`ipfs://${CID_V1}`); + + const expected = [ + "https://ipfs.io/ipfs/", + "https://cloudflare-ipfs.com/ipfs/", + "https://gateway.pinata.cloud/ipfs/", + "https://dweb.link/ipfs/", + ]; + for (const gateway of expected) { + const img = screen.getByAltText("Cool Cat #1"); + expect(img).toHaveAttribute("src", `${gateway}${CID_V1}`); + fireEvent.error(img); + } + + expect(screen.queryByAltText("Cool Cat #1")).not.toBeInTheDocument(); + expect(screen.getByText("No image")).toBeInTheDocument(); + }); + + it("keeps the image when a later gateway loads successfully", () => { + renderCard(`ipfs://${CID_V1}`); + + fireEvent.error(screen.getByAltText("Cool Cat #1")); + const img = screen.getByAltText("Cool Cat #1"); + expect(img).toHaveAttribute("src", `https://cloudflare-ipfs.com/ipfs/${CID_V1}`); + + fireEvent.load(img); + expect(screen.getByAltText("Cool Cat #1")).toBeInTheDocument(); + expect(screen.queryByText("No image")).not.toBeInTheDocument(); + }); +}); + // ─── NFTGallery — wallet not connected ─────────────────────────────────────── describe("NFTGallery — not connected", () => { @@ -480,6 +565,17 @@ describe("NFTGallery — empty collection", () => { }); }); + it("renders only the empty message — no cards, grid or skeletons", async () => { + render(); + await waitFor(() => { + expect(screen.getByText("No NFTs found in this wallet")).toBeInTheDocument(); + }); + expect(screen.queryByText(/no nfts match your filters/i)).not.toBeInTheDocument(); + expect(screen.queryAllByTestId("nft-card")).toHaveLength(0); + expect(screen.queryByRole("list", { name: /nft grid/i })).not.toBeInTheDocument(); + expect(screen.queryByTestId("nft-loading-skeleton")).not.toBeInTheDocument(); + }); + it("does not show Select button with empty collection", async () => { render(); await waitFor(() => { diff --git a/src/components/NFTGallery.tsx b/src/components/NFTGallery.tsx index ca1512e..1ed0d4c 100644 --- a/src/components/NFTGallery.tsx +++ b/src/components/NFTGallery.tsx @@ -306,12 +306,12 @@ export function NFTCard({ nft, selected, bulkMode, onSelect, onSend, onList }: N {nft.metadata.attributes.slice(0, 3).map((attr) => ( - {attr.value} + {attr.value} {attr.rarityPct !== undefined && ( - {attr.rarityPct.toFixed(0)}% + {attr.rarityPct.toFixed(0)}% )} ))} @@ -835,9 +835,9 @@ function NFTDetailDialog({ nft, open, onClose, onSend, onList }: NFTDetailDialog

{nft.metadata.attributes.map((attr) => ( -
-

{attr.traitType}

-

{attr.value}

+
+

{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"} +
+

+ Period started {formatUtcDate(periodStart)} +

setXlmAmountInput(e.target.value)} + /> +

+ ≈ ${xlmAsUsd.toFixed(2)} USD (1 XLM = ${XLM_USD_RATE}) +

+
+