@startsimpli/ui 0.4.171 → 0.4.172

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@startsimpli/ui",
3
- "version": "0.4.171",
3
+ "version": "0.4.172",
4
4
  "description": "Shared UI components package for StartSimpli applications",
5
5
  "license": "MIT",
6
6
  "homepage": "https://startsimpli.com",
@@ -22,6 +22,7 @@
22
22
  "./gantt": "./src/components/gantt/index.ts",
23
23
  "./gantt/styles": "./src/components/gantt/gantt.css",
24
24
  "./charts": "./src/components/charts/index.ts",
25
+ "./payoff": "./src/components/payoff/index.ts",
25
26
  "./components": "./src/components/index.ts",
26
27
  "./error": "./src/components/error/index.ts",
27
28
  "./i18n": "./src/components/i18n/index.ts",
@@ -67,9 +68,9 @@
67
68
  "@codemirror/language": "^6.0.0",
68
69
  "@codemirror/state": "^6.0.0",
69
70
  "@codemirror/view": "^6.0.0",
71
+ "@startsimpli/api": "^0.5.69",
70
72
  "@startsimpli/auth": "^0.4.50",
71
- "@startsimpli/i18n": "^0.1.3",
72
- "@startsimpli/api": "^0.5.69"
73
+ "@startsimpli/i18n": "^0.1.3"
73
74
  },
74
75
  "peerDependenciesMeta": {
75
76
  "@startsimpli/api": {
@@ -164,8 +165,8 @@
164
165
  "tailwindcss": "^3.4.19",
165
166
  "tsup": "^8.5.1",
166
167
  "typescript": "^6.0.3",
167
- "@startsimpli/i18n": "0.1.3",
168
- "@startsimpli/realtime": "0.0.2"
168
+ "@startsimpli/realtime": "0.0.2",
169
+ "@startsimpli/i18n": "0.1.3"
169
170
  },
170
171
  "keywords": [
171
172
  "ui",
@@ -0,0 +1,38 @@
1
+ import * as React from 'react'
2
+ import { DonutChart } from '../dashboard/DonutChart'
3
+ import { moneyWhole } from './format'
4
+ import { totalsByTag, type CategoryItem } from './category'
5
+
6
+ export interface CategoryBreakdownProps {
7
+ items: CategoryItem[]
8
+ /** Tags shown before the rest roll into "Other" (default 6). */
9
+ topN?: number
10
+ centerLabel?: string
11
+ }
12
+
13
+ /** Palette cycles brand + muted tones from the theme contract. */
14
+ const COLORS = [
15
+ 'hsl(var(--primary))',
16
+ 'hsl(var(--primary) / 0.7)',
17
+ 'hsl(var(--primary) / 0.5)',
18
+ 'hsl(var(--muted-foreground))',
19
+ 'hsl(var(--muted-foreground) / 0.7)',
20
+ 'hsl(var(--muted-foreground) / 0.5)',
21
+ 'hsl(var(--border))',
22
+ ]
23
+
24
+ /** Donut of totals per generic tag, with a legend of amounts. */
25
+ export function CategoryBreakdown({ items, topN = 6, centerLabel = 'Total' }: CategoryBreakdownProps) {
26
+ const totals = React.useMemo(() => totalsByTag(items, topN), [items, topN])
27
+ const sum = totals.reduce((s, t) => s + t.total, 0)
28
+ return (
29
+ <DonutChart
30
+ segments={totals.map((t, i) => ({ label: t.tag, value: Math.max(t.total, 0), color: COLORS[i % COLORS.length] }))}
31
+ centerValue={moneyWhole(sum)}
32
+ centerLabel={centerLabel}
33
+ showLegend
34
+ formatLegendValue={(s) => moneyWhole(s.value)}
35
+ ariaLabel="Spending by category"
36
+ />
37
+ )
38
+ }
@@ -0,0 +1,23 @@
1
+ 'use client'
2
+
3
+ import * as React from 'react'
4
+ import { TimeSeriesChart } from '../charts/TimeSeriesChart'
5
+ import { monthlyByTag, type CategoryItem } from './category'
6
+
7
+ export interface CategoryTrendProps {
8
+ items: CategoryItem[]
9
+ topN?: number
10
+ height?: number
11
+ }
12
+
13
+ /** Monthly total per tag as one line each. */
14
+ export function CategoryTrend({ items, topN = 6, height = 280 }: CategoryTrendProps) {
15
+ const series = React.useMemo(() => {
16
+ const { tags, months, values } = monthlyByTag(items, topN)
17
+ return tags.map((tag) => ({
18
+ name: tag,
19
+ points: months.map((mk) => ({ t: `${mk}-01`, v: values[tag][mk] ?? 0 })),
20
+ }))
21
+ }, [items, topN])
22
+ return <TimeSeriesChart series={series} unit="$" height={height} ariaLabel="Monthly spending by category" />
23
+ }
@@ -0,0 +1,42 @@
1
+ 'use client'
2
+
3
+ import * as React from 'react'
4
+ import { TimeSeriesChart } from '../charts/TimeSeriesChart'
5
+ import type { TimeSeries } from '../charts/lib/series'
6
+ import type { PayoffDebtLabel, PayoffScheduleLike } from './types'
7
+
8
+ export interface PayoffBalanceChartProps {
9
+ schedule: PayoffScheduleLike
10
+ debts: PayoffDebtLabel[]
11
+ /** Draw the combined-balance line (default true). */
12
+ showTotal?: boolean
13
+ height?: number
14
+ }
15
+
16
+ /** Remaining balance per debt over the plan, plus an optional Total line. */
17
+ export function PayoffBalanceChart({ schedule, debts, showTotal = true, height = 320 }: PayoffBalanceChartProps) {
18
+ const series = React.useMemo(() => {
19
+ // Without a start date the x axis is a count of months from an arbitrary epoch.
20
+ const at = (m: { month: number; date?: string }) => m.date ?? Date.UTC(2000, m.month - 1, 1)
21
+ const out: TimeSeries[] = debts.map((d) => ({
22
+ name: d.name,
23
+ points: schedule.months.map((m) => ({
24
+ t: at(m),
25
+ v: m.payments.find((p) => p.debtId === d.id)?.endBalance ?? 0,
26
+ })),
27
+ }))
28
+ if (showTotal) {
29
+ out.push({ name: 'Total', points: schedule.months.map((m) => ({ t: at(m), v: m.totalBalance })) })
30
+ }
31
+ return out
32
+ }, [schedule, debts, showTotal])
33
+
34
+ return (
35
+ <TimeSeriesChart
36
+ series={series}
37
+ unit="$"
38
+ height={height}
39
+ ariaLabel="Remaining balance by debt over time"
40
+ />
41
+ )
42
+ }
@@ -0,0 +1,72 @@
1
+ 'use client'
2
+
3
+ import * as React from 'react'
4
+ import { Button } from '../ui/button'
5
+ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../ui/table'
6
+ import { money, monthYear } from './format'
7
+ import type { PayoffDebtLabel, PayoffScheduleLike } from './types'
8
+
9
+ export interface PayoffScheduleTableProps {
10
+ schedule: PayoffScheduleLike
11
+ debts: PayoffDebtLabel[]
12
+ /** Months per page (default 12). */
13
+ pageSize?: number
14
+ }
15
+
16
+ /** Month x debt: payment, interest and ending balance, one page of months at a time. */
17
+ export function PayoffScheduleTable({ schedule, debts, pageSize = 12 }: PayoffScheduleTableProps) {
18
+ const [page, setPage] = React.useState(0)
19
+ const pages = Math.max(1, Math.ceil(schedule.months.length / pageSize))
20
+ const safePage = Math.min(page, pages - 1)
21
+ const slice = schedule.months.slice(safePage * pageSize, safePage * pageSize + pageSize)
22
+ const name = (id: string) => debts.find((d) => d.id === id)?.name ?? id
23
+
24
+ return (
25
+ <div className="space-y-3">
26
+ <Table>
27
+ <TableHeader>
28
+ <TableRow>
29
+ <TableHead>Month</TableHead>
30
+ <TableHead>Debt</TableHead>
31
+ <TableHead className="text-right">Payment</TableHead>
32
+ <TableHead className="text-right">Interest</TableHead>
33
+ <TableHead className="text-right">Balance</TableHead>
34
+ </TableRow>
35
+ </TableHeader>
36
+ <TableBody>
37
+ {slice.flatMap((m) => [
38
+ ...m.payments.map((p, i) => (
39
+ <TableRow key={`${m.month}-${p.debtId}`}>
40
+ <TableCell>{i === 0 ? (m.date ? monthYear(m.date) : `Month ${m.month}`) : ''}</TableCell>
41
+ <TableCell>{name(p.debtId)}</TableCell>
42
+ <TableCell className="text-right tabular-nums">{money(p.payment)}</TableCell>
43
+ <TableCell className="text-right tabular-nums">{money(p.interest)}</TableCell>
44
+ <TableCell className="text-right tabular-nums">{money(p.endBalance)}</TableCell>
45
+ </TableRow>
46
+ )),
47
+ <TableRow key={`${m.month}-total`} className="bg-muted/40 font-medium">
48
+ <TableCell />
49
+ <TableCell>Total</TableCell>
50
+ <TableCell className="text-right tabular-nums">{money(m.totalPayment)}</TableCell>
51
+ <TableCell className="text-right tabular-nums">{money(m.totalInterest)}</TableCell>
52
+ <TableCell className="text-right tabular-nums">{money(m.totalBalance)}</TableCell>
53
+ </TableRow>,
54
+ ])}
55
+ </TableBody>
56
+ </Table>
57
+ <div className="flex items-center justify-between text-sm text-muted-foreground">
58
+ <span>
59
+ Page {safePage + 1} of {pages}
60
+ </span>
61
+ <div className="flex gap-2">
62
+ <Button variant="outline" size="sm" disabled={safePage === 0} onClick={() => setPage(safePage - 1)}>
63
+ Previous
64
+ </Button>
65
+ <Button variant="outline" size="sm" disabled={safePage >= pages - 1} onClick={() => setPage(safePage + 1)}>
66
+ Next
67
+ </Button>
68
+ </div>
69
+ </div>
70
+ </div>
71
+ )
72
+ }
@@ -0,0 +1,54 @@
1
+ import * as React from 'react'
2
+ import { Card, CardContent } from '../ui/card'
3
+ import { Progress } from '../ui/progress'
4
+ import { duration, monthYear, moneyWhole } from './format'
5
+ import type { PayoffScheduleLike } from './types'
6
+
7
+ export interface PayoffSummaryCardsProps {
8
+ schedule: PayoffScheduleLike
9
+ /** Total owed when the plan began; enables the progress card. */
10
+ startingBalance?: number
11
+ /** Total owed today; defaults to the starting balance (no progress yet). */
12
+ currentBalance?: number
13
+ }
14
+
15
+ function Stat({ label, value, hint }: { label: string; value: React.ReactNode; hint?: React.ReactNode }) {
16
+ return (
17
+ <Card>
18
+ <CardContent className="p-5">
19
+ <p className="text-sm text-muted-foreground">{label}</p>
20
+ <p className="mt-1 text-2xl font-semibold tabular-nums">{value}</p>
21
+ {hint ? <p className="mt-1 text-sm text-muted-foreground">{hint}</p> : null}
22
+ </CardContent>
23
+ </Card>
24
+ )
25
+ }
26
+
27
+ /** Debt-free date, interest cost, total paid and (optionally) progress. */
28
+ export function PayoffSummaryCards({ schedule, startingBalance, currentBalance }: PayoffSummaryCardsProps) {
29
+ const free = schedule.paidOff && schedule.debtFreeMonth !== null
30
+ const progress =
31
+ startingBalance && startingBalance > 0
32
+ ? Math.max(0, Math.min(100, (1 - (currentBalance ?? startingBalance) / startingBalance) * 100))
33
+ : null
34
+ return (
35
+ <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4" data-testid="payoff-summary">
36
+ <Stat
37
+ label="Debt-free"
38
+ value={free ? (schedule.debtFreeDate ? monthYear(schedule.debtFreeDate) : `Month ${schedule.debtFreeMonth}`) : 'Not within forecast'}
39
+ hint={free ? `in ${duration(schedule.debtFreeMonth!)}` : 'Raise the monthly payment to pay these off'}
40
+ />
41
+ <Stat label="Total interest" value={moneyWhole(schedule.totalInterest)} />
42
+ <Stat label="Total paid" value={moneyWhole(schedule.totalPaid)} />
43
+ {progress !== null ? (
44
+ <Card>
45
+ <CardContent className="p-5">
46
+ <p className="text-sm text-muted-foreground">Progress</p>
47
+ <p className="mt-1 text-2xl font-semibold tabular-nums">{Math.round(progress)}%</p>
48
+ <Progress value={progress} className="mt-3 h-2" aria-label="Paid off so far" />
49
+ </CardContent>
50
+ </Card>
51
+ ) : null}
52
+ </div>
53
+ )
54
+ }
@@ -0,0 +1,66 @@
1
+ import * as React from 'react'
2
+ import { ActivityTimeline, type ActivityTimelineItem } from '../ActivityTimeline'
3
+ import { duration, monthYear, moneyWhole } from './format'
4
+ import type { PayoffDebtLabel, PayoffScheduleLike } from './types'
5
+
6
+ export interface PayoffTimelineProps {
7
+ schedule: PayoffScheduleLike
8
+ debts: PayoffDebtLabel[]
9
+ /** Month each debt reached zero, from the schedule result. */
10
+ payoffMonthByDebt: Record<string, number>
11
+ }
12
+
13
+ /** Milestones in payoff order: each debt cleared, then debt-free. */
14
+ export function payoffMilestones(
15
+ schedule: PayoffScheduleLike,
16
+ debts: PayoffDebtLabel[],
17
+ payoffMonthByDebt: Record<string, number>,
18
+ ): ActivityTimelineItem[] {
19
+ const dateOf = (month: number) =>
20
+ schedule.months.find((m) => m.month === month)?.date ?? new Date(Date.UTC(2000, month - 1, 1)).toISOString()
21
+ const items: ActivityTimelineItem[] = debts
22
+ .filter((d) => (payoffMonthByDebt[d.id] ?? 0) > 0)
23
+ .map((d) => {
24
+ const month = payoffMonthByDebt[d.id]
25
+ const interest = schedule.months
26
+ .flatMap((m) => m.payments)
27
+ .filter((p) => p.debtId === d.id)
28
+ .reduce((s, p) => s + p.interest, 0)
29
+ return {
30
+ id: d.id,
31
+ type: 'debt_cleared',
32
+ title: `${d.name} paid off`,
33
+ description: `Month ${month} · ${moneyWhole(interest)} interest`,
34
+ occurredAt: dateOf(month),
35
+ tone: 'ok' as const,
36
+ }
37
+ })
38
+ .sort((a, b) => a.occurredAt.localeCompare(b.occurredAt))
39
+ if (schedule.paidOff && schedule.debtFreeMonth) {
40
+ items.push({
41
+ id: 'debt-free',
42
+ type: 'debt_free',
43
+ title: 'Debt-free',
44
+ description: `After ${duration(schedule.debtFreeMonth)} · ${moneyWhole(schedule.totalInterest)} total interest`,
45
+ occurredAt: dateOf(schedule.debtFreeMonth),
46
+ tone: 'ok',
47
+ })
48
+ }
49
+ return items
50
+ }
51
+
52
+ /** Future-facing timeline of payoff milestones, oldest first. */
53
+ export function PayoffTimeline({ schedule, debts, payoffMonthByDebt }: PayoffTimelineProps) {
54
+ const activities = React.useMemo(
55
+ () => payoffMilestones(schedule, debts, payoffMonthByDebt),
56
+ [schedule, debts, payoffMonthByDebt],
57
+ )
58
+ return (
59
+ <ActivityTimeline
60
+ activities={activities}
61
+ emptyTitle="No payoff milestones"
62
+ emptyDescription="Raise the monthly payment so these debts clear within the forecast."
63
+ formatTime={monthYear}
64
+ />
65
+ )
66
+ }
@@ -0,0 +1,41 @@
1
+ 'use client'
2
+
3
+ import * as React from 'react'
4
+ import { cn } from '../../lib/utils'
5
+
6
+ export interface RangeSliderProps {
7
+ label: string
8
+ value: number
9
+ min: number
10
+ max: number
11
+ step?: number
12
+ onChange: (value: number) => void
13
+ /** How the current value reads next to the label. */
14
+ format?: (value: number) => string
15
+ className?: string
16
+ }
17
+
18
+ /** A labelled range input with the current value shown. Native input: keyboard and a11y for free. */
19
+ export function RangeSlider({ label, value, min, max, step = 1, onChange, format, className }: RangeSliderProps) {
20
+ const id = React.useId()
21
+ return (
22
+ <div className={cn('space-y-2', className)}>
23
+ <div className="flex items-baseline justify-between text-sm">
24
+ <label htmlFor={id} className="font-medium">
25
+ {label}
26
+ </label>
27
+ <span className="tabular-nums text-muted-foreground">{format ? format(value) : value}</span>
28
+ </div>
29
+ <input
30
+ id={id}
31
+ type="range"
32
+ min={min}
33
+ max={max}
34
+ step={step}
35
+ value={value}
36
+ onChange={(e) => onChange(Number(e.target.value))}
37
+ className="h-2 w-full cursor-pointer accent-primary"
38
+ />
39
+ </div>
40
+ )
41
+ }
@@ -0,0 +1,66 @@
1
+ import * as React from 'react'
2
+ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../ui/table'
3
+ import { duration, monthYear, moneyWhole } from './format'
4
+ import type { PayoffScheduleLike } from './types'
5
+
6
+ export interface PayoffScenario {
7
+ id: string
8
+ label: string
9
+ schedule: PayoffScheduleLike
10
+ }
11
+
12
+ export interface ScenarioCompareProps {
13
+ /** The first scenario is the baseline the others are compared with. */
14
+ scenarios: PayoffScenario[]
15
+ }
16
+
17
+ function delta(value: number, unit: 'money' | 'months'): { text: string; good: boolean } | null {
18
+ if (value === 0) return null
19
+ const abs = Math.abs(value)
20
+ const text = unit === 'money' ? moneyWhole(abs) : duration(abs)
21
+ return { text: value > 0 ? `${text} less` : `${text} more`, good: value > 0 }
22
+ }
23
+
24
+ /** Debt-free date, months and interest per scenario, with savings vs the baseline. */
25
+ export function ScenarioCompare({ scenarios }: ScenarioCompareProps) {
26
+ const base = scenarios[0]?.schedule
27
+ return (
28
+ <Table data-testid="scenario-compare">
29
+ <TableHeader>
30
+ <TableRow>
31
+ <TableHead>Scenario</TableHead>
32
+ <TableHead>Debt-free</TableHead>
33
+ <TableHead className="text-right">Time</TableHead>
34
+ <TableHead className="text-right">Interest</TableHead>
35
+ <TableHead className="text-right">Total paid</TableHead>
36
+ </TableRow>
37
+ </TableHeader>
38
+ <TableBody>
39
+ {scenarios.map((s, i) => {
40
+ const sch = s.schedule
41
+ const bothFree = i > 0 && base && base.paidOff && sch.paidOff
42
+ const time = bothFree ? delta(base!.debtFreeMonth! - sch.debtFreeMonth!, 'months') : null
43
+ const interest = i > 0 && base ? delta(base.totalInterest - sch.totalInterest, 'money') : null
44
+ const tone = (g: boolean) => (g ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400')
45
+ return (
46
+ <TableRow key={s.id}>
47
+ <TableCell className="font-medium">{s.label}</TableCell>
48
+ <TableCell>
49
+ {sch.paidOff ? (sch.debtFreeDate ? monthYear(sch.debtFreeDate) : `Month ${sch.debtFreeMonth}`) : 'Not within forecast'}
50
+ </TableCell>
51
+ <TableCell className="text-right tabular-nums">
52
+ {sch.paidOff ? duration(sch.debtFreeMonth!) : '—'}
53
+ {time ? <div className={`text-xs ${tone(time.good)}`}>{time.text}</div> : null}
54
+ </TableCell>
55
+ <TableCell className="text-right tabular-nums">
56
+ {moneyWhole(sch.totalInterest)}
57
+ {interest ? <div className={`text-xs ${tone(interest.good)}`}>{interest.text}</div> : null}
58
+ </TableCell>
59
+ <TableCell className="text-right tabular-nums">{moneyWhole(sch.totalPaid)}</TableCell>
60
+ </TableRow>
61
+ )
62
+ })}
63
+ </TableBody>
64
+ </Table>
65
+ )
66
+ }
@@ -0,0 +1,51 @@
1
+ import { render, screen } from '@testing-library/react'
2
+ import { totalsByTag, monthlyByTag, monthKey } from '../category'
3
+ import { payoffMilestones, PayoffTimeline } from '../PayoffTimeline'
4
+ import type { PayoffScheduleLike } from '../types'
5
+
6
+ const items = [
7
+ { date: '2027-01-05', amount: 100, tag: 'food' },
8
+ { date: '2027-01-20', amount: 50, tag: 'food' },
9
+ { date: '2027-03-02', amount: 30, tag: 'fun' },
10
+ { date: '2027-03-09', amount: 10, tag: null },
11
+ { date: '2027-03-10', amount: 5, tag: 'tiny' },
12
+ ]
13
+
14
+ describe('category helpers', () => {
15
+ it('totals by tag, largest first, with Other rollup', () => {
16
+ expect(totalsByTag(items, 10).map((t) => [t.tag, t.total])).toEqual([
17
+ ['food', 150], ['fun', 30], ['Uncategorized', 10], ['tiny', 5],
18
+ ])
19
+ const r = totalsByTag(items, 2)
20
+ expect(r.map((t) => t.tag)).toEqual(['food', 'fun', 'Other'])
21
+ expect(r[2].total).toBe(15)
22
+ })
23
+ it('monthly series fill empty months with 0', () => {
24
+ const r = monthlyByTag(items, 10)
25
+ expect(r.months).toEqual(['2027-01', '2027-02', '2027-03'])
26
+ expect(r.values.food).toEqual({ '2027-01': 150, '2027-02': 0, '2027-03': 0 })
27
+ })
28
+ it('ignores bad dates and amounts', () => {
29
+ expect(monthKey('nope')).toBeNull()
30
+ expect(totalsByTag([{ date: 'x', amount: NaN, tag: 'a' }])).toEqual([])
31
+ })
32
+ })
33
+
34
+ const sched: PayoffScheduleLike = {
35
+ months: [1, 2, 3].map((m) => ({ month: m, date: `2027-0${m}-01`, payments: [
36
+ { debtId: 'a', month: m, startBalance: 1, interest: 2, payment: 1, endBalance: 0 },
37
+ ], totalPayment: 1, totalInterest: 2, totalBalance: 0 })),
38
+ debtFreeMonth: 3, debtFreeDate: '2027-03-01', totalInterest: 6, totalPaid: 3, paidOff: true,
39
+ }
40
+
41
+ describe('PayoffTimeline', () => {
42
+ it('lists debts in payoff order then debt-free', () => {
43
+ const m = payoffMilestones(sched, [{ id: 'a', name: 'Visa' }, { id: 'b', name: 'Auto' }], { a: 1, b: 3 })
44
+ expect(m.map((i) => i.title)).toEqual(['Visa paid off', 'Auto paid off', 'Debt-free'])
45
+ })
46
+ it('renders', () => {
47
+ render(<PayoffTimeline schedule={sched} debts={[{ id: 'a', name: 'Visa' }]} payoffMonthByDebt={{ a: 2 }} />)
48
+ expect(screen.getByText('Visa paid off')).toBeTruthy()
49
+ expect(screen.getByText('Debt-free')).toBeTruthy()
50
+ })
51
+ })
@@ -0,0 +1,86 @@
1
+ import { render, screen, fireEvent } from '@testing-library/react'
2
+ import { PayoffSummaryCards } from '../PayoffSummaryCards'
3
+ import { PayoffScheduleTable } from '../PayoffScheduleTable'
4
+ import { ScenarioCompare } from '../ScenarioCompare'
5
+ import { RangeSlider } from '../RangeSlider'
6
+ import { duration, monthYear } from '../format'
7
+ import type { PayoffScheduleLike } from '../types'
8
+
9
+ function schedule(over: Partial<PayoffScheduleLike> = {}, months = 3): PayoffScheduleLike {
10
+ return {
11
+ months: Array.from({ length: months }, (_, i) => ({
12
+ month: i + 1,
13
+ date: `2027-0${i + 1}-01`,
14
+ payments: [
15
+ { debtId: 'a', month: i + 1, startBalance: 100, interest: 1, payment: 50, endBalance: 51 },
16
+ { debtId: 'b', month: i + 1, startBalance: 10, interest: 0, payment: 10, endBalance: 0 },
17
+ ],
18
+ totalPayment: 60,
19
+ totalInterest: 1,
20
+ totalBalance: 51,
21
+ })),
22
+ debtFreeMonth: months,
23
+ debtFreeDate: '2027-03-01',
24
+ totalInterest: 300,
25
+ totalPaid: 1300,
26
+ paidOff: true,
27
+ ...over,
28
+ }
29
+ }
30
+
31
+ describe('format', () => {
32
+ it('formats durations and month/year', () => {
33
+ expect(duration(14)).toBe('1 year 2 months')
34
+ expect(duration(5)).toBe('5 months')
35
+ expect(duration(24)).toBe('2 years')
36
+ expect(monthYear('2028-03-01')).toBe('Mar 2028')
37
+ })
38
+ })
39
+
40
+ describe('PayoffSummaryCards', () => {
41
+ it('shows debt-free date, interest and progress', () => {
42
+ render(<PayoffSummaryCards schedule={schedule()} startingBalance={1000} currentBalance={750} />)
43
+ expect(screen.getByText('Mar 2027')).toBeTruthy()
44
+ expect(screen.getByText('$300')).toBeTruthy()
45
+ expect(screen.getByText('25%')).toBeTruthy()
46
+ })
47
+ it('says so when the plan never clears', () => {
48
+ render(<PayoffSummaryCards schedule={schedule({ paidOff: false, debtFreeMonth: null, debtFreeDate: null })} />)
49
+ expect(screen.getByText('Not within forecast')).toBeTruthy()
50
+ })
51
+ })
52
+
53
+ describe('PayoffScheduleTable', () => {
54
+ it('pages by month and names debts', () => {
55
+ render(<PayoffScheduleTable schedule={schedule({}, 5)} debts={[{ id: 'a', name: 'Visa' }, { id: 'b', name: 'Auto' }]} pageSize={2} />)
56
+ expect(screen.getAllByText('Visa')).toHaveLength(2)
57
+ expect(screen.getByText('Page 1 of 3')).toBeTruthy()
58
+ fireEvent.click(screen.getByText('Next'))
59
+ expect(screen.getByText('Page 2 of 3')).toBeTruthy()
60
+ })
61
+ })
62
+
63
+ describe('ScenarioCompare', () => {
64
+ it('reports savings against the first scenario', () => {
65
+ render(
66
+ <ScenarioCompare
67
+ scenarios={[
68
+ { id: 'base', label: 'Base', schedule: schedule({ debtFreeMonth: 24, totalInterest: 1000 }) },
69
+ { id: 'fast', label: 'Fast', schedule: schedule({ debtFreeMonth: 12, totalInterest: 400 }) },
70
+ ]}
71
+ />,
72
+ )
73
+ expect(screen.getByText('1 year less')).toBeTruthy()
74
+ expect(screen.getByText('$600 less')).toBeTruthy()
75
+ })
76
+ })
77
+
78
+ describe('RangeSlider', () => {
79
+ it('reports changes as numbers', () => {
80
+ const onChange = jest.fn()
81
+ render(<RangeSlider label="Payment" min={0} max={100} value={10} onChange={onChange} format={(v) => `$${v}`} />)
82
+ expect(screen.getByText('$10')).toBeTruthy()
83
+ fireEvent.change(screen.getByLabelText('Payment'), { target: { value: '40' } })
84
+ expect(onChange).toHaveBeenCalledWith(40)
85
+ })
86
+ })
@@ -0,0 +1,71 @@
1
+ /** One spend (or income) record, segmented by a generic tag. */
2
+ export interface CategoryItem {
3
+ /** ISO date (YYYY-MM-DD or full timestamp). */
4
+ date: string
5
+ amount: number
6
+ /** Generic tag/category; empty or missing groups under "Uncategorized". */
7
+ tag?: string | null
8
+ }
9
+
10
+ export const UNCATEGORIZED = 'Uncategorized'
11
+ export const OTHER = 'Other'
12
+
13
+ const tagOf = (i: CategoryItem) => (i.tag && i.tag.trim() ? i.tag.trim() : UNCATEGORIZED)
14
+
15
+ /** Totals per tag, largest first; everything past `topN` rolls up into "Other". */
16
+ export function totalsByTag(items: CategoryItem[], topN = 6): { tag: string; total: number }[] {
17
+ const sums = new Map<string, number>()
18
+ for (const i of items) {
19
+ if (!Number.isFinite(i.amount)) continue
20
+ sums.set(tagOf(i), (sums.get(tagOf(i)) ?? 0) + i.amount)
21
+ }
22
+ const sorted = [...sums.entries()].map(([tag, total]) => ({ tag, total })).sort((a, b) => b.total - a.total)
23
+ if (sorted.length <= topN) return sorted
24
+ const head = sorted.slice(0, topN)
25
+ const rest = sorted.slice(topN).reduce((s, r) => s + r.total, 0)
26
+ return [...head, { tag: OTHER, total: rest }]
27
+ }
28
+
29
+ /** "YYYY-MM" bucket of an ISO date, or null if unreadable. */
30
+ export function monthKey(date: string): string | null {
31
+ const m = /^(\d{4})-(\d{2})/.exec(date)
32
+ return m ? `${m[1]}-${m[2]}` : null
33
+ }
34
+
35
+ /**
36
+ * Monthly totals for each of the top tags (same top-N/"Other" rule as
37
+ * `totalsByTag`). Months with no spend for a tag are 0, not gaps, so lines
38
+ * stay continuous across the whole range.
39
+ */
40
+ export function monthlyByTag(
41
+ items: CategoryItem[],
42
+ topN = 6,
43
+ ): { tags: string[]; months: string[]; values: Record<string, Record<string, number>> } {
44
+ const keep = new Set(totalsByTag(items, topN).map((t) => t.tag))
45
+ const named = new Set([...keep].filter((t) => t !== OTHER))
46
+ const values: Record<string, Record<string, number>> = {}
47
+ const monthSet = new Set<string>()
48
+ for (const i of items) {
49
+ const mk = monthKey(i.date)
50
+ if (!mk || !Number.isFinite(i.amount)) continue
51
+ const t = named.has(tagOf(i)) ? tagOf(i) : OTHER
52
+ monthSet.add(mk)
53
+ values[t] ??= {}
54
+ values[t][mk] = (values[t][mk] ?? 0) + i.amount
55
+ }
56
+ const months = [...monthSet].sort()
57
+ // Fill every month between first and last.
58
+ const filled: string[] = []
59
+ if (months.length) {
60
+ let [y, m] = months[0].split('-').map(Number)
61
+ const [ey, em] = months[months.length - 1].split('-').map(Number)
62
+ while (y < ey || (y === ey && m <= em)) {
63
+ filled.push(`${y}-${String(m).padStart(2, '0')}`)
64
+ m++
65
+ if (m > 12) { m = 1; y++ }
66
+ }
67
+ }
68
+ const tags = [...keep].filter((t) => values[t])
69
+ for (const t of tags) for (const mk of filled) values[t][mk] ??= 0
70
+ return { tags, months: filled, values }
71
+ }
@@ -0,0 +1,23 @@
1
+ const usd = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' })
2
+ const usd0 = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 })
3
+
4
+ export const money = (n: number) => usd.format(n)
5
+ export const moneyWhole = (n: number) => usd0.format(n)
6
+
7
+ /** "Mar 2028" from an ISO date, in UTC so the day never shifts. */
8
+ export function monthYear(iso: string): string {
9
+ const d = new Date(`${iso.slice(0, 10)}T00:00:00Z`)
10
+ return Number.isNaN(d.getTime())
11
+ ? iso
12
+ : d.toLocaleDateString('en-US', { month: 'short', year: 'numeric', timeZone: 'UTC' })
13
+ }
14
+
15
+ /** "3 years 2 months" / "5 months". */
16
+ export function duration(months: number): string {
17
+ const y = Math.floor(months / 12)
18
+ const m = months % 12
19
+ const parts: string[] = []
20
+ if (y) parts.push(`${y} ${y === 1 ? 'year' : 'years'}`)
21
+ if (m || !y) parts.push(`${m} ${m === 1 ? 'month' : 'months'}`)
22
+ return parts.join(' ')
23
+ }
@@ -0,0 +1,26 @@
1
+ // Payoff UI lives behind its own subpath (`@startsimpli/ui/payoff`) because it pulls in recharts.
2
+ export { PayoffBalanceChart } from './PayoffBalanceChart'
3
+ export type { PayoffBalanceChartProps } from './PayoffBalanceChart'
4
+ export { PayoffSummaryCards } from './PayoffSummaryCards'
5
+ export type { PayoffSummaryCardsProps } from './PayoffSummaryCards'
6
+ export { PayoffScheduleTable } from './PayoffScheduleTable'
7
+ export type { PayoffScheduleTableProps } from './PayoffScheduleTable'
8
+ export { ScenarioCompare } from './ScenarioCompare'
9
+ export type { ScenarioCompareProps, PayoffScenario } from './ScenarioCompare'
10
+ export { RangeSlider } from './RangeSlider'
11
+ export type { RangeSliderProps } from './RangeSlider'
12
+ export { money, moneyWhole, monthYear, duration } from './format'
13
+ export type {
14
+ PayoffDebtLabel,
15
+ PayoffMonthLike,
16
+ PayoffPaymentLike,
17
+ PayoffScheduleLike,
18
+ } from './types'
19
+ export { CategoryBreakdown } from './CategoryBreakdown'
20
+ export type { CategoryBreakdownProps } from './CategoryBreakdown'
21
+ export { CategoryTrend } from './CategoryTrend'
22
+ export type { CategoryTrendProps } from './CategoryTrend'
23
+ export { PayoffTimeline, payoffMilestones } from './PayoffTimeline'
24
+ export type { PayoffTimelineProps } from './PayoffTimeline'
25
+ export { totalsByTag, monthlyByTag, monthKey, UNCATEGORIZED, OTHER } from './category'
26
+ export type { CategoryItem } from './category'
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Structural shapes the payoff components read. They match what
3
+ * `buildPayoffSchedule` (@startsimpli/utils) returns, but are declared here so
4
+ * @startsimpli/ui does not depend on @startsimpli/utils.
5
+ */
6
+ export interface PayoffPaymentLike {
7
+ debtId: string
8
+ month: number
9
+ date?: string
10
+ startBalance: number
11
+ interest: number
12
+ payment: number
13
+ endBalance: number
14
+ }
15
+
16
+ export interface PayoffMonthLike {
17
+ month: number
18
+ date?: string
19
+ payments: PayoffPaymentLike[]
20
+ totalPayment: number
21
+ totalInterest: number
22
+ totalBalance: number
23
+ }
24
+
25
+ export interface PayoffScheduleLike {
26
+ months: PayoffMonthLike[]
27
+ debtFreeMonth: number | null
28
+ debtFreeDate: string | null
29
+ totalInterest: number
30
+ totalPaid: number
31
+ paidOff: boolean
32
+ }
33
+
34
+ export interface PayoffDebtLabel {
35
+ id: string
36
+ name: string
37
+ }