Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added
- Essential baseline widget showing average monthly essential spending (1y, 6m, or all-time), with category breakdown and percent of income
- Category-level `isEssential` flag and per-transaction essential override for classifying spending as essential vs. discretionary
- Runway widget estimating months of financial runway based on conservative and current-habits spending scenarios

## [1.9.0] - 2026-05-26

### Added
Expand Down
142 changes: 142 additions & 0 deletions src/components/EssentialBaselineWidget.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import { Card, Divider, Group, Paper, Progress, SegmentedControl, SimpleGrid, Stack, Text, ThemeIcon } from '@mantine/core';
import { IconHomeShield, IconTrendingUp, IconTrendingDown, IconMinus } from '@tabler/icons-react';
import { formatNumberToMonetary } from '../utils/formatters';
import { getColorHex } from '../utils/colors';
import type { BaselinePeriod, EssentialBaselineMetrics } from '../types';

interface EssentialBaselineWidgetProps {
metrics: EssentialBaselineMetrics;
trend: number | null;
period: BaselinePeriod;
onPeriodChange: (period: BaselinePeriod) => void;
currency: string;
}

const PERIOD_OPTIONS: Array<{ label: string; value: BaselinePeriod }> = [
{ label: '1 Year', value: '1y' },
{ label: '6 Months', value: '6m' },
{ label: 'Ever', value: 'ever' },
];

const getIncomeShareColor = (percentOfIncome: number): string => {
if (percentOfIncome <= 0.5) return 'teal';
if (percentOfIncome <= 0.7) return 'yellow';
return 'red';
};

const getTrendIndicator = (trend: number | null) => {
if (trend === null || Math.abs(trend) < 0.005) {
return { icon: IconMinus, color: 'gray', label: 'No change' };
}
if (trend > 0) {
return { icon: IconTrendingUp, color: 'red', label: `+${(trend * 100).toFixed(1)}%` };
}
return { icon: IconTrendingDown, color: 'teal', label: `${(trend * 100).toFixed(1)}%` };
};

const StatBox = ({
label,
value,
valueColor,
icon: Icon,
}: {
label: string;
value: string;
valueColor?: string;
icon?: typeof IconTrendingUp;
}) => (
<Paper radius="md" p="sm" bg="var(--mantine-color-gray-0)">
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>{label}</Text>
<Group gap={4} wrap="nowrap" align="center">
{Icon && <Icon size={14} color={valueColor ? `var(--mantine-color-${valueColor}-6)` : undefined} />}
<Text size="md" fw={700} c={valueColor} truncate>{value}</Text>
</Group>
</Paper>
);

export const EssentialBaselineWidget = ({ metrics, trend, period, onPeriodChange, currency }: EssentialBaselineWidgetProps) => {
const {
monthlyAverage,
monthlyMedian,
stdDev,
min,
max,
categoryBreakdown,
percentOfIncome,
isLowSample,
monthsIncluded,
} = metrics;

const incomeColor = getIncomeShareColor(percentOfIncome);
const trendInfo = getTrendIndicator(trend);
const TrendIcon = trendInfo.icon;
const maxCategoryAmount = categoryBreakdown[0]?.monthlyAverage || 1;

return (
<Card padding="lg" radius="md" withBorder>
<Stack gap="md">
<Group justify="space-between" align="flex-start">
<Group gap="sm">
<ThemeIcon size={48} radius="md" color={incomeColor} variant="light">
<IconHomeShield size={26} />
</ThemeIcon>
<div>
<Text size="lg" fw={700}>Essential Baseline</Text>
<Text size="sm" c="dimmed">Monthly floor spend</Text>
</div>
</Group>
<SegmentedControl
size="sm"
value={period}
onChange={(value) => onPeriodChange(value as BaselinePeriod)}
data={PERIOD_OPTIONS}
/>
</Group>

<Divider />

<Text size="2.75rem" fw={800} c={incomeColor} lh={1}>
{formatNumberToMonetary(monthlyAverage)} {currency}
</Text>

<SimpleGrid cols={3} spacing="sm">
<StatBox label="Median" value={formatNumberToMonetary(monthlyMedian)} />
<StatBox label="% of income" value={`${(percentOfIncome * 100).toFixed(1)}%`} valueColor={incomeColor} />
<StatBox label="6M vs 1Y" value={trendInfo.label} valueColor={trendInfo.color} icon={TrendIcon} />
</SimpleGrid>

<Text size="sm" c="dimmed">
Range: {formatNumberToMonetary(min)} – {formatNumberToMonetary(max)} {currency} (σ {formatNumberToMonetary(stdDev)})
</Text>

{categoryBreakdown.length > 0 && (
<>
<Divider />
<Stack gap={8}>
<Text size="xs" fw={600} c="dimmed" tt="uppercase">By category</Text>
{categoryBreakdown.slice(0, 5).map((item) => (
<Group key={item.categoryId} gap="sm" wrap="nowrap" align="center">
<Text size="sm" style={{ width: 120, flexShrink: 0 }} truncate>{item.categoryName}</Text>
<Progress
value={(item.monthlyAverage / maxCategoryAmount) * 100}
color={getColorHex(item.categoryColor)}
size="md"
radius="xl"
style={{ flex: 1 }}
/>
<Text size="sm" fw={600} style={{ width: 110, textAlign: 'right', flexShrink: 0 }}>
{formatNumberToMonetary(item.monthlyAverage)}
</Text>
</Group>
))}
</Stack>
</>
)}

{isLowSample && (
<Text size="xs" c="dimmed">Sample size is small ({monthsIncluded} month{monthsIncluded === 1 ? '' : 's'} of data).</Text>
)}
</Stack>
</Card>
);
};
24 changes: 24 additions & 0 deletions src/components/EssentialOverrideControl.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { SegmentedControl, Stack, Text } from '@mantine/core';
import type { EssentialOverrideOption } from '../utils/essentialOverride';

