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 }))} + /> + )} +