Skip to content
Merged
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
2 changes: 1 addition & 1 deletion .github/scripts/update-changelog.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// Usage: VERSION=1.9.0 node update-changelog.js
const fs = require('fs');
import fs from 'node:fs';

const version = process.env.VERSION;
const date = new Date().toISOString().split('T')[0];
Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ jobs:
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"

- name: Delete stale remote release branch (if any)
run: git push origin --delete release/$VERSION || true

- name: Create release branch
run: git checkout -b release/$VERSION

Expand Down
10 changes: 9 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [1.10.0] - 2026-07-01

### 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 Expand Up @@ -217,7 +224,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- React Router for navigation
- Recharts for data visualization

[Unreleased]: https://github.com/tomas2305/platito/compare/1.3.1...HEAD
[Unreleased]: https://github.com/tomas2305/platito/compare/1.10.0...HEAD
[1.10.0]: https://github.com/tomas2305/platito/compare/1.9.0...1.10.0
[1.3.1]: https://github.com/tomas2305/platito/compare/1.3.0...1.3.1
[1.3.0]: https://github.com/tomas2305/platito/compare/1.2.0...1.3.0
[1.2.0]: https://github.com/tomas2305/platito/compare/1.1.1...1.2.0
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "platito",
"private": true,
"version": "1.3.3",
"version": "1.10.0",
"type": "module",
"scripts": {
"dev": "vite",
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>
);
};
Loading
Loading