From b5b6c626b1cddf85df682e3ce9a358cd1ba189fa Mon Sep 17 00:00:00 2001 From: mintoku Date: Wed, 22 Jul 2026 14:19:12 -0700 Subject: [PATCH 1/2] Test CI and Vercel preview --- README.md | 1 + app/page.tsx | 41 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/README.md b/README.md index abf8869..c6f3f65 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,3 @@ # fiscal your personal finance dashboard +ci/cd test \ No newline at end of file diff --git a/app/page.tsx b/app/page.tsx index a3f1e87..b6280a2 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -460,6 +460,47 @@ export default function Home() { } /> + +
+

+ Privacy +

+
+
    +
  • No account login is required.
  • +
  • Bank usernames, passwords, and credentials are never collected.
  • +
  • + CSV files are read and parsed in your browser; they are not uploaded + to a file-storage service. +
  • +
  • + Transaction data lives in this page's memory only for your + session — refreshing or clearing removes it. +
  • +
  • You can explore with built-in sample data instead of your own files.
  • +
+

+ + When you click Categorize expenses: + {" "} + only uncategorized expense rows are considered. Matching descriptions + are deduplicated, then each unique item is sent as{" "} + + {"{ id, description }"} + {" "} + to this app's categorization API, which calls an AI provider. + Amounts, account types, source filenames, balances, and full CSV + contents are not included in that request. Suggested categories stay + editable by you. +

