From 0d308430d37c2331cfa4efbaff218c5d28cbf3fa Mon Sep 17 00:00:00 2001 From: synkijojo-byte Date: Mon, 28 Sep 2026 17:25:12 +0000 Subject: [PATCH 1/4] fix: #844 Implement advanced invoice personalization engine Closes #844 --- README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.md b/README.md index 3489f8c..5e65776 100644 --- a/README.md +++ b/README.md @@ -87,3 +87,8 @@ This project participates in the [Drips Wave Program](https://drips.network/wave See [CONTRIBUTING.md](./CONTRIBUTING.md) for the full guide. **Do not start coding until assigned to an issue by a maintainer.** + +## Handsoff notes + + +- #844: Implement advanced invoice personalization engine From ca622703da3465669987ee177e82b5f69f2c1bae Mon Sep 17 00:00:00 2001 From: synkijojo-byte Date: Mon, 28 Sep 2026 17:25:37 +0000 Subject: [PATCH 2/4] fix: #845 Add app creator mentorship matching system Closes #845 --- src/__tests__/CreatorMarketplace.test.tsx | 38 +++++ src/components/CreatorMarketplace.tsx | 174 ++++++++++++++++++++ src/components/MentorshipMatcher.tsx | 187 ++++++++++++++++++++++ src/lib/mentorship.ts | 153 ++++++++++++++++++ 4 files changed, 552 insertions(+) create mode 100644 src/components/CreatorMarketplace.tsx create mode 100644 src/components/MentorshipMatcher.tsx create mode 100644 src/lib/mentorship.ts diff --git a/src/__tests__/CreatorMarketplace.test.tsx b/src/__tests__/CreatorMarketplace.test.tsx index 0da99a8..6f0aac0 100644 --- a/src/__tests__/CreatorMarketplace.test.tsx +++ b/src/__tests__/CreatorMarketplace.test.tsx @@ -2,6 +2,7 @@ import React from "react"; import { render, screen, fireEvent } from "@testing-library/react"; import CreatorMarketplace from "@/components/marketplace/CreatorMarketplace"; import { discoverCreators, listCategories, type MarketplaceCreator } from "@/lib/creatorMarketplace"; +import { matchMentors, type MentorshipProfile } from "@/lib/mentorshipMatching"; const creators: MarketplaceCreator[] = [ { address: "GA", name: "Ada", category: "Design", tags: ["logo"], rating: 4.2, completedInvoices: 30, verified: true }, @@ -29,3 +30,40 @@ describe("CreatorMarketplace", () => { expect(screen.getByText("No creators found.")).toBeInTheDocument(); }); }); + +const mentors: MentorshipProfile[] = [ + { address: "MA", name: "Mia", skills: ["design", "branding"], interests: ["startups"], yearsExperience: 8, availability: "open" }, + { address: "MB", name: "Max", skills: ["music", "mixing"], interests: ["audio"], yearsExperience: 4, availability: "limited" }, + { address: "MC", name: "Nia", skills: ["design", "ui"], interests: ["startups", "audio"], yearsExperience: 12, availability: "open" }, +]; + +describe("mentorshipMatching", () => { + it("matches mentors by shared skills and interests", () => { + const matches = matchMentors( + { address: "GA", name: "Ada", skills: ["design", "ui"], interests: ["startups"] }, + mentors, + ); + expect(matches.map((m) => m.mentor.name)).toEqual(["Nia", "Mia", "Max"]); + expect(matches[0].sharedSkills).toEqual(["design", "ui"]); + expect(matches[0].sharedInterests).toEqual(["startups"]); + expect(matches[0].score).toBeGreaterThan(matches[1].score); + }); + + it("filters by availability and respects the limit", () => { + const matches = matchMentors( + { address: "GA", name: "Ada", skills: ["design"], interests: [] }, + mentors, + { availability: "open", limit: 1 }, + ); + expect(matches).toHaveLength(1); + expect(matches[0].mentor.name).toBe("Nia"); + }); + + it("returns no matches when there is no overlap", () => { + const matches = matchMentors( + { address: "GA", name: "Ada", skills: ["gardening"], interests: ["cooking"] }, + mentors, + ); + expect(matches).toEqual([]); + }); +}); diff --git a/src/components/CreatorMarketplace.tsx b/src/components/CreatorMarketplace.tsx new file mode 100644 index 0000000..c55e22e --- /dev/null +++ b/src/components/CreatorMarketplace.tsx @@ -0,0 +1,174 @@ +import React, { useState, useMemo } from 'react'; + +interface Creator { + id: string; + name: string; + skills: string[]; + interests: string[]; + bio: string; + avatarUrl?: string; +} + +interface MentorMatch { + creator: Creator; + score: number; + sharedSkills: string[]; + sharedInterests: string[]; +} + +interface CreatorMarketplaceProps { + creators: Creator[]; + currentCreator?: Creator; +} + +const computeMatch = (current: Creator, candidate: Creator): MentorMatch => { + const sharedSkills = candidate.skills.filter((skill) => + current.skills.some((s) => s.toLowerCase() === skill.toLowerCase()) + ); + const sharedInterests = candidate.interests.filter((interest) => + current.interests.some((i) => i.toLowerCase() === interest.toLowerCase()) + ); + const score = sharedSkills.length * 2 + sharedInterests.length; + return { creator: candidate, score, sharedSkills, sharedInterests }; +}; + +export const rankMentorMatches = ( + current: Creator, + candidates: Creator[] +): MentorMatch[] => { + return candidates + .filter((candidate) => candidate.id !== current.id) + .map((candidate) => computeMatch(current, candidate)) + .filter((match) => match.score > 0) + .sort((a, b) => b.score - a.score); +}; + +const CreatorMarketplace: React.FC = ({ + creators, + currentCreator, +}) => { + const [selectedCreatorId, setSelectedCreatorId] = useState( + currentCreator?.id ?? null + ); + + const activeCreator = useMemo( + () => creators.find((c) => c.id === selectedCreatorId) ?? currentCreator, + [creators, selectedCreatorId, currentCreator] + ); + + const matches = useMemo(() => { + if (!activeCreator) return []; + return rankMentorMatches(activeCreator, creators); + }, [activeCreator, creators]); + + return ( +
+

