From a74474a759cceb3d4164a95b3b809b9363e823d4 Mon Sep 17 00:00:00 2001 From: adetomiwa21 Date: Sun, 30 Aug 2026 17:08:46 +0100 Subject: [PATCH 01/11] feat: add ProgressRing SVG component with emerald fill and CSS transition Implements an interactive SVG progress ring using stroke-dasharray and stroke-dashoffset for smooth CSS transition animation. Uses brand emerald green (#10B981) for the filled arc and muted gray (#E5E7EB) for the track. Double rAF on mount triggers the fill-up animation from 0 to progress value. --- .../dashboard/ProgressRing/ProgressRing.tsx | 88 +++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 src/components/dashboard/ProgressRing/ProgressRing.tsx diff --git a/src/components/dashboard/ProgressRing/ProgressRing.tsx b/src/components/dashboard/ProgressRing/ProgressRing.tsx new file mode 100644 index 0000000..396e18d --- /dev/null +++ b/src/components/dashboard/ProgressRing/ProgressRing.tsx @@ -0,0 +1,88 @@ +"use client"; + +import React, { useEffect, useState } from "react"; + +interface ProgressRingProps { + /** Completion percentage from 0 to 100. */ + progress: number; + /** Logical size of the SVG viewBox (pixels). Defaults to 120. */ + size?: number; + /** Stroke width in viewBox units. Defaults to 10. */ + strokeWidth?: number; + className?: string; + children?: React.ReactNode; +} + +const TRACK_COLOR = "#E5E7EB"; +const FILL_COLOR = "#10B981"; + +export function ProgressRing({ + progress, + size = 120, + strokeWidth = 10, + className = "", + children, +}: ProgressRingProps) { + const clamped = Math.min(100, Math.max(0, progress)); + const center = size / 2; + const radius = center - strokeWidth / 2; + const circumference = 2 * Math.PI * radius; + const targetOffset = circumference * (1 - clamped / 100); + + // Start fully empty so CSS transition animates fill-up on mount. + const [offset, setOffset] = useState(circumference); + + useEffect(() => { + // Double rAF ensures the initial empty state is painted before the + // transition to the real offset begins. + let inner: number; + const outer = requestAnimationFrame(() => { + inner = requestAnimationFrame(() => setOffset(targetOffset)); + }); + return () => { + cancelAnimationFrame(outer); + cancelAnimationFrame(inner); + }; + }, [targetOffset]); + + return ( +
+ + {/* Track */} + + {/* Progress arc */} + + + {children !== undefined && ( +
+ {children} +
+ )} +
+ ); +} From 79a595c7c6925197552c2f4410526fdf733ab2a5 Mon Sep 17 00:00:00 2001 From: adetomiwa21 Date: Sun, 30 Aug 2026 17:09:19 +0100 Subject: [PATCH 02/11] feat: add ProgressRing barrel export --- src/components/dashboard/ProgressRing/index.ts | 1 + 1 file changed, 1 insertion(+) create mode 100644 src/components/dashboard/ProgressRing/index.ts diff --git a/src/components/dashboard/ProgressRing/index.ts b/src/components/dashboard/ProgressRing/index.ts new file mode 100644 index 0000000..6e6d23f --- /dev/null +++ b/src/components/dashboard/ProgressRing/index.ts @@ -0,0 +1 @@ +export { ProgressRing } from "./ProgressRing"; From 6ed535320b9e6f329effb68b79a7d1f92e264b51 Mon Sep 17 00:00:00 2001 From: adetomiwa21 Date: Sun, 30 Aug 2026 17:09:24 +0100 Subject: [PATCH 03/11] feat: replace flat progress bar with ProgressRing in SavingsCircles Integrates the new ProgressRing into each savings group card, showing the completion percentage centred inside the ring with an 'of goal' label. Updates the loading skeleton to match the ring-based card layout. --- .../SavingsCircles/SavingsCircles.tsx | 123 +++++++++++------- 1 file changed, 77 insertions(+), 46 deletions(-) diff --git a/src/components/dashboard/SavingsCircles/SavingsCircles.tsx b/src/components/dashboard/SavingsCircles/SavingsCircles.tsx index e15ac47..ac11f03 100644 --- a/src/components/dashboard/SavingsCircles/SavingsCircles.tsx +++ b/src/components/dashboard/SavingsCircles/SavingsCircles.tsx @@ -1,15 +1,19 @@ -'use client'; +"use client"; -import React from 'react'; -import { Home, Plane, Plus, ChevronRight } from 'lucide-react'; -import type { SavingsGroup } from '@/hooks/useGroups'; +import React from "react"; +import { Home, Plane, Plus, ChevronRight } from "lucide-react"; +import type { SavingsGroup } from "@/hooks/useGroups"; +import { ProgressRing } from "@/components/dashboard/ProgressRing"; interface SavingsCirclesProps { groups: SavingsGroup[]; isLoading?: boolean; } -export function SavingsCircles({ groups, isLoading = false }: SavingsCirclesProps) { +export function SavingsCircles({ + groups, + isLoading = false, +}: SavingsCirclesProps) { if (isLoading) { return (
@@ -18,15 +22,21 @@ export function SavingsCircles({ groups, isLoading = false }: SavingsCirclesProp
{[1, 2, 3].map((i) => ( -
-
-
+
+
+
-
-
-
-
+
+
+
+
+
+
+
@@ -42,7 +52,9 @@ export function SavingsCircles({ groups, isLoading = false }: SavingsCirclesProp return (
-

My Savings Circles

+

+ My Savings Circles +

@@ -51,53 +63,70 @@ export function SavingsCircles({ groups, isLoading = false }: SavingsCirclesProp
{groups.map((group) => { const progress = Math.min((group.saved / group.target) * 100, 100); - + // Helper to get icon based on tag const getIcon = () => { - if (group.tag.includes('HOME')) return ; - if (group.tag.includes('TRAVEL')) return ; + if (group.tag.includes("HOME")) + return ; + if (group.tag.includes("TRAVEL")) + return ; return ; }; return ( -
-
-
-
- {getIcon()} -
- - {group.tag} - -
- -

{group.name}

-
- ${group.saved.toLocaleString()} saved - {Math.round(progress)}% +
+
+
+ {getIcon()}
- - {/* Progress bar */} -
-
+ + {group.tag} + +
+ + +
+

+ {Math.round(progress)}% +

+

+ of goal +

+
+ +
+

+ {group.name} +

+

+ ${group.saved.toLocaleString()} saved +

-
+
Next: {group.nextDate}
- {group.avatars.map((avatar, idx) => ( - avatar.startsWith('+') ? ( -
+ {group.avatars.map((avatar, idx) => + avatar.startsWith("+") ? ( +
{avatar}
) : ( - Member - ) - ))} + Member + ), + )}
@@ -110,7 +139,9 @@ export function SavingsCircles({ groups, isLoading = false }: SavingsCirclesProp

New Circle

-

Start a community savings plan with friends.

+

+ Start a community savings plan with friends. +

From bf5a078455f87a527126642129f228294c18c3b7 Mon Sep 17 00:00:00 2001 From: adetomiwa21 Date: Sun, 30 Aug 2026 17:09:34 +0100 Subject: [PATCH 04/11] test: add ProgressRing unit tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Covers SVG structure (track + progress circles), brand colours (#10B981 and #E5E7EB), stroke-dasharray, vector-effect non-scaling-stroke for responsive scaling, aria-label accessibility, progress clamping (0–100), children rendering inside the ring, and custom className forwarding. --- src/__tests__/dashboard/ProgressRing.test.tsx | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 src/__tests__/dashboard/ProgressRing.test.tsx diff --git a/src/__tests__/dashboard/ProgressRing.test.tsx b/src/__tests__/dashboard/ProgressRing.test.tsx new file mode 100644 index 0000000..7b9cf7a --- /dev/null +++ b/src/__tests__/dashboard/ProgressRing.test.tsx @@ -0,0 +1,84 @@ +import { render, screen } from "@testing-library/react"; +import { describe, it, expect } from "vitest"; +import { ProgressRing } from "@/components/dashboard/ProgressRing"; + +describe("ProgressRing", () => { + it("renders an SVG with two circles (track + progress)", () => { + const { container } = render(); + const circles = container.querySelectorAll("circle"); + expect(circles).toHaveLength(2); + }); + + it("has correct aria-label reflecting the progress value", () => { + render(); + expect( + screen.getByRole("img", { name: "Savings progress: 82%" }), + ).toBeInTheDocument(); + }); + + it("clamps progress above 100 to 100%", () => { + render(); + expect( + screen.getByRole("img", { name: "Savings progress: 100%" }), + ).toBeInTheDocument(); + }); + + it("clamps progress below 0 to 0%", () => { + render(); + expect( + screen.getByRole("img", { name: "Savings progress: 0%" }), + ).toBeInTheDocument(); + }); + + it("renders children inside the ring", () => { + render( + + 50% + , + ); + expect(screen.getByText("50%")).toBeInTheDocument(); + }); + + it("uses emerald green (#10B981) for the progress arc", () => { + const { container } = render(); + const circles = container.querySelectorAll("circle"); + // Second circle is the progress arc + expect(circles[1].getAttribute("stroke")).toBe("#10B981"); + }); + + it("uses a muted gray (#E5E7EB) for the track", () => { + const { container } = render(); + const circles = container.querySelectorAll("circle"); + // First circle is the track + expect(circles[0].getAttribute("stroke")).toBe("#E5E7EB"); + }); + + it("sets stroke-dasharray on the progress arc", () => { + const { container } = render(); + const progressCircle = container.querySelectorAll("circle")[1]; + expect(progressCircle.getAttribute("stroke-dasharray")).toBeTruthy(); + }); + + it("uses non-scaling-stroke vector-effect for responsive scaling", () => { + const { container } = render(); + const circles = container.querySelectorAll("circle"); + circles.forEach((circle) => { + expect(circle.getAttribute("vector-effect")).toBe("non-scaling-stroke"); + }); + }); + + it("applies a custom className to the wrapper", () => { + const { container } = render( + , + ); + expect(container.firstChild).toHaveClass("w-32", "h-32"); + }); + + it("renders loading skeleton with animate-pulse when isLoading", () => { + // ProgressRing itself has no loading state — verify SavingsCircles skeleton + // still uses animate-pulse (tested in SavingsCircles.test.tsx). + // This test is a placeholder confirming ProgressRing renders without error at 0%. + const { container } = render(); + expect(container.querySelector("svg")).toBeInTheDocument(); + }); +}); From 507ad96204960d1b6ba9851f72e84985d5635a0c Mon Sep 17 00:00:00 2001 From: adetomiwa21 Date: Sun, 30 Aug 2026 17:09:39 +0100 Subject: [PATCH 05/11] style: format BalanceCard with Prettier --- .../dashboard/BalanceCard/BalanceCard.tsx | 34 +++++++++++++------ 1 file changed, 24 insertions(+), 10 deletions(-) diff --git a/src/components/dashboard/BalanceCard/BalanceCard.tsx b/src/components/dashboard/BalanceCard/BalanceCard.tsx index 6fa7d98..41a1d90 100644 --- a/src/components/dashboard/BalanceCard/BalanceCard.tsx +++ b/src/components/dashboard/BalanceCard/BalanceCard.tsx @@ -1,7 +1,7 @@ -'use client'; +"use client"; -import React from 'react'; -import { Send, Plus, TrendingUp, TrendingDown } from 'lucide-react'; +import React from "react"; +import { Send, Plus, TrendingUp, TrendingDown } from "lucide-react"; interface BalanceCardProps { balance?: number; @@ -10,7 +10,12 @@ interface BalanceCardProps { isLoading?: boolean; } -export function BalanceCard({ balance = 0, currency = 'USDC', trendPercentage = 0, isLoading = false }: BalanceCardProps) { +export function BalanceCard({ + balance = 0, + currency = "USDC", + trendPercentage = 0, + isLoading = false, +}: BalanceCardProps) { if (isLoading) { return (
@@ -27,7 +32,7 @@ export function BalanceCard({ balance = 0, currency = 'USDC', trendPercentage = ); } - const formattedBalance = new Intl.NumberFormat('en-US', { + const formattedBalance = new Intl.NumberFormat("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2, }).format(balance); @@ -37,10 +42,16 @@ export function BalanceCard({ balance = 0, currency = 'USDC', trendPercentage = return (
-

Total Balance

+

+ Total Balance +

-

{formattedBalance}

- {currency} +

+ {formattedBalance} +

+ + {currency} +
{isPositiveTrend ? ( @@ -48,8 +59,11 @@ export function BalanceCard({ balance = 0, currency = 'USDC', trendPercentage = ) : ( )} - - {isPositiveTrend ? '+' : ''}{trendPercentage}% + + {isPositiveTrend ? "+" : ""} + {trendPercentage}% from last month
From 097961a2418a726a24b5fb03de4f735cc9950b2e Mon Sep 17 00:00:00 2001 From: adetomiwa21 Date: Sun, 30 Aug 2026 17:09:43 +0100 Subject: [PATCH 06/11] style: format TransactionList with Prettier --- .../TransactionList/TransactionList.tsx | 54 ++++++++++++------- 1 file changed, 34 insertions(+), 20 deletions(-) diff --git a/src/components/dashboard/TransactionList/TransactionList.tsx b/src/components/dashboard/TransactionList/TransactionList.tsx index fbe5989..7686e71 100644 --- a/src/components/dashboard/TransactionList/TransactionList.tsx +++ b/src/components/dashboard/TransactionList/TransactionList.tsx @@ -1,22 +1,28 @@ -'use client'; +"use client"; -import React from 'react'; -import { ArrowDown, ArrowUp, Users } from 'lucide-react'; -import type { Transaction } from '@/hooks/usePayments'; +import React from "react"; +import { ArrowDown, ArrowUp, Users } from "lucide-react"; +import type { Transaction } from "@/hooks/usePayments"; interface TransactionListProps { transactions: Transaction[]; isLoading?: boolean; } -export function TransactionList({ transactions, isLoading = false }: TransactionListProps) { +export function TransactionList({ + transactions, + isLoading = false, +}: TransactionListProps) { if (isLoading) { return (
{[1, 2, 3].map((i) => ( -
+
@@ -31,7 +37,7 @@ export function TransactionList({ transactions, isLoading = false }: Transaction
))}
-
+
@@ -40,11 +46,11 @@ export function TransactionList({ transactions, isLoading = false }: Transaction const getIcon = (type: string) => { switch (type) { - case 'deposit': + case "deposit": return ; - case 'contribution': + case "contribution": return ; - case 'send': + case "send": return ; default: return ; @@ -53,19 +59,23 @@ export function TransactionList({ transactions, isLoading = false }: Transaction return (
-

Recent Activity

- +

+ Recent Activity +

+
{transactions.map((tx, idx) => { const isPositive = tx.amount > 0; - const formattedAmount = `${isPositive ? '+' : ''}${tx.amount.toFixed(2)} ${tx.currency}`; - + const formattedAmount = `${isPositive ? "+" : ""}${tx.amount.toFixed(2)} ${tx.currency}`; + return ( -
@@ -73,14 +83,18 @@ export function TransactionList({ transactions, isLoading = false }: Transaction {getIcon(tx.type)}
-

{tx.title}

+

+ {tx.title} +

{tx.date} • {tx.time}

-

+

{formattedAmount}

@@ -91,7 +105,7 @@ export function TransactionList({ transactions, isLoading = false }: Transaction ); })}

- +