+
+
); From 73ef7aa4845373fc06e45171689bf277853fd49f Mon Sep 17 00:00:00 2001 From: mintoku Date: Wed, 22 Jul 2026 14:40:08 -0700 Subject: [PATCH 2/2] add dashboards and alter ui --- .github/workflows/ci.yml | 4 +- app/page.tsx | 489 ++++--- components/dashboard/CategoryBreakdown.tsx | 70 + components/dashboard/Dashboard.tsx | 239 +++ .../dashboard/DashboardSummaryCards.tsx | 86 ++ components/dashboard/LargestExpenses.tsx | 61 + components/dashboard/MonthlyInsights.tsx | 30 + components/dashboard/SpendingTrendChart.tsx | 81 + lib/dashboard.test.ts | 411 ++++++ lib/dashboard.ts | 434 ++++++ lib/formatMoney.ts | 10 + package-lock.json | 1302 ++++++++++++++++- package.json | 12 +- vitest.config.ts | 14 + 14 files changed, 2992 insertions(+), 251 deletions(-) create mode 100644 components/dashboard/CategoryBreakdown.tsx create mode 100644 components/dashboard/Dashboard.tsx create mode 100644 components/dashboard/DashboardSummaryCards.tsx create mode 100644 components/dashboard/LargestExpenses.tsx create mode 100644 components/dashboard/MonthlyInsights.tsx create mode 100644 components/dashboard/SpendingTrendChart.tsx create mode 100644 lib/dashboard.test.ts create mode 100644 lib/dashboard.ts create mode 100644 lib/formatMoney.ts create mode 100644 vitest.config.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c22b1c1..299fceb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,4 +16,6 @@ jobs: cache: npm - run: npm ci - run: npm run lint - - run: npm run build \ No newline at end of file + - run: npm run typecheck + - run: npm test + - run: npm run build diff --git a/app/page.tsx b/app/page.tsx index b6280a2..4be2145 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,17 +1,20 @@ "use client"; import { useEffect, useState } from "react"; +import Dashboard from "@/components/dashboard/Dashboard"; import FileUploader from "@/components/FileUploader"; import LandingHero from "@/components/LandingHero"; -import MonthlySummaryCards from "@/components/MonthlySummaryCards"; import TransactionTable from "@/components/TransactionTable"; import { ALL_TIME, - calculateMonthlySummary, filterTransactionsByPeriod, - formatMonthLabel, getAvailableMonths, } from "@/lib/calculateMonthlySummary"; +import { + filterByAccountType, + type AccountFilter, + type TypeFilter, +} from "@/lib/dashboard"; import { getUniqueUncategorizedExpenses, normalizeDescription, @@ -50,9 +53,11 @@ export default function Home() { const [hasStarted, setHasStarted] = useState(false); const [transactions, setTransactions] = useState([]); const [unsupportedFiles, setUnsupportedFiles] = useState([]); - const [typeFilter, setTypeFilter] = useState<"all" | TransactionType>("all"); - const [selectedPeriod, setSelectedPeriod] = useState(ALL_TIME); + const [typeFilter, setTypeFilter] = useState("all"); + const [accountFilter, setAccountFilter] = useState("all"); + const [selectedPeriod, setSelectedPeriod] = useState(null); const [isCategorizing, setIsCategorizing] = useState(false); + const [categorizeProgress, setCategorizeProgress] = useState(0); const [categorizeError, setCategorizeError] = useState(null); const [usingSampleData, setUsingSampleData] = useState(true); const [samplesReady, setSamplesReady] = useState(false); @@ -66,7 +71,7 @@ export default function Home() { if (cancelled || sampleTransactions.length === 0) return; setTransactions(sampleTransactions); setUsingSampleData(true); - setSelectedPeriod(ALL_TIME); + setSelectedPeriod(null); } finally { if (!cancelled) setSamplesReady(true); } @@ -86,11 +91,10 @@ export default function Home() { ? selectedPeriod : (availableMonths[0] ?? null); - const periodTransactions = filterTransactionsByPeriod( - transactions, - effectivePeriod, + const periodTransactions = filterByAccountType( + filterTransactionsByPeriod(transactions, effectivePeriod), + accountFilter, ); - const summary = calculateMonthlySummary(periodTransactions); const uncategorizedExpenseCount = transactions.filter( (transaction) => @@ -102,6 +106,7 @@ export default function Home() { usingSampleData ? newTransactions : [...current, ...newTransactions], ); setUsingSampleData(false); + setSelectedPeriod(null); } function handleTransactionTypeChange( @@ -150,8 +155,16 @@ export default function Home() { } setIsCategorizing(true); + setCategorizeProgress(12); setCategorizeError(null); + const progressTimer = window.setInterval(() => { + setCategorizeProgress((current) => { + if (current >= 88) return current; + return current + Math.max(1, Math.round((90 - current) * 0.08)); + }); + }, 200); + try { const response = await fetch("/api/categorize", { method: "POST", @@ -195,6 +208,7 @@ export default function Home() { }); } + setCategorizeProgress(100); setTransactions((current) => current.map((transaction) => { if (transaction.transactionType !== "expense") return transaction; @@ -218,14 +232,19 @@ export default function Home() { "Could not reach the categorization service. Your transactions were left unchanged.", ); } finally { - setIsCategorizing(false); + window.clearInterval(progressTimer); + window.setTimeout(() => { + setIsCategorizing(false); + setCategorizeProgress(0); + }, 350); } } async function handleReloadSamples() { setUnsupportedFiles([]); setTypeFilter("all"); - setSelectedPeriod(ALL_TIME); + setAccountFilter("all"); + setSelectedPeriod(null); setCategorizeError(null); const sampleTransactions = await loadSampleTransactions(); setTransactions(sampleTransactions); @@ -236,11 +255,18 @@ export default function Home() { setTransactions([]); setUnsupportedFiles([]); setTypeFilter("all"); - setSelectedPeriod(ALL_TIME); + setAccountFilter("all"); + setSelectedPeriod(null); setCategorizeError(null); setUsingSampleData(false); } + function handleViewTransactions() { + document + .getElementById("transactions-heading") + ?.scrollIntoView({ behavior: "smooth", block: "start" }); + } + if (!hasStarted) { return setHasStarted(true)} />; } @@ -257,251 +283,238 @@ export default function Home() {
-
-
-

- Your financial snapshot -

-

- Fiscal -

-
- - {usingSampleData && transactions.length > 0 && ( -
- - - Getting started - - Show - - - Hide - - - -
    -
  1. - 1 - - Try{" "} - - Categorize expenses - {" "} - for smart suggestions — you can change any category. - -
  2. -
  3. - 2 - - Browse periods and adjust labels anytime; you stay in control. - -
  4. -
  5. - 3 - - Clear, then - upload your own CSVs. +
    +
    +

    + Your financial snapshot +

    +

    + Fiscal +

    +
    + + {usingSampleData && transactions.length > 0 && ( +
    + + + Getting started + + Show + + + Hide + -
  6. -
-
- )} + +
    +
  1. + 1 + + Try{" "} + + Categorize expenses + {" "} + for smart suggestions — you can change any category. + +
  2. +
  3. + 2 + + Browse the dashboard insights, then adjust labels anytime. + +
  4. +
  5. + 3 + + Clear, + then upload your own CSVs. + +
  6. +
+ + )} - {!usingSampleData && transactions.length === 0 && samplesReady && ( -
- Sample cleared. Upload your CSV files, or{" "} + {!usingSampleData && transactions.length === 0 && samplesReady && ( +
+ Sample cleared. Upload your CSV files, or{" "} + + . +
+ )} +
+ +
+
+

+ 1 · Data +

- .
- )} - + +

+ Temporary note: only Bank of America checking and credit-card CSV + exports are supported right now. +

+ {unsupportedFiles.length > 0 && ( +
+ Unsupported file + {unsupportedFiles.length === 1 ? "" : "s"}:{" "} + {unsupportedFiles.join(", ")} +
+ )} +
-
-
-

- 1 · Data -

- -
- -

- Temporary note: only Bank of America checking and credit-card CSV - exports are supported right now. -

- {unsupportedFiles.length > 0 && ( -
- Unsupported file - {unsupportedFiles.length === 1 ? "" : "s"}:{" "} - {unsupportedFiles.join(", ")} -
- )} -
+
+ +
- {transactions.length > 0 && (
-
-

- 2 · Summary -

+
- - + 3 · Transactions + + {usingSampleData && transactions.length > 0 && ( + <> + + Sample data + + + + )} +
+
+ + {usingSampleData && transactions.length > 0 && ( +

+ Demo checking & credit-card exports — not your accounts +

+ )}
+ +
- - -
-

- {uncategorizedExpenseCount > 0 - ? `${uncategorizedExpenseCount} uncategorized expense${uncategorizedExpenseCount === 1 ? "" : "s"}` - : "All expenses categorized"} - {" · "} - Smart suggestions only — edit any category yourself. Only - descriptions are sent. +

+

+ Privacy +

+
+
    +
  • No account login is required.
  • +
  • + Bank usernames, passwords, and credentials are never collected. +
  • +
  • + CSV files are read and parsed in your browser; they are not + uploaded to a file-storage service. +
  • +
  • + Transaction data lives in this page's memory only for your + session — refreshing or clearing removes it. +
  • +
  • + You can explore with built-in sample data instead of your own + files. +
  • +
+

+ + When you click Categorize expenses: + {" "} + only uncategorized expense rows are considered. Matching + descriptions are deduplicated, then each unique item is sent as{" "} + + {"{ id, description }"} + {" "} + to this app's categorization API, which calls an AI provider. + Amounts, account types, source filenames, balances, and full CSV + contents are not included in that request. Suggested categories + stay editable by you.

-
- - {categorizeError && ( -
- {categorizeError} -
- )}
- )} - -
-
-
-

- 3 · Transactions -

- {usingSampleData && transactions.length > 0 && ( - - Sample data - - )} -
- {usingSampleData && transactions.length > 0 && ( -

- Demo checking & credit-card exports — not your accounts -

- )} -
- -
- -
-

- Privacy -

-
-
    -
  • No account login is required.
  • -
  • Bank usernames, passwords, and credentials are never collected.
  • -
  • - CSV files are read and parsed in your browser; they are not uploaded - to a file-storage service. -
  • -
  • - Transaction data lives in this page's memory only for your - session — refreshing or clearing removes it. -
  • -
  • You can explore with built-in sample data instead of your own files.
  • -
-

- - When you click Categorize expenses: - {" "} - only uncategorized expense rows are considered. Matching descriptions - are deduplicated, then each unique item is sent as{" "} - - {"{ id, description }"} - {" "} - to this app's categorization API, which calls an AI provider. - Amounts, account types, source filenames, balances, and full CSV - contents are not included in that request. Suggested categories stay - editable by you. -

-
-
-
+ ); } diff --git a/components/dashboard/CategoryBreakdown.tsx b/components/dashboard/CategoryBreakdown.tsx new file mode 100644 index 0000000..8761102 --- /dev/null +++ b/components/dashboard/CategoryBreakdown.tsx @@ -0,0 +1,70 @@ +"use client"; + +import { useState } from "react"; +import type { CategoryBreakdownItem } from "@/lib/dashboard"; +import { formatCurrency, formatPercent } from "@/lib/formatMoney"; + +type CategoryBreakdownProps = { + items: CategoryBreakdownItem[]; + allItems: CategoryBreakdownItem[]; +}; + +export default function CategoryBreakdown({ + items, + allItems, +}: CategoryBreakdownProps) { + const [showAll, setShowAll] = useState(false); + const visible = showAll ? allItems : items; + const maxTotal = visible[0]?.total ?? 0; + + if (allItems.length === 0) { + return ( +
+ No categorized expenses yet. Try Categorize expenses. +
+ ); + } + + return ( +
+
    + {visible.map((item) => { + const width = + maxTotal === 0 ? 0 : Math.max((item.total / maxTotal) * 100, 4); + return ( +
  • +
    + + {item.category} + + + {formatCurrency(item.total)} + +
    +
    +
    +
    +

    + {formatPercent(item.percent)} of expenses · {item.count}{" "} + transaction{item.count === 1 ? "" : "s"} +

    +
  • + ); + })} +
+ {allItems.length > items.length && ( + + )} +
+ ); +} diff --git a/components/dashboard/Dashboard.tsx b/components/dashboard/Dashboard.tsx new file mode 100644 index 0000000..70a6286 --- /dev/null +++ b/components/dashboard/Dashboard.tsx @@ -0,0 +1,239 @@ +"use client"; + +import DashboardSummaryCards from "@/components/dashboard/DashboardSummaryCards"; +import CategoryBreakdown from "@/components/dashboard/CategoryBreakdown"; +import LargestExpenses from "@/components/dashboard/LargestExpenses"; +import MonthlyInsights from "@/components/dashboard/MonthlyInsights"; +import SpendingTrendChart from "@/components/dashboard/SpendingTrendChart"; +import { + ALL_TIME, + calculateDashboardSummary, + filterDashboardTransactions, + generateMonthlyInsights, + getLargestExpenses, + groupExpensesByCategory, + groupExpensesByTime, + resolvePreviousPeriodTransactions, + type AccountFilter, + type TypeFilter, +} from "@/lib/dashboard"; +import { formatMonthLabel, getAvailableMonths } from "@/lib/calculateMonthlySummary"; +import type { Transaction } from "@/types/transaction"; + +type DashboardProps = { + transactions: Transaction[]; + period: string | null; + onPeriodChange: (period: string) => void; + accountFilter: AccountFilter; + onAccountFilterChange: (value: AccountFilter) => void; + typeFilter: TypeFilter; + onTypeFilterChange: (value: TypeFilter) => void; + onViewTransactions: () => void; + uncategorizedExpenseCount: number; + isCategorizing: boolean; + categorizeProgress: number; + onCategorize: () => void; + categorizeError: string | null; +}; + +export default function Dashboard({ + transactions, + period, + onPeriodChange, + accountFilter, + onAccountFilterChange, + typeFilter, + onTypeFilterChange, + onViewTransactions, + uncategorizedExpenseCount, + isCategorizing, + categorizeProgress, + onCategorize, + categorizeError, +}: DashboardProps) { + const availableMonths = getAvailableMonths(transactions); + + if (transactions.length === 0) { + return ( +
+ No transactions loaded yet. Upload a Bank of America CSV or reload the + sample data to see your dashboard. +
+ ); + } + + const effectivePeriod = + period === ALL_TIME + ? ALL_TIME + : period && availableMonths.includes(period) + ? period + : (availableMonths[0] ?? ALL_TIME); + + const filters = { + period: effectivePeriod, + accountType: accountFilter, + transactionType: typeFilter, + }; + + const filtered = filterDashboardTransactions(transactions, filters); + const previous = resolvePreviousPeriodTransactions(transactions, filters); + const summary = calculateDashboardSummary(filtered, previous); + const trendMode = effectivePeriod === ALL_TIME ? "month" : "day"; + const trendPoints = groupExpensesByTime(filtered, trendMode); + const categoryAll = groupExpensesByCategory(filtered, { + topN: 50, + collapseOther: false, + }); + const categoryTop = groupExpensesByCategory(filtered, { + topN: 5, + collapseOther: true, + }); + const largest = getLargestExpenses(filtered, 5); + const insights = generateMonthlyInsights(filtered, previous); + + return ( +
+
+

+ 2 · Dashboard +

+
+ + + +
+
+ + + +
+
+

+ Spending trend +

+

+ Expenses only + {trendMode === "day" ? " · by day" : " · by month"} +

+ +
+
+
+

+ Spending by category +

+

+ {uncategorizedExpenseCount > 0 + ? `${uncategorizedExpenseCount} expense${uncategorizedExpenseCount === 1 ? "" : "s"} still need a category` + : "All expenses categorized — edit any label anytime"} +

+
+ + + {(isCategorizing || categorizeProgress > 0) && ( +
+
+
+
+

+ {categorizeProgress >= 100 + ? "Done" + : `Working… ${Math.round(categorizeProgress)}%`} +

+
+ )} +

+ AI suggests categories from descriptions only. You stay in control. +

+ {categorizeError && ( +
+ {categorizeError} +
+ )} +
+
+ +
+
+

+ Largest expenses +

+ +
+
+

+ Monthly insights +

+ +
+
+
+ ); +} diff --git a/components/dashboard/DashboardSummaryCards.tsx b/components/dashboard/DashboardSummaryCards.tsx new file mode 100644 index 0000000..ed3fd0e --- /dev/null +++ b/components/dashboard/DashboardSummaryCards.tsx @@ -0,0 +1,86 @@ +"use client"; + +import type { DashboardSummary, MetricChange } from "@/lib/dashboard"; +import { formatCurrency } from "@/lib/formatMoney"; + +type DashboardSummaryCardsProps = { + summary: DashboardSummary; +}; + +function ChangeHint({ change }: { change: MetricChange | null | undefined }) { + if (!change) return null; + + if (change.percent === null) { + if (change.absolute === 0) { + return No prior month; + } + const sign = change.absolute > 0 ? "+" : ""; + return ( + + {sign} + {formatCurrency(change.absolute)} vs prior month + + ); + } + + const direction = change.percent > 0 ? "up" : change.percent < 0 ? "down" : "flat"; + const label = + direction === "flat" + ? "Same as prior month" + : `${Math.abs(change.percent).toFixed(0)}% ${direction} vs prior month`; + + return {label}; +} + +export default function DashboardSummaryCards({ + summary, +}: DashboardSummaryCardsProps) { + const cards = [ + { + label: "Income", + value: summary.income, + change: summary.changes?.income, + }, + { + label: "Expenses", + value: summary.expenses, + change: summary.changes?.expenses, + }, + { + label: "Net cash flow", + value: summary.netCashFlow, + change: summary.changes?.netCashFlow, + emphasize: true, + }, + { + label: "Transfers", + value: summary.transfers, + change: summary.changes?.transfers, + }, + ]; + + return ( +
+ {cards.map((card) => ( +
+

+ {card.label} +

+

+ {formatCurrency(card.value)} +

+
+ +
+
+ ))} +
+ ); +} diff --git a/components/dashboard/LargestExpenses.tsx b/components/dashboard/LargestExpenses.tsx new file mode 100644 index 0000000..95fe79d --- /dev/null +++ b/components/dashboard/LargestExpenses.tsx @@ -0,0 +1,61 @@ +"use client"; + +import { cleanDescription } from "@/lib/dashboard"; +import { formatCurrency } from "@/lib/formatMoney"; +import type { Transaction } from "@/types/transaction"; + +type LargestExpensesProps = { + expenses: Transaction[]; + onViewAll: () => void; +}; + +export default function LargestExpenses({ + expenses, + onViewAll, +}: LargestExpensesProps) { + if (expenses.length === 0) { + return ( +
+ No expenses in this period. +
+ ); + } + + return ( +
+
    + {expenses.map((expense) => ( +
  • +
    +

    + {cleanDescription(expense.description)} +

    +

    + {expense.date} + {" · "} + {expense.category ?? "Uncategorized"} + {" · "} + {expense.accountType} +

    +
    +

    + {formatCurrency(Math.abs(expense.amount))} +

    +
  • + ))} +
+
+ +
+
+ ); +} diff --git a/components/dashboard/MonthlyInsights.tsx b/components/dashboard/MonthlyInsights.tsx new file mode 100644 index 0000000..b867532 --- /dev/null +++ b/components/dashboard/MonthlyInsights.tsx @@ -0,0 +1,30 @@ +"use client"; + +import type { Insight } from "@/lib/dashboard"; + +type MonthlyInsightsProps = { + insights: Insight[]; +}; + +export default function MonthlyInsights({ insights }: MonthlyInsightsProps) { + if (insights.length === 0) { + return ( +
+ Not enough activity yet for insights. +
+ ); + } + + return ( +
    + {insights.map((insight) => ( +
  • + {insight.text} +
  • + ))} +
+ ); +} diff --git a/components/dashboard/SpendingTrendChart.tsx b/components/dashboard/SpendingTrendChart.tsx new file mode 100644 index 0000000..8b53faa --- /dev/null +++ b/components/dashboard/SpendingTrendChart.tsx @@ -0,0 +1,81 @@ +"use client"; + +import { + Bar, + BarChart, + CartesianGrid, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; +import type { TrendPoint } from "@/lib/dashboard"; +import { formatCurrency } from "@/lib/formatMoney"; + +type SpendingTrendChartProps = { + points: TrendPoint[]; + mode: "day" | "month"; +}; + +export default function SpendingTrendChart({ + points, + mode, +}: SpendingTrendChartProps) { + if (points.length === 0) { + return ( +
+ No expenses in this period to chart. +
+ ); + } + + return ( +
+ + + + + + `$${Math.round(value).toLocaleString("en-US")}` + } + width={56} + /> + [ + formatCurrency(typeof value === "number" ? value : Number(value)), + "Expenses", + ]} + labelFormatter={(label) => String(label)} + contentStyle={{ + background: "var(--surface)", + border: "1px solid var(--border)", + borderRadius: 0, + fontSize: 12, + }} + /> + + + +
+ ); +} diff --git a/lib/dashboard.test.ts b/lib/dashboard.test.ts new file mode 100644 index 0000000..3e8afc4 --- /dev/null +++ b/lib/dashboard.test.ts @@ -0,0 +1,411 @@ +import { describe, expect, it } from "vitest"; +import { + ALL_TIME, + calculateDashboardSummary, + calculateMetricChange, + calculateTotals, + filterDashboardTransactions, + generateMonthlyInsights, + getLargestExpenses, + getPreviousMonthKey, + groupExpensesByCategory, + groupExpensesByTime, +} from "@/lib/dashboard"; +import type { Transaction } from "@/types/transaction"; + +function tx( + partial: Partial & + Pick, +): Transaction { + return { + description: partial.description ?? "Test", + accountType: partial.accountType ?? "checking", + sourceFile: partial.sourceFile ?? "test.csv", + category: partial.category ?? null, + categorySource: partial.categorySource ?? null, + categoryConfidence: partial.categoryConfidence ?? null, + ...partial, + }; +} + +describe("calculateTotals", () => { + it("excludes transfers from income and expenses", () => { + const totals = calculateTotals([ + tx({ id: "1", date: "01/10/2026", amount: 1000, transactionType: "income" }), + tx({ id: "2", date: "01/11/2026", amount: -40, transactionType: "expense" }), + tx({ + id: "3", + date: "01/12/2026", + amount: -200, + transactionType: "transfer", + }), + tx({ + id: "4", + date: "01/13/2026", + amount: 50, + transactionType: "transfer", + }), + ]); + + expect(totals.income).toBe(1000); + expect(totals.expenses).toBe(40); + expect(totals.transfers).toBe(250); + expect(totals.netCashFlow).toBe(960); + }); + + it("uses transactionType rather than amount sign alone", () => { + const totals = calculateTotals([ + // Positive expense should still count as expense if typed that way + tx({ + id: "1", + date: "01/10/2026", + amount: 25, + transactionType: "expense", + }), + // Negative income still income by type + tx({ + id: "2", + date: "01/11/2026", + amount: -10, + transactionType: "income", + }), + ]); + + expect(totals.expenses).toBe(25); + expect(totals.income).toBe(10); + expect(totals.netCashFlow).toBe(-15); + }); + + it("handles empty arrays", () => { + expect(calculateTotals([])).toEqual({ + income: 0, + expenses: 0, + transfers: 0, + netCashFlow: 0, + }); + }); +}); + +describe("net cash flow", () => { + it("is income minus expenses", () => { + const summary = calculateDashboardSummary( + [ + tx({ + id: "1", + date: "01/01/2026", + amount: 500, + transactionType: "income", + }), + tx({ + id: "2", + date: "01/02/2026", + amount: -120, + transactionType: "expense", + }), + ], + null, + ); + expect(summary.netCashFlow).toBe(380); + }); +}); + +describe("date filtering", () => { + const rows = [ + tx({ + id: "1", + date: "01/05/2026", + amount: -10, + transactionType: "expense", + accountType: "checking", + }), + tx({ + id: "2", + date: "07/05/2026", + amount: -20, + transactionType: "expense", + accountType: "credit", + }), + tx({ + id: "3", + date: "07/06/2026", + amount: 100, + transactionType: "income", + accountType: "checking", + }), + ]; + + it("filters by month period", () => { + const filtered = filterDashboardTransactions(rows, { + period: "2026-07", + accountType: "all", + transactionType: "all", + }); + expect(filtered.map((t) => t.id)).toEqual(["2", "3"]); + }); + + it("supports all-time", () => { + const filtered = filterDashboardTransactions(rows, { + period: ALL_TIME, + accountType: "all", + transactionType: "all", + }); + expect(filtered).toHaveLength(3); + }); + + it("filters by account and type", () => { + const filtered = filterDashboardTransactions(rows, { + period: ALL_TIME, + accountType: "checking", + transactionType: "expense", + }); + expect(filtered.map((t) => t.id)).toEqual(["1"]); + }); +}); + +describe("category grouping", () => { + it("groups expenses and sorts highest to lowest", () => { + const { items, totalExpenses } = groupExpensesByCategory( + [ + tx({ + id: "1", + date: "01/01/2026", + amount: -10, + transactionType: "expense", + category: "Dining", + }), + tx({ + id: "2", + date: "01/02/2026", + amount: -30, + transactionType: "expense", + category: "Dining", + }), + tx({ + id: "3", + date: "01/03/2026", + amount: -20, + transactionType: "expense", + category: null, + }), + tx({ + id: "4", + date: "01/04/2026", + amount: 100, + transactionType: "income", + category: null, + }), + ], + { topN: 5, collapseOther: false }, + ); + + expect(totalExpenses).toBe(60); + expect(items[0]?.category).toBe("Dining"); + expect(items[0]?.total).toBe(40); + expect(items[0]?.count).toBe(2); + expect(items[0]?.percent).toBeCloseTo((40 / 60) * 100); + expect(items[1]?.category).toBe("Uncategorized"); + }); + + it("collapses overflow into Other", () => { + const many = Array.from({ length: 7 }, (_, i) => + tx({ + id: String(i), + date: "01/01/2026", + amount: -(i + 1) * 10, + transactionType: "expense", + category: + ( + [ + "Dining", + "Groceries", + "Transportation", + "Shopping", + "Bills", + "Entertainment", + "Health", + ] as const + )[i]!, + }), + ); + const { items } = groupExpensesByCategory(many, { + topN: 5, + collapseOther: true, + }); + expect(items).toHaveLength(6); + expect(items[items.length - 1]?.category).toBe("Other"); + }); +}); + +describe("largest expenses", () => { + it("orders by absolute amount descending", () => { + const largest = getLargestExpenses( + [ + tx({ + id: "a", + date: "01/01/2026", + amount: -10, + transactionType: "expense", + }), + tx({ + id: "b", + date: "01/02/2026", + amount: -50, + transactionType: "expense", + }), + tx({ + id: "c", + date: "01/03/2026", + amount: -20, + transactionType: "expense", + }), + tx({ + id: "d", + date: "01/04/2026", + amount: 1000, + transactionType: "income", + }), + ], + 2, + ); + expect(largest.map((t) => t.id)).toEqual(["b", "c"]); + }); +}); + +describe("month-over-month comparison", () => { + it("computes previous month key", () => { + expect(getPreviousMonthKey("2026-01")).toBe("2025-12"); + expect(getPreviousMonthKey("2026-07")).toBe("2026-06"); + }); + + it("returns null percent when previous is zero", () => { + const change = calculateMetricChange(50, 0); + expect(change.absolute).toBe(50); + expect(change.percent).toBeNull(); + }); + + it("compares with previous month totals", () => { + const summary = calculateDashboardSummary( + [ + tx({ + id: "1", + date: "07/01/2026", + amount: -100, + transactionType: "expense", + }), + ], + [ + tx({ + id: "2", + date: "06/01/2026", + amount: -80, + transactionType: "expense", + }), + ], + ); + + expect(summary.changes?.expenses?.absolute).toBe(20); + expect(summary.changes?.expenses?.percent).toBeCloseTo(25); + }); + + it("handles zero previous month without crashing", () => { + const summary = calculateDashboardSummary( + [ + tx({ + id: "1", + date: "07/01/2026", + amount: -40, + transactionType: "expense", + }), + ], + [], + ); + expect(summary.previous?.expenses).toBe(0); + expect(summary.changes?.expenses?.percent).toBeNull(); + }); +}); + +describe("trend grouping", () => { + it("groups expenses by day and excludes income/transfers", () => { + const points = groupExpensesByTime( + [ + tx({ + id: "1", + date: "07/01/2026", + amount: -10, + transactionType: "expense", + }), + tx({ + id: "2", + date: "07/01/2026", + amount: -5, + transactionType: "expense", + }), + tx({ + id: "3", + date: "07/02/2026", + amount: 100, + transactionType: "income", + }), + tx({ + id: "4", + date: "07/02/2026", + amount: -50, + transactionType: "transfer", + }), + ], + "day", + ); + expect(points).toEqual([ + { key: "2026-07-01", label: "07/01", total: 15 }, + ]); + }); +}); + +describe("insights", () => { + it("generates supported insights only", () => { + const insights = generateMonthlyInsights( + [ + tx({ + id: "1", + date: "07/01/2026", + amount: 1000, + transactionType: "income", + }), + tx({ + id: "2", + date: "07/02/2026", + amount: -200, + transactionType: "expense", + description: "CAFE", + category: "Dining", + }), + tx({ + id: "3", + date: "07/03/2026", + amount: -50, + transactionType: "expense", + description: "CAFE", + category: null, + }), + ], + [ + tx({ + id: "4", + date: "06/01/2026", + amount: -100, + transactionType: "expense", + category: "Dining", + }), + ], + ); + + expect(insights.length).toBeGreaterThan(0); + expect(insights.length).toBeLessThanOrEqual(4); + expect(insights.some((i) => i.id === "largest-category")).toBe(true); + expect(insights.some((i) => i.id === "uncategorized")).toBe(true); + }); + + it("returns empty list for empty transactions", () => { + expect(generateMonthlyInsights([], null)).toEqual([]); + }); +}); diff --git a/lib/dashboard.ts b/lib/dashboard.ts new file mode 100644 index 0000000..d955533 --- /dev/null +++ b/lib/dashboard.ts @@ -0,0 +1,434 @@ +import { + ALL_TIME, + filterTransactionsByPeriod, + formatMonthLabel, + getMonthKey, +} from "@/lib/calculateMonthlySummary"; +import type { + AccountType, + Transaction, + TransactionType, +} from "@/types/transaction"; + +export type AccountFilter = "all" | AccountType; +export type TypeFilter = "all" | TransactionType; + +export type DashboardFilters = { + period: string | null; + accountType: AccountFilter; + transactionType: TypeFilter; +}; + +export type Totals = { + income: number; + expenses: number; + netCashFlow: number; + transfers: number; +}; + +export type MetricChange = { + absolute: number; + /** Null when the previous value is zero (avoids misleading percentages). */ + percent: number | null; +}; + +export type DashboardSummary = Totals & { + previous: Totals | null; + changes: { + income: MetricChange | null; + expenses: MetricChange | null; + netCashFlow: MetricChange | null; + transfers: MetricChange | null; + } | null; +}; + +export type TrendPoint = { + key: string; + label: string; + total: number; +}; + +export type CategoryBreakdownItem = { + category: string; + total: number; + percent: number; + count: number; +}; + +export type Insight = { + id: string; + text: string; +}; + +/** Strip common bank noise for display without changing stored data. */ +export function cleanDescription(description: string): string { + return description + .split(" DES:")[0] + ?.split(";")[0] + ?.trim() + .replace(/\s+/g, " ") ?? description; +} + +export function filterByAccountType( + transactions: Transaction[], + accountType: AccountFilter, +): Transaction[] { + if (accountType === "all") return transactions; + return transactions.filter((t) => t.accountType === accountType); +} + +export function filterByTransactionType( + transactions: Transaction[], + transactionType: TypeFilter, +): Transaction[] { + if (transactionType === "all") return transactions; + return transactions.filter((t) => t.transactionType === transactionType); +} + +export function filterDashboardTransactions( + transactions: Transaction[], + filters: DashboardFilters, +): Transaction[] { + const byPeriod = filterTransactionsByPeriod(transactions, filters.period); + const byAccount = filterByAccountType(byPeriod, filters.accountType); + return filterByTransactionType(byAccount, filters.transactionType); +} + +/** + * Uses existing transactionType — never amount sign alone. + * Transfers are excluded from income and expenses. + */ +export function calculateTotals(transactions: Transaction[]): Totals { + let income = 0; + let expenses = 0; + let transfers = 0; + + for (const transaction of transactions) { + if (transaction.transactionType === "transfer") { + transfers += Math.abs(transaction.amount); + continue; + } + if (transaction.transactionType === "expense") { + expenses += Math.abs(transaction.amount); + continue; + } + if (transaction.transactionType === "income") { + income += Math.abs(transaction.amount); + } + } + + return { + income, + expenses, + transfers, + netCashFlow: income - expenses, + }; +} + +export function getPreviousMonthKey(monthKey: string): string { + const [yearPart, monthPart] = monthKey.split("-"); + const year = Number(yearPart); + const month = Number(monthPart); + const date = new Date(year, month - 2, 1); + return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`; +} + +export function calculateMetricChange( + current: number, + previous: number, +): MetricChange { + const absolute = current - previous; + if (previous === 0) { + return { absolute, percent: null }; + } + return { + absolute, + percent: (absolute / Math.abs(previous)) * 100, + }; +} + +export function calculateDashboardSummary( + currentTransactions: Transaction[], + previousTransactions: Transaction[] | null, +): DashboardSummary { + const current = calculateTotals(currentTransactions); + if (!previousTransactions) { + return { + ...current, + previous: null, + changes: null, + }; + } + + const previous = calculateTotals(previousTransactions); + return { + ...current, + previous, + changes: { + income: calculateMetricChange(current.income, previous.income), + expenses: calculateMetricChange(current.expenses, previous.expenses), + netCashFlow: calculateMetricChange( + current.netCashFlow, + previous.netCashFlow, + ), + transfers: calculateMetricChange(current.transfers, previous.transfers), + }, + }; +} + +function parseDateParts(date: string): { month: string; day: string; year: string } { + const [month, day, year] = date.split("/"); + return { + month: (month ?? "").padStart(2, "0"), + day: (day ?? "").padStart(2, "0"), + year: year ?? "", + }; +} + +/** Expenses only; income and transfers excluded. */ +export function groupExpensesByTime( + transactions: Transaction[], + mode: "day" | "month", +): TrendPoint[] { + const totals = new Map(); + + for (const transaction of transactions) { + if (transaction.transactionType !== "expense") continue; + const { month, day, year } = parseDateParts(transaction.date); + const key = + mode === "day" ? `${year}-${month}-${day}` : `${year}-${month}`; + totals.set(key, (totals.get(key) ?? 0) + Math.abs(transaction.amount)); + } + + return Array.from(totals.entries()) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([key, total]) => { + if (mode === "month") { + return { + key, + label: formatMonthLabel(key), + total, + }; + } + const [, m, d] = key.split("-"); + return { + key, + label: `${m}/${d}`, + total, + }; + }); +} + +export function groupExpensesByCategory( + transactions: Transaction[], + options: { topN?: number; collapseOther?: boolean } = {}, +): { items: CategoryBreakdownItem[]; totalExpenses: number } { + const { topN = 5, collapseOther = true } = options; + const expenses = transactions.filter((t) => t.transactionType === "expense"); + const totalExpenses = expenses.reduce( + (sum, t) => sum + Math.abs(t.amount), + 0, + ); + + const groups = new Map(); + for (const transaction of expenses) { + const category = transaction.category ?? "Uncategorized"; + const existing = groups.get(category) ?? { total: 0, count: 0 }; + existing.total += Math.abs(transaction.amount); + existing.count += 1; + groups.set(category, existing); + } + + const ranked = Array.from(groups.entries()) + .map(([category, data]) => ({ + category, + total: data.total, + count: data.count, + percent: totalExpenses === 0 ? 0 : (data.total / totalExpenses) * 100, + })) + .sort((a, b) => b.total - a.total); + + if (!collapseOther || ranked.length <= topN) { + return { items: ranked, totalExpenses }; + } + + const top = ranked.slice(0, topN); + const rest = ranked.slice(topN); + const otherTotal = rest.reduce((sum, item) => sum + item.total, 0); + const otherCount = rest.reduce((sum, item) => sum + item.count, 0); + + return { + items: [ + ...top, + { + category: "Other", + total: otherTotal, + count: otherCount, + percent: totalExpenses === 0 ? 0 : (otherTotal / totalExpenses) * 100, + }, + ], + totalExpenses, + }; +} + +export function getLargestExpenses( + transactions: Transaction[], + limit = 5, +): Transaction[] { + return [...transactions] + .filter((t) => t.transactionType === "expense") + .sort((a, b) => Math.abs(b.amount) - Math.abs(a.amount)) + .slice(0, limit); +} + +function merchantKey(description: string): string { + return cleanDescription(description).toLowerCase(); +} + +/** + * Rule-based insights only (no LLM). Returns up to four items supported by data. + */ +export function generateMonthlyInsights( + currentTransactions: Transaction[], + previousTransactions: Transaction[] | null, +): Insight[] { + const insights: Insight[] = []; + const expenses = currentTransactions.filter( + (t) => t.transactionType === "expense", + ); + const totals = calculateTotals(currentTransactions); + + if (expenses.length === 0 && totals.income === 0 && totals.transfers === 0) { + return insights; + } + + const { items } = groupExpensesByCategory(expenses, { + topN: 20, + collapseOther: false, + }); + const largestCategory = items.find((item) => item.category !== "Other"); + if (largestCategory && largestCategory.total > 0) { + insights.push({ + id: "largest-category", + text: `${largestCategory.category} was your largest category at ${formatCurrencyLabel(largestCategory.total)}.`, + }); + } + + const largestExpense = getLargestExpenses(expenses, 1)[0]; + if (largestExpense) { + insights.push({ + id: "largest-expense", + text: `Your largest expense was ${cleanDescription(largestExpense.description)} at ${formatCurrencyLabel(Math.abs(largestExpense.amount))}.`, + }); + } + + const frequency = new Map(); + for (const expense of expenses) { + const key = merchantKey(expense.description); + const existing = frequency.get(key); + if (existing) { + existing.count += 1; + } else { + frequency.set(key, { + count: 1, + label: cleanDescription(expense.description), + }); + } + } + const mostFrequent = Array.from(frequency.values()).sort( + (a, b) => b.count - a.count, + )[0]; + if (mostFrequent && mostFrequent.count >= 2) { + insights.push({ + id: "frequent-merchant", + text: `${mostFrequent.label} appeared most often (${mostFrequent.count} times).`, + }); + } + + const uncategorized = expenses.filter((t) => t.category === null).length; + if (uncategorized > 0) { + insights.push({ + id: "uncategorized", + text: + uncategorized === 1 + ? "One expense still needs a category." + : `${uncategorized} expenses still need a category.`, + }); + } + + if (previousTransactions) { + const previous = calculateTotals(previousTransactions); + if (previous.expenses > 0) { + const change = calculateMetricChange(totals.expenses, previous.expenses); + if (change.percent !== null && Math.abs(change.percent) >= 1) { + const direction = change.percent < 0 ? "less" : "more"; + insights.push({ + id: "mom-expenses", + text: `You spent ${Math.abs(change.percent).toFixed(0)}% ${direction} than last month.`, + }); + } + } else if (totals.expenses > 0 && previous.expenses === 0) { + insights.push({ + id: "mom-expenses-new", + text: `Expenses this period totaled ${formatCurrencyLabel(totals.expenses)} (no expenses in the prior month).`, + }); + } + } + + const byDay = new Map(); + for (const expense of expenses) { + byDay.set( + expense.date, + (byDay.get(expense.date) ?? 0) + Math.abs(expense.amount), + ); + } + const highestDay = Array.from(byDay.entries()).sort((a, b) => b[1] - a[1])[0]; + if (highestDay && highestDay[1] > 0) { + insights.push({ + id: "highest-day", + text: `${highestDay[0]} was your highest-spending day at ${formatCurrencyLabel(highestDay[1])}.`, + }); + } + + if (totals.income > 0 && totals.expenses > 0) { + const spentShare = (totals.expenses / totals.income) * 100; + insights.push({ + id: "income-spent", + text: `Expenses were ${spentShare.toFixed(0)}% of income this period.`, + }); + } + + return insights.slice(0, 4); +} + +function formatCurrencyLabel(amount: number): string { + return new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + maximumFractionDigits: 0, + }).format(amount); +} + +export function resolvePreviousPeriodTransactions( + allTransactions: Transaction[], + filters: DashboardFilters, +): Transaction[] | null { + if (!filters.period || filters.period === ALL_TIME) { + return null; + } + + const previousKey = getPreviousMonthKey(filters.period); + const previousFilters: DashboardFilters = { + ...filters, + period: previousKey, + }; + const previous = filterDashboardTransactions( + allTransactions, + previousFilters, + ); + + // Still return the (possibly empty) set so zero-previous comparisons work. + return previous; +} + +export { ALL_TIME, getMonthKey }; diff --git a/lib/formatMoney.ts b/lib/formatMoney.ts new file mode 100644 index 0000000..f2f2671 --- /dev/null +++ b/lib/formatMoney.ts @@ -0,0 +1,10 @@ +export function formatCurrency(amount: number): string { + return new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + }).format(amount); +} + +export function formatPercent(value: number): string { + return `${value.toFixed(0)}%`; +} diff --git a/package-lock.json b/package-lock.json index b9940a7..bfbc823 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,8 @@ "next": "16.2.11", "papaparse": "^5.5.4", "react": "19.2.4", - "react-dom": "19.2.4" + "react-dom": "19.2.4", + "recharts": "^3.10.0" }, "devDependencies": { "@tailwindcss/postcss": "^4", @@ -19,10 +20,13 @@ "@types/papaparse": "^5.5.2", "@types/react": "^19", "@types/react-dom": "^19", + "@vitejs/plugin-react": "^6.0.4", "eslint": "^9", "eslint-config-next": "16.2.11", "tailwindcss": "^4", - "typescript": "^5" + "typescript": "^5", + "vite-tsconfig-paths": "^6.1.1", + "vitest": "^4.1.10" } }, "node_modules/@alloc/quick-lru": { @@ -1248,6 +1252,347 @@ "node": ">=12.4.0" } }, + "node_modules/@oxc-project/types": { + "version": "0.139.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", + "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@reduxjs/toolkit": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz", + "integrity": "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", + "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz", + "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz", + "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz", + "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz", + "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz", + "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz", + "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz", + "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz", + "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz", + "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz", + "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz", + "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz", + "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", + "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz", + "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, "node_modules/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -1255,6 +1600,18 @@ "dev": true, "license": "MIT" }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, "node_modules/@swc/helpers": { "version": "0.5.15", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", @@ -1546,6 +1903,87 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -1591,7 +2029,7 @@ "version": "19.2.17", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -1607,6 +2045,12 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.65.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.65.0.tgz", @@ -2249,6 +2693,145 @@ "win32" ] }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.4.tgz", + "integrity": "sha512-XcCQz0TBpBgljhj0gMuuDj49i6Ytqh5q1osT/Gp5uAVJUCTWxyskk/l1jwYYiu2xcNHHipdMz40EGfM1VdamVg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/acorn": { "version": "8.17.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", @@ -2482,6 +3065,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/ast-types-flow": { "version": "0.0.8", "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", @@ -2692,6 +3285,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -2715,6 +3318,15 @@ "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", "license": "MIT" }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -2768,9 +3380,130 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/damerau-levenshtein": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", @@ -2850,6 +3583,12 @@ } } }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -3095,6 +3834,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", @@ -3158,6 +3904,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/es-toolkit": { + "version": "1.49.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.49.0.tgz", + "integrity": "sha512-G5iZ6Pc/FNRY/soKZHC+TxGDD83rHUDXxzaWhGCX44vAv/tMs56WMusnm/KMNK+luUPsgA9U28cGr4RDlSzL2g==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -3577,6 +4333,16 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -3587,6 +4353,22 @@ "node": ">=0.10.0" } }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -3722,10 +4504,25 @@ "is-callable": "^1.2.7" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, "node_modules/function-bind": { @@ -3905,6 +4702,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/globrex": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/globrex/-/globrex-0.1.2.tgz", + "integrity": "sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==", + "dev": true, + "license": "MIT" + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -4046,6 +4850,16 @@ "node": ">= 4" } }, + "node_modules/immer": { + "version": "11.1.15", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.15.tgz", + "integrity": "sha512-VrNANlmnWQnh5COXIIOQXM9oOJw7naGKlBT74ZOOR6lpVXc3gFEu9FJLDFcpCJ2j+NWr8TIwtWD//T6ZX6TKiQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -4088,6 +4902,15 @@ "node": ">= 0.4" } }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/is-array-buffer": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", @@ -5353,6 +6176,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -5468,6 +6305,13 @@ "dev": true, "license": "MIT" }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -5604,9 +6448,76 @@ "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "dev": true, "license": "MIT" }, + "node_modules/react-redux": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz", + "integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, + "node_modules/recharts": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.10.0.tgz", + "integrity": "sha512-wulMvfncpIlmu2uFtRU/mE5/+NiVtASXkw2KdwJTdHs3WsASX0WxZlX+rpKgyn5BDbIhkPtCpUKkB9XNK5KE0w==", + "license": "MIT", + "workspaces": [ + "www" + ], + "dependencies": { + "@reduxjs/toolkit": "^1.9.0 || 2.x.x", + "clsx": "^2.1.1", + "decimal.js-light": "^2.5.1", + "es-toolkit": "^1.39.3", + "eventemitter3": "^5.0.1", + "immer": "^11.1.8", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.2.0", + "tiny-invariant": "^1.3.3", + "use-sync-external-store": "^1.2.2", + "victory-vendor": "^37.0.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -5651,6 +6562,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/reselect": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.2.0.tgz", + "integrity": "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==", + "license": "MIT" + }, "node_modules/resolve": { "version": "2.0.0-next.7", "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.7.tgz", @@ -5706,6 +6623,40 @@ "node": ">=0.10.0" } }, + "node_modules/rolldown": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", + "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.139.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.1.5", + "@rolldown/binding-darwin-arm64": "1.1.5", + "@rolldown/binding-darwin-x64": "1.1.5", + "@rolldown/binding-freebsd-x64": "1.1.5", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", + "@rolldown/binding-linux-arm64-gnu": "1.1.5", + "@rolldown/binding-linux-arm64-musl": "1.1.5", + "@rolldown/binding-linux-ppc64-gnu": "1.1.5", + "@rolldown/binding-linux-s390x-gnu": "1.1.5", + "@rolldown/binding-linux-x64-gnu": "1.1.5", + "@rolldown/binding-linux-x64-musl": "1.1.5", + "@rolldown/binding-openharmony-arm64": "1.1.5", + "@rolldown/binding-wasm32-wasi": "1.1.5", + "@rolldown/binding-win32-arm64-msvc": "1.1.5", + "@rolldown/binding-win32-x64-msvc": "1.1.5" + } + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -6007,6 +6958,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -6023,6 +6981,20 @@ "dev": true, "license": "MIT" }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, "node_modules/stop-iteration-iterator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", @@ -6244,6 +7216,29 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -6292,6 +7287,16 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -6318,6 +7323,28 @@ "typescript": ">=4.8.4" } }, + "node_modules/tsconfck": { + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/tsconfck/-/tsconfck-3.1.6.tgz", + "integrity": "sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==", + "deprecated": "unmaintained", + "dev": true, + "license": "MIT", + "bin": { + "tsconfck": "bin/tsconfck.js" + }, + "engines": { + "node": "^18 || >=20" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/tsconfig-paths": { "version": "3.15.0", "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", @@ -6584,6 +7611,246 @@ "punycode": "^2.1.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/victory-vendor": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, + "node_modules/vite": { + "version": "8.1.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz", + "integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.17", + "rolldown": "~1.1.5", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-tsconfig-paths": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/vite-tsconfig-paths/-/vite-tsconfig-paths-6.1.1.tgz", + "integrity": "sha512-2cihq7zliibCCZ8P9cKJrQBkfgdvcFkOOc3Y02o3GWUDLgqjWsZudaoiuOwO/gzTzy17cS5F7ZPo4bsnS4DGkg==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "globrex": "^0.1.2", + "tsconfck": "^3.0.3" + }, + "peerDependencies": { + "vite": "*" + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vitest": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -6689,6 +7956,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", diff --git a/package.json b/package.json index 8d31acf..18f679b 100644 --- a/package.json +++ b/package.json @@ -6,13 +6,16 @@ "dev": "next dev", "build": "next build", "start": "next start", - "lint": "eslint" + "lint": "eslint", + "test": "vitest run", + "typecheck": "tsc --noEmit" }, "dependencies": { "next": "16.2.11", "papaparse": "^5.5.4", "react": "19.2.4", - "react-dom": "19.2.4" + "react-dom": "19.2.4", + "recharts": "^3.10.0" }, "devDependencies": { "@tailwindcss/postcss": "^4", @@ -20,9 +23,12 @@ "@types/papaparse": "^5.5.2", "@types/react": "^19", "@types/react-dom": "^19", + "@vitejs/plugin-react": "^6.0.4", "eslint": "^9", "eslint-config-next": "16.2.11", "tailwindcss": "^4", - "typescript": "^5" + "typescript": "^5", + "vite-tsconfig-paths": "^6.1.1", + "vitest": "^4.1.10" } } diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..770ac60 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,14 @@ +import path from "node:path"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + include: ["**/*.test.ts"], + }, + resolve: { + alias: { + "@": path.resolve(__dirname, "."), + }, + }, +});