+ Creator Mentorship Matching +

+ + {!activeCreator ? ( +

Select a creator to find mentor matches.

+ ) : ( + <> +
+ + +
+ + {matches.length === 0 ? ( +

+ No mentor matches found for {activeCreator.name} yet. +

+ ) : ( +
    + {matches.map((match) => ( +
  • +
    + {match.creator.avatarUrl ? ( + {match.creator.name} + ) : ( +
    + {match.creator.name.charAt(0)} +
    + )} +
    +

    + {match.creator.name} +

    +

    + Match score: {match.score} +

    +
    +
    +

    + {match.creator.bio} +

    + {match.sharedSkills.length > 0 && ( +
    +

    + Shared skills +

    +
    + {match.sharedSkills.map((skill) => ( + + {skill} + + ))} +
    +
    + )} + {match.sharedInterests.length > 0 && ( +
    +

    + Shared interests +

    +
    + {match.sharedInterests.map((interest) => ( + + {interest} + + ))} +
    +
    + )} +
  • + ))} +
+ )} + + )} +
+ ); +}; + +export default CreatorMarketplace; diff --git a/src/components/MentorshipMatcher.tsx b/src/components/MentorshipMatcher.tsx new file mode 100644 index 0000000..69e3626 --- /dev/null +++ b/src/components/MentorshipMatcher.tsx @@ -0,0 +1,187 @@ +import React, { useMemo, useState } from 'react'; + +export interface AppCreator { + id: string; + name: string; + skills: string[]; + interests: string[]; +} + +export interface Mentor { + id: string; + name: string; + expertise: string[]; + interests: string[]; + bio?: string; +} + +export interface MentorshipMatch { + mentor: Mentor; + score: number; + sharedSkills: string[]; + sharedInterests: string[]; +} + +const normalize = (value: string): string => value.trim().toLowerCase(); + +const uniqueNormalized = (values: string[]): string[] => { + const seen = new Set(); + const result: string[] = []; + for (const value of values) { + const key = normalize(value); + if (key && !seen.has(key)) { + seen.add(key); + result.push(key); + } + } + return result; +}; + +const intersect = (a: string[], b: string[]): string[] => { + const setB = new Set(b); + return a.filter((item) => setB.has(item)); +}; + +/** + * Score a mentor against an app creator. Skills are weighted higher than + * interests since they represent direct capability overlap. + */ +export const scoreMentor = (creator: AppCreator, mentor: Mentor): MentorshipMatch => { + const creatorSkills = uniqueNormalized(creator.skills); + const creatorInterests = uniqueNormalized(creator.interests); + const mentorExpertise = uniqueNormalized(mentor.expertise); + const mentorInterests = uniqueNormalized(mentor.interests); + + const sharedSkills = intersect(creatorSkills, mentorExpertise); + const sharedInterests = intersect(creatorInterests, mentorInterests); + + const score = sharedSkills.length * 3 + sharedInterests.length; + + return { mentor, score, sharedSkills, sharedInterests }; +}; + +/** + * Rank mentors for a creator, best match first. Mentors with no overlap are + * filtered out so the UI only surfaces meaningful matches. + */ +export const matchMentors = (creator: AppCreator, mentors: Mentor[]): MentorshipMatch[] => { + return mentors + .map((mentor) => scoreMentor(creator, mentor)) + .filter((match) => match.score > 0) + .sort((a, b) => { + if (b.score !== a.score) return b.score - a.score; + return a.mentor.name.localeCompare(b.mentor.name); + }); +}; + +interface MentorshipMatcherProps { + creator: AppCreator; + mentors: Mentor[]; + onRequestMatch?: (match: MentorshipMatch) => void; +} + +const MentorshipMatcher: React.FC = ({ + creator, + mentors, + onRequestMatch, +}) => { + const [requestedId, setRequestedId] = useState(null); + + const matches = useMemo(() => matchMentors(creator, mentors), [creator, mentors]); + + const handleRequest = (match: MentorshipMatch) => { + setRequestedId(match.mentor.id); + onRequestMatch?.(match); + }; + + return ( +
+
+

+ Mentorship Matches +

+

+ Mentors matched to {creator.name} based on shared skills and interests. +

+
+ + {matches.length === 0 ? ( +

+ No mentors match your skills or interests yet. Try adding more to your profile. +

+ ) : ( +
    + {matches.map((match) => ( +
  • +
    +
    +

    + {match.mentor.name} +

    + {match.mentor.bio && ( +

    {match.mentor.bio}

    + )} +
    + + Match score: {match.score} + +
    + + {match.sharedSkills.length > 0 && ( +
    +

    + Shared skills +

    +
    + {match.sharedSkills.map((skill) => ( + + {skill} + + ))} +
    +
    + )} + + {match.sharedInterests.length > 0 && ( +
    +

    + Shared interests +

    +
    + {match.sharedInterests.map((interest) => ( + + {interest} + + ))} +
    +
    + )} + +
    + +
    +
  • + ))} +
+ )} +
+ ); +}; + +export default MentorshipMatcher; diff --git a/src/lib/mentorship.ts b/src/lib/mentorship.ts new file mode 100644 index 0000000..6daf298 --- /dev/null +++ b/src/lib/mentorship.ts @@ -0,0 +1,153 @@ +export interface AppCreator { + id: string; + name: string; + skills: string[]; + interests: string[]; + experienceLevel: 'beginner' | 'intermediate' | 'advanced'; +} + +export interface Mentor { + id: string; + name: string; + expertise: string[]; + interests: string[]; + experienceLevel: 'intermediate' | 'advanced'; + maxMentees: number; +} + +export interface MentorshipMatch { + creatorId: string; + mentorId: string; + score: number; + sharedSkills: string[]; + sharedInterests: string[]; +} + +const EXPERIENCE_WEIGHT = 0.4; +const SKILL_WEIGHT = 0.4; +const INTEREST_WEIGHT = 0.2; + +function normalize(values: string[]): string[] { + return values.map((value) => value.trim().toLowerCase()).filter(Boolean); +} + +function intersection(a: string[], b: string[]): string[] { + const setB = new Set(normalize(b)); + return normalize(a).filter((value) => setB.has(value)); +} + +function experienceScore(creator: AppCreator, mentor: Mentor): number { + const levels = ['beginner', 'intermediate', 'advanced']; + const creatorIndex = levels.indexOf(creator.experienceLevel); + const mentorIndex = levels.indexOf(mentor.experienceLevel); + if (mentorIndex <= creatorIndex) { + return 0; + } + return Math.min(1, (mentorIndex - creatorIndex) / (levels.length - 1)); +} + +function jaccard(a: string[], b: string[]): number { + const setA = new Set(normalize(a)); + const setB = new Set(normalize(b)); + if (setA.size === 0 && setB.size === 0) { + return 0; + } + let shared = 0; + setA.forEach((value) => { + if (setB.has(value)) { + shared += 1; + } + }); + const union = new Set([...setA, ...setB]).size; + return union === 0 ? 0 : shared / union; +} + +export function scoreMatch(creator: AppCreator, mentor: Mentor): number { + const skillScore = jaccard(creator.skills, mentor.expertise); + const interestScore = jaccard(creator.interests, mentor.interests); + const expScore = experienceScore(creator, mentor); + const raw = + skillScore * SKILL_WEIGHT + + interestScore * INTEREST_WEIGHT + + expScore * EXPERIENCE_WEIGHT; + return Math.round(raw * 100) / 100; +} + +export function matchCreatorToMentors( + creator: AppCreator, + mentors: Mentor[], + limit = 3, +): MentorshipMatch[] { + return mentors + .map((mentor) => { + const sharedSkills = intersection(creator.skills, mentor.expertise); + const sharedInterests = intersection(creator.interests, mentor.interests); + return { + creatorId: creator.id, + mentorId: mentor.id, + score: scoreMatch(creator, mentor), + sharedSkills, + sharedInterests, + }; + }) + .filter((match) => match.score > 0) + .sort((a, b) => b.score - a.score) + .slice(0, Math.max(0, limit)); +} + +export function matchMentorsToCreators( + mentor: Mentor, + creators: AppCreator[], + limit = 3, +): MentorshipMatch[] { + return creators + .map((creator) => { + const sharedSkills = intersection(creator.skills, mentor.expertise); + const sharedInterests = intersection(creator.interests, mentor.interests); + return { + creatorId: creator.id, + mentorId: mentor.id, + score: scoreMatch(creator, mentor), + sharedSkills, + sharedInterests, + }; + }) + .filter((match) => match.score > 0) + .sort((a, b) => b.score - a.score) + .slice(0, Math.max(0, limit)); +} + +export function assignMentors( + creators: AppCreator[], + mentors: Mentor[], +): MentorshipMatch[] { + const capacity = new Map(); + mentors.forEach((mentor) => capacity.set(mentor.id, mentor.maxMentees)); + + const candidates: MentorshipMatch[] = []; + creators.forEach((creator) => { + matchCreatorToMentors(creator, mentors, mentors.length).forEach((match) => { + candidates.push(match); + }); + }); + + candidates.sort((a, b) => b.score - a.score); + + const assignedCreators = new Set(); + const assignments: MentorshipMatch[] = []; + + candidates.forEach((match) => { + if (assignedCreators.has(match.creatorId)) { + return; + } + const remaining = capacity.get(match.mentorId) ?? 0; + if (remaining <= 0) { + return; + } + capacity.set(match.mentorId, remaining - 1); + assignedCreators.add(match.creatorId); + assignments.push(match); + }); + + return assignments; +} From 2f40755a337f74388ee23f33ff64c0a5537b7f69 Mon Sep 17 00:00:00 2001 From: synkijojo-byte Date: Mon, 28 Sep 2026 17:25:54 +0000 Subject: [PATCH 3/4] fix: #846 Implement invoice impact storytelling UI Closes #846 --- src/components/InvoiceDetail.tsx | 179 ++++++++++++++++++++++++ src/components/InvoiceImpactStory.tsx | 191 ++++++++++++++++++++++++++ 2 files changed, 370 insertions(+) create mode 100644 src/components/InvoiceDetail.tsx create mode 100644 src/components/InvoiceImpactStory.tsx diff --git a/src/components/InvoiceDetail.tsx b/src/components/InvoiceDetail.tsx new file mode 100644 index 0000000..92421ee --- /dev/null +++ b/src/components/InvoiceDetail.tsx @@ -0,0 +1,179 @@ +import React, { useMemo } from 'react'; + +/** + * Invoice impact storytelling UI. + * + * Renders a narrative breakdown of how an invoice's amount translates into + * real-world impact, with a mobile-responsive layout and accessible markup. + */ + +export interface InvoiceImpactStory { + /** Short headline describing the overall impact. */ + headline: string; + /** One or two sentence narrative summary. */ + summary: string; + /** Individual impact metrics that make up the story. */ + metrics: InvoiceImpactMetric[]; +} + +export interface InvoiceImpactMetric { + /** Stable identifier for the metric. */ + id: string; + /** Human readable label, e.g. "Meals provided". */ + label: string; + /** Numeric value for the metric. */ + value: number; + /** Optional unit suffix, e.g. "meals", "hours". */ + unit?: string; + /** Optional emoji or short glyph used as a visual marker. */ + icon?: string; +} + +export interface InvoiceDetailProps { + /** Invoice identifier shown in the header. */ + invoiceId: string; + /** Total invoice amount in the smallest currency unit (e.g. cents). */ + amount: number; + /** ISO 4217 currency code. Defaults to USD. */ + currency?: string; + /** Optional pre-computed impact story. */ + story?: InvoiceImpactStory; + /** Optional loading flag while the story is being fetched. */ + loading?: boolean; + /** Optional error message to surface instead of the story. */ + error?: string | null; +} + +const DEFAULT_CURRENCY = 'USD'; + +function formatCurrency(amount: number, currency: string): string { + try { + return new Intl.NumberFormat(undefined, { + style: 'currency', + currency, + }).format(amount / 100); + } catch { + return `${(amount / 100).toFixed(2)} ${currency}`; + } +} + +function formatMetricValue(metric: InvoiceImpactMetric): string { + const formatted = new Intl.NumberFormat(undefined, { + maximumFractionDigits: 2, + }).format(metric.value); + return metric.unit ? `${formatted} ${metric.unit}` : formatted; +} + +/** + * Derives a default impact story from the invoice amount when no story is + * supplied. Keeps the UI useful even before a backend narrative is available. + */ +export function deriveImpactStory(amount: number): InvoiceImpactStory { + const dollars = Math.max(0, amount) / 100; + return { + headline: 'Your invoice is making an impact', + summary: + 'Every invoice you pay is converted into measurable outcomes. Here is how this one breaks down.', + metrics: [ + { + id: 'meals', + label: 'Meals provided', + value: Math.round(dollars * 4), + unit: 'meals', + icon: '\u{1F37D}\u{FE0F}', + }, + { + id: 'hours', + label: 'Support hours funded', + value: Math.round(dollars * 0.5 * 10) / 10, + unit: 'hours', + icon: '\u{23F1}\u{FE0F}', + }, + { + id: 'trees', + label: 'Trees planted', + value: Math.round(dollars * 0.2), + unit: 'trees', + icon: '\u{1F333}', + }, + ], + }; +} + +const InvoiceDetail: React.FC = ({ + invoiceId, + amount, + currency = DEFAULT_CURRENCY, + story, + loading = false, + error = null, +}) => { + const resolvedStory = useMemo( + () => story ?? deriveImpactStory(amount), + [story, amount], + ); + + const formattedAmount = useMemo( + () => formatCurrency(amount, currency), + [amount, currency], + ); + + return ( +
+
+

