diff --git a/.github/scripts/update-changelog.js b/.github/scripts/update-changelog.js
index dc16a68..0b54d20 100644
--- a/.github/scripts/update-changelog.js
+++ b/.github/scripts/update-changelog.js
@@ -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];
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index e0e4ac8..853b13c 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -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
diff --git a/CHANGELOG.md b/CHANGELOG.md
index fbc1557..195cb54 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
@@ -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
diff --git a/package-lock.json b/package-lock.json
index e30a23c..50b5045 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "platito",
- "version": "1.3.3",
+ "version": "1.10.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "platito",
- "version": "1.3.3",
+ "version": "1.10.0",
"dependencies": {
"@fontsource-variable/manrope": "^5.2.8",
"@mantine/carousel": "^8.3.9",
diff --git a/package.json b/package.json
index 97d76c0..857507c 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "platito",
"private": true,
- "version": "1.3.3",
+ "version": "1.10.0",
"type": "module",
"scripts": {
"dev": "vite",
diff --git a/src/components/EssentialBaselineWidget.tsx b/src/components/EssentialBaselineWidget.tsx
new file mode 100644
index 0000000..5a6150d
--- /dev/null
+++ b/src/components/EssentialBaselineWidget.tsx
@@ -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;
+}) => (
+
+ {label}
+
+ {Icon && }
+ {value}
+
+
+);
+
+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 (
+
+
+
+
+
+
+
+
+ Essential Baseline
+ Monthly floor spend
+
+
+ onPeriodChange(value as BaselinePeriod)}
+ data={PERIOD_OPTIONS}
+ />
+
+
+
+
+
+ {formatNumberToMonetary(monthlyAverage)} {currency}
+
+
+
+
+
+
+
+
+
+ Range: {formatNumberToMonetary(min)} – {formatNumberToMonetary(max)} {currency} (σ {formatNumberToMonetary(stdDev)})
+
+
+ {categoryBreakdown.length > 0 && (
+ <>
+
+
+ By category
+ {categoryBreakdown.slice(0, 5).map((item) => (
+
+ {item.categoryName}
+
+
+ {formatNumberToMonetary(item.monthlyAverage)}
+
+
+ ))}
+
+ >
+ )}
+
+ {isLowSample && (
+ Sample size is small ({monthsIncluded} month{monthsIncluded === 1 ? '' : 's'} of data).
+ )}
+
+
+ );
+};
diff --git a/src/components/EssentialOverrideControl.tsx b/src/components/EssentialOverrideControl.tsx
new file mode 100644
index 0000000..65fe72a
--- /dev/null
+++ b/src/components/EssentialOverrideControl.tsx
@@ -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) => (
+
+ Essential classification
+ onChange(v as EssentialOverrideOption)}
+ data={[
+ { label: 'Use category default', value: 'default' },
+ { label: 'Mark as essential', value: 'essential' },
+ { label: 'Mark as discretionary', value: 'discretionary' },
+ ]}
+ />
+
+);
diff --git a/src/components/RunwayWidget.tsx b/src/components/RunwayWidget.tsx
new file mode 100644
index 0000000..cf3bbb7
--- /dev/null
+++ b/src/components/RunwayWidget.tsx
@@ -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 (
+
+
+
+ Now
+
+
+
+
+ Max
+
+
+
+
+ );
+};
+
+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 (
+
+
+
+
+
+
+
+ Runway
+
+ Total balance: {formatNumberToMonetary(totalBalance)} {currency}
+
+
+
+
+
+
+
+
+ Conservative (essential only)
+ {formatMonths(conservativeRunwayMonths)}
+ {formatYears(conservativeRunwayMonths) && (
+ {formatYears(conservativeRunwayMonths)}
+ )}
+
+
+ Current habits (total spend)
+ {formatMonths(currentHabitsRunwayMonths)}
+ {formatYears(currentHabitsRunwayMonths) && (
+ {formatYears(currentHabitsRunwayMonths)}
+ )}
+
+
+
+ {conservativeRunwayMonths !== null && currentHabitsRunwayMonths !== null && (
+
+ )}
+
+
+
+ {gap !== null && (
+
+ Decision range: {gap.toFixed(1)} months (≈ {(gap / 12).toFixed(1)} years) between cutting all discretionary spend and keeping current habits.
+
+ )}
+
+
+ );
+};
diff --git a/src/components/TransactionForm.tsx b/src/components/TransactionForm.tsx
index 079b376..501fd96 100644
--- a/src/components/TransactionForm.tsx
+++ b/src/components/TransactionForm.tsx
@@ -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;
@@ -28,6 +30,7 @@ interface FormState {
tagIds: number[];
description: string;
date: Date | null;
+ essentialOverride: EssentialOverrideOption;
}
interface Props {
@@ -120,6 +123,7 @@ export const TransactionForm = ({
tagIds: [],
description: '',
date: getLastUsedDate(),
+ essentialOverride: 'default',
});
const [error, setError] = useState(null);
@@ -225,6 +229,7 @@ export const TransactionForm = ({
description: form.description.trim(),
date: formatDateToISO(form.date),
tagIds: form.tagIds,
+ essentialOverride: optionToEssentialOverride(form.essentialOverride),
};
try {
@@ -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) {
@@ -383,6 +389,13 @@ export const TransactionForm = ({
maxVisible={7}
/>
+ {form.transactionType === 'expense' && (
+ setForm((prev) => ({ ...prev, essentialOverride: value }))}
+ />
+ )}
+