@startsimpli/ui 0.4.170 → 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 +5 -4
- package/src/components/payoff/CategoryBreakdown.tsx +38 -0
- package/src/components/payoff/CategoryTrend.tsx +23 -0
- package/src/components/payoff/PayoffBalanceChart.tsx +42 -0
- package/src/components/payoff/PayoffScheduleTable.tsx +72 -0
- package/src/components/payoff/PayoffSummaryCards.tsx +54 -0
- package/src/components/payoff/PayoffTimeline.tsx +66 -0
- package/src/components/payoff/RangeSlider.tsx +41 -0
- package/src/components/payoff/ScenarioCompare.tsx +66 -0
- package/src/components/payoff/__tests__/category.test.tsx +51 -0
- package/src/components/payoff/__tests__/payoff.test.tsx +86 -0
- package/src/components/payoff/category.ts +71 -0
- package/src/components/payoff/format.ts +23 -0
- package/src/components/payoff/index.ts +26 -0
- package/src/components/payoff/types.ts +37 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@startsimpli/ui",
|
|
3
|
-
"version": "0.4.
|
|
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",
|
|
70
|
-
"@startsimpli/
|
|
71
|
-
"@startsimpli/
|
|
72
|
-
"@startsimpli/
|
|
71
|
+
"@startsimpli/api": "^0.5.69",
|
|
72
|
+
"@startsimpli/auth": "^0.4.50",
|
|
73
|
+
"@startsimpli/i18n": "^0.1.3"
|
|
73
74
|
},
|
|
74
75
|
"peerDependenciesMeta": {
|
|
75
76
|
"@startsimpli/api": {
|
|
@@ -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
|
+
}
|