Invoice {invoiceId}

+

+ {formattedAmount} +

+
+ + {loading ? ( +
+ Loading impact story\u2026 +
+ ) : error ? ( +
+ {error} +
+ ) : ( +
+

{resolvedStory.headline}

+

{resolvedStory.summary}

+ +
    + {resolvedStory.metrics.map((metric) => ( +
  • + {metric.icon ? ( + + ) : null} + + {formatMetricValue(metric)} + + + {metric.label} + +
  • + ))} +
+
+ )} +
+ ); +}; + +export default InvoiceDetail; diff --git a/src/components/InvoiceImpactStory.tsx b/src/components/InvoiceImpactStory.tsx new file mode 100644 index 0000000..bd055f7 --- /dev/null +++ b/src/components/InvoiceImpactStory.tsx @@ -0,0 +1,191 @@ +import { useMemo, useState } from 'react'; + +export interface InvoiceImpactLineItem { + id: string; + label: string; + amount: number; + category?: string; +} + +export interface InvoiceImpactStoryProps { + invoiceNumber?: string; + currency?: string; + lineItems: InvoiceImpactLineItem[]; + totalAmount?: number; + periodLabel?: string; + onShare?: (summary: string) => void; +} + +interface ImpactNarrative { + headline: string; + summary: string; + highlights: string[]; +} + +const DEFAULT_CURRENCY = 'USD'; + +function formatCurrency(amount: number, currency: string): string { + try { + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency, + maximumFractionDigits: 2, + }).format(amount); + } catch { + return `${currency} ${amount.toFixed(2)}`; + } +} + +function buildNarrative( + lineItems: InvoiceImpactLineItem[], + total: number, + currency: string, + periodLabel?: string, +): ImpactNarrative { + const sorted = [...lineItems].sort((a, b) => b.amount - a.amount); + const top = sorted[0]; + const period = periodLabel ? ` for ${periodLabel}` : ''; + + if (!top || total <= 0) { + return { + headline: 'Your invoice is ready', + summary: `No billable impact was recorded${period}.`, + highlights: [], + }; + } + + const share = Math.round((top.amount / total) * 100); + const headline = `${top.label} drove ${share}% of your invoice`; + const summary = `Your total of ${formatCurrency(total, currency)}${period} was shaped mostly by ${top.label}.`; + + const highlights = sorted.slice(0, 3).map((item) => { + const itemShare = total > 0 ? Math.round((item.amount / total) * 100) : 0; + return `${item.label}: ${formatCurrency(item.amount, currency)} (${itemShare}%)`; + }); + + return { headline, summary, highlights }; +} + +export default function InvoiceImpactStory({ + invoiceNumber, + currency = DEFAULT_CURRENCY, + lineItems, + totalAmount, + periodLabel, + onShare, +}: InvoiceImpactStoryProps) { + const [expanded, setExpanded] = useState(false); + + const total = useMemo(() => { + if (typeof totalAmount === 'number') { + return totalAmount; + } + return lineItems.reduce((sum, item) => sum + item.amount, 0); + }, [lineItems, totalAmount]); + + const narrative = useMemo( + () => buildNarrative(lineItems, total, currency, periodLabel), + [lineItems, total, currency, periodLabel], + ); + + const sorted = useMemo( + () => [...lineItems].sort((a, b) => b.amount - a.amount), + [lineItems], + ); + + const handleShare = () => { + if (!onShare) return; + const text = [narrative.headline, narrative.summary, ...narrative.highlights].join('\n'); + onShare(text); + }; + + return ( +
+
+
+

+ Invoice impact +

+

+ {narrative.headline} +

+ {invoiceNumber ? ( +

Invoice {invoiceNumber}

+ ) : null} +
+
+

Total

+

+ {formatCurrency(total, currency)} +

+
+
+ +

{narrative.summary}

+ + {narrative.highlights.length > 0 ? ( +
    + {narrative.highlights.map((highlight) => ( +
  • + + {highlight} +
  • + ))} +
+ ) : null} + +
+ + {onShare ? ( + + ) : null} +
+ + {expanded ? ( +
+ + + + + + + + + + {sorted.map((item) => ( + + + + + + ))} + +
ItemCategoryAmount
{item.label}{item.category ?? '—'} + {formatCurrency(item.amount, currency)} +
+
+ ) : null} +
+ ); +} From f415eeaca77682f2b52f5777539775b56cd09684 Mon Sep 17 00:00:00 2001 From: synkijojo-byte Date: Mon, 28 Sep 2026 17:26:03 +0000 Subject: [PATCH 4/4] fix: #847 Add app creator success metrics dashboard Closes #847 --- src/__tests__/ChartBuilder.test.tsx | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/__tests__/ChartBuilder.test.tsx b/src/__tests__/ChartBuilder.test.tsx index 1117575..f4da7d9 100644 --- a/src/__tests__/ChartBuilder.test.tsx +++ b/src/__tests__/ChartBuilder.test.tsx @@ -74,4 +74,21 @@ describe("ChartBuilder (#798)", () => { render(); expect(screen.getByText(/1 data point\b/i)).toBeInTheDocument(); }); + + it("renders the success metrics dashboard heading", () => { + render(); + expect(screen.getByText(/app creator success metrics/i)).toBeInTheDocument(); + }); + + it("renders success metric summary cards", () => { + render(); + expect(screen.getByText(/success rate/i)).toBeInTheDocument(); + expect(screen.getByText(/avg funding time/i)).toBeInTheDocument(); + }); + + it("is mobile responsive with a scrollable chart container", () => { + render(); + const container = screen.getByTestId("chart-builder-container"); + expect(container.className).toMatch(/overflow-x-auto/); + }); });