interface Props {
value: EssentialOverrideOption;
onChange: (value: EssentialOverrideOption) => void;
labelWeight?: number;
labelColor?: string;
}

export const EssentialOverrideControl = ({ value, onChange, labelWeight, labelColor = 'dimmed' }: Props) => (
<Stack gap={4}>
<Text size="sm" c={labelWeight ? undefined : labelColor} fw={labelWeight}>Essential classification</Text>
<SegmentedControl
value={value}
onChange={(v) => onChange(v as EssentialOverrideOption)}
data={[
{ label: 'Use category default', value: 'default' },
{ label: 'Mark as essential', value: 'essential' },
{ label: 'Mark as discretionary', value: 'discretionary' },
]}
/>
</Stack>
);
154 changes: 154 additions & 0 deletions src/components/RunwayWidget.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import { Card, Divider, Group, Stack, Text, ThemeIcon } from '@mantine/core';
import { IconLifebuoy } from '@tabler/icons-react';
import { formatNumberToMonetary } from '../utils/formatters';
import type { RunwayMetrics } from '../types';

interface RunwayWidgetProps {
metrics: RunwayMetrics;
currency: string;
}

const getRunwayColor = (months: number | null): string => {
if (months === null) return 'gray';
if (months < 3) return 'red';
if (months < 6) return 'yellow';
return 'teal';
};

const formatMonths = (months: number | null): string => (months === null ? '—' : `${months.toFixed(1)} months`);

const formatYears = (months: number | null): string | null => (months === null ? null : `≈ ${(months / 12).toFixed(1)} years`);

const RunwayRangeBar = ({ nowMonths, maxMonths }: { nowMonths: number; maxMonths: number }) => {
const nowPercent = maxMonths > 0 ? Math.min(100, (nowMonths / maxMonths) * 100) : 0;

return (
<div style={{ position: 'relative', paddingTop: 32, paddingBottom: 4 }}>
<div
style={{
position: 'absolute',
top: 0,
left: `${nowPercent}%`,
transform: 'translateX(-50%)',
whiteSpace: 'nowrap',
}}
>
<Text
size="xs"
fw={600}
c="teal"
style={{
backgroundColor: 'var(--mantine-color-teal-0)',
border: '1px solid var(--mantine-color-teal-3)',
borderRadius: 6,
padding: '2px 8px',
}}
>
Now
</Text>
</div>
<div
style={{
position: 'absolute',
top: 0,
right: 0,
whiteSpace: 'nowrap',
}}
>
<Text
size="xs"
fw={600}
c="dimmed"
style={{
backgroundColor: 'var(--mantine-color-gray-0)',
border: '1px solid var(--mantine-color-gray-3)',
borderRadius: 6,
padding: '2px 8px',
}}
>
Max
</Text>
</div>
<div
style={{
position: 'relative',
height: 10,
borderRadius: 999,
backgroundColor: 'var(--mantine-color-teal-1)',
overflow: 'hidden',
}}
>
<div
style={{
position: 'absolute',
top: 0,
left: 0,
height: '100%',
width: `${nowPercent}%`,
backgroundColor: 'var(--mantine-color-teal-6)',
borderRadius: 999,
}}
/>
</div>
</div>
);
};

export const RunwayWidget = ({ metrics, currency }: RunwayWidgetProps) => {
const { totalBalance, conservativeRunwayMonths, currentHabitsRunwayMonths } = metrics;
const conservativeColor = getRunwayColor(conservativeRunwayMonths);
const currentHabitsColor = getRunwayColor(currentHabitsRunwayMonths);

const gap = conservativeRunwayMonths !== null && currentHabitsRunwayMonths !== null
? conservativeRunwayMonths - currentHabitsRunwayMonths
: null;

return (
<Card padding="lg" radius="md" withBorder>
<Stack gap="md">
<Group gap="sm">
<ThemeIcon size={48} radius="md" color={conservativeColor} variant="light">
<IconLifebuoy size={26} />
</ThemeIcon>
<div>
<Text size="lg" fw={700}>Runway</Text>
<Text size="sm" c="dimmed">
Total balance: {formatNumberToMonetary(totalBalance)} {currency}
</Text>
</div>
</Group>

<Divider />

<Group justify="space-between">
<div>
<Text size="sm" c="dimmed">Conservative (essential only)</Text>
<Text size="xl" fw={700} c={conservativeColor}>{formatMonths(conservativeRunwayMonths)}</Text>
{formatYears(conservativeRunwayMonths) && (
<Text size="xs" c="dimmed">{formatYears(conservativeRunwayMonths)}</Text>
)}
</div>
<div style={{ textAlign: 'right' }}>
<Text size="sm" c="dimmed">Current habits (total spend)</Text>
<Text size="xl" fw={700} c={currentHabitsColor}>{formatMonths(currentHabitsRunwayMonths)}</Text>
{formatYears(currentHabitsRunwayMonths) && (
<Text size="xs" c="dimmed">{formatYears(currentHabitsRunwayMonths)}</Text>
)}
</div>
</Group>

{conservativeRunwayMonths !== null && currentHabitsRunwayMonths !== null && (
<RunwayRangeBar nowMonths={currentHabitsRunwayMonths} maxMonths={conservativeRunwayMonths} />
)}

<Divider />

{gap !== null && (
<Text size="sm" c="dimmed">
Decision range: <Text component="span" fw={700} c="dark">{gap.toFixed(1)} months (≈ {(gap / 12).toFixed(1)} years)</Text> between cutting all discretionary spend and keeping current habits.
</Text>
)}
</Stack>
</Card>
);
};
13 changes: 13 additions & 0 deletions src/components/TransactionForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,10 @@ import { IconChevronLeft, IconChevronRight } from '@tabler/icons-react';
import { notifications } from '@mantine/notifications';
import { CategoryIcon } from './CategoryIcon';
import { CircularSelector } from './CircularSelector';
import { EssentialOverrideControl } from './EssentialOverrideControl';
import type { Account, Category, Tag, Transaction, TransactionType } from '../types';
import { formatMonetaryValue, parseMonetaryValue } from '../utils/formatters';
import { optionToEssentialOverride, type EssentialOverrideOption } from '../utils/essentialOverride';

interface FormState {
amount: string;
Expand All @@ -28,6 +30,7 @@ interface FormState {
tagIds: number[];
description: string;
date: Date | null;
essentialOverride: EssentialOverrideOption;
}

interface Props {
Expand Down Expand Up @@ -120,6 +123,7 @@ export const TransactionForm = ({
tagIds: [],
description: '',
date: getLastUsedDate(),
essentialOverride: 'default',
});

const [error, setError] = useState<string | null>(null);
Expand Down Expand Up @@ -225,6 +229,7 @@ export const TransactionForm = ({
description: form.description.trim(),
date: formatDateToISO(form.date),
tagIds: form.tagIds,
essentialOverride: optionToEssentialOverride(form.essentialOverride),
};

try {
Expand Down Expand Up @@ -263,6 +268,7 @@ export const TransactionForm = ({
transactionType: form.transactionType,
tagIds: form.tagIds,
description: '',
essentialOverride: 'default',
date: form.date, // Keep the same date
});
} catch (err) {
Expand Down Expand Up @@ -383,6 +389,13 @@ export const TransactionForm = ({
maxVisible={7}
/>

{form.transactionType === 'expense' && (
<EssentialOverrideControl
value={form.essentialOverride}
onChange={(value) => setForm((prev) => ({ ...prev, essentialOverride: value }))}
/>
)}

<Textarea
label="Description"
placeholder="Optional description"
Expand Down
Loading
Loading