feat: idle cash alert and category hierarchy (subcategories)

Two of the four ideas picked from the Maybe/Sure feature audit:

- Idle cash: a 5th proactive alert flags a depository account (checking/
  savings/cash) sitting at or above a configurable balance with no
  transaction touching it for a configurable number of days.
- Category hierarchy: categories can now have one level of subcategories
  (Groceries -> Alcohol). Dashboard/report aggregations roll a
  subcategory's spending into its parent's slice.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
ZProger
2026-08-25 02:27:46 +03:00
parent 79e4ebb06b
commit 09a0e383e0
23 changed files with 628 additions and 81 deletions

View File

@@ -1,7 +1,7 @@
{
"name": "aurum-frontend",
"private": true,
"version": "0.23.2",
"version": "0.24.0",
"type": "module",
"scripts": {
"dev": "vite",

View File

@@ -4,7 +4,8 @@ import { Button } from "@/components/ui/Button";
import { Input, Label, Select } from "@/components/ui/Input";
import { CategoryColorPicker } from "@/components/categories/CategoryColorPicker";
import { CategoryIconPicker } from "@/components/categories/CategoryIconPicker";
import { useCreateCategory, useUpdateCategory } from "@/hooks/useCategories";
import { useCategories, useCreateCategory, useUpdateCategory } from "@/hooks/useCategories";
import { translateCategoryName } from "@/lib/categoryLabels";
import { useTranslation, type TranslationKey } from "@/lib/i18n";
import type { Category, CategoryKind } from "@/types";
@@ -20,11 +21,12 @@ interface CategoryFormModalProps {
const KINDS: CategoryKind[] = ["expense", "income"];
function emptyForm(kind: CategoryKind) {
return { name: "", kind, icon: "wallet", color: "#2a78d6" };
return { name: "", kind, icon: "wallet", color: "#2a78d6", parent_id: "" };
}
export function CategoryFormModal({ open, onClose, category, defaultKind }: CategoryFormModalProps) {
const { t } = useTranslation();
const { data: allCategories } = useCategories();
const createCategory = useCreateCategory();
const updateCategory = useUpdateCategory();
@@ -35,26 +37,41 @@ export function CategoryFormModal({ open, onClose, category, defaultKind }: Cate
if (!open) return;
setForm(
category
? { name: category.name, kind: category.kind, icon: category.icon ?? "wallet", color: category.color }
? {
name: category.name,
kind: category.kind,
icon: category.icon ?? "wallet",
color: category.color,
parent_id: category.parent_id ? String(category.parent_id) : "",
}
: emptyForm(defaultKind)
);
setError(null);
}, [open, category, defaultKind]);
// A category that already has subcategories can't become one itself
// (backend rejects it) — the parent picker is disabled in that case.
const hasChildren = category ? (allCategories ?? []).some((c) => c.parent_id === category.id) : false;
const parentCandidates = (allCategories ?? []).filter(
(c) => c.kind === form.kind && c.parent_id === null && c.id !== category?.id
);
const isSaving = createCategory.isPending || updateCategory.isPending;
async function handleSubmit(event: React.FormEvent) {
event.preventDefault();
setError(null);
const parent_id = form.parent_id ? Number(form.parent_id) : null;
try {
if (category) {
await updateCategory.mutateAsync({
id: category.id,
input: { name: form.name, icon: form.icon, color: form.color },
input: { name: form.name, icon: form.icon, color: form.color, parent_id },
});
} else {
await createCategory.mutateAsync(form);
await createCategory.mutateAsync({ name: form.name, kind: form.kind, icon: form.icon, color: form.color, parent_id });
}
onClose();
} catch {
@@ -82,7 +99,9 @@ export function CategoryFormModal({ open, onClose, category, defaultKind }: Cate
id="category-kind"
value={form.kind}
disabled={Boolean(category)}
onChange={(event) => setForm((prev) => ({ ...prev, kind: event.target.value as CategoryKind }))}
onChange={(event) =>
setForm((prev) => ({ ...prev, kind: event.target.value as CategoryKind, parent_id: "" }))
}
>
{KINDS.map((kind) => (
<option key={kind} value={kind}>
@@ -92,6 +111,24 @@ export function CategoryFormModal({ open, onClose, category, defaultKind }: Cate
</Select>
</div>
<div>
<Label htmlFor="category-parent">{t("category.form.parentLabel")}</Label>
<Select
id="category-parent"
value={hasChildren ? "" : form.parent_id}
disabled={hasChildren}
onChange={(event) => setForm((prev) => ({ ...prev, parent_id: event.target.value }))}
>
<option value="">{t("category.form.noParent")}</option>
{parentCandidates.map((parent) => (
<option key={parent.id} value={parent.id}>
{translateCategoryName(parent.name)}
</option>
))}
</Select>
{hasChildren && <p className="mt-1 text-xs text-text-muted">{t("category.form.hasChildrenHint")}</p>}
</div>
<div>
<Label>{t("category.form.iconLabel")}</Label>
<CategoryIconPicker value={form.icon} onChange={(icon) => setForm((prev) => ({ ...prev, icon }))} />

View File

@@ -10,6 +10,57 @@ interface CategoryListProps {
onDelete: (category: Category) => void;
}
interface RowProps {
category: Category;
indented: boolean;
onEdit: (category: Category) => void;
onDelete: (category: Category) => void;
}
function CategoryRow({ category, indented, onEdit, onDelete }: RowProps) {
const { t } = useTranslation();
const Icon = getCategoryIcon(category.icon);
return (
<li className={`flex items-center gap-3 py-2.5 ${indented ? "pl-8" : ""}`}>
<span
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full"
style={{ backgroundColor: `${category.color}1a`, color: category.color }}
>
<Icon size={15} />
</span>
<span className="flex min-w-0 flex-1 items-center gap-1.5 truncate text-sm font-medium text-text-primary">
<span className="truncate">{translateCategoryName(category.name)}</span>
{category.is_default && (
<span className="shrink-0 rounded bg-surface-2 px-1 py-0.5 text-[10px] leading-none text-text-muted">
{t("category.defaultBadge")}
</span>
)}
</span>
<span className="flex shrink-0 gap-1">
<button
type="button"
aria-label={t("common.edit")}
onClick={() => onEdit(category)}
className="rounded-md p-1.5 text-text-muted hover:bg-surface-2 hover:text-text-primary"
>
<Pencil size={15} />
</button>
<button
type="button"
aria-label={t("common.delete")}
title={category.is_default ? t("category.defaultCannotDelete") : undefined}
disabled={category.is_default}
onClick={() => onDelete(category)}
className="rounded-md p-1.5 text-text-muted hover:bg-surface-2 hover:text-danger disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent disabled:hover:text-text-muted"
>
<Trash2 size={15} />
</button>
</span>
</li>
);
}
export function CategoryList({ items, onEdit, onDelete }: CategoryListProps) {
const { t } = useTranslation();
@@ -17,50 +68,31 @@ export function CategoryList({ items, onEdit, onDelete }: CategoryListProps) {
return <p className="py-6 text-center text-sm text-text-muted">{t("category.empty")}</p>;
}
// `items` arrives pre-sorted (alphabetically, by CategoriesPage) — walk
// top-level categories in that order and splice each one's children in
// directly beneath it, so a subcategory renders indented under its parent
// while everything stays a single flat, evenly-divided list.
const topLevel = items.filter((category) => category.parent_id === null);
const topLevelIds = new Set(topLevel.map((category) => category.id));
// Defensive only — a child whose parent got filtered out (e.g. a kind
// mismatch) should never happen given the create/update validation, but
// falls back to a top-level row instead of silently disappearing.
const orphaned = items.filter((category) => category.parent_id !== null && !topLevelIds.has(category.parent_id));
const rows: { category: Category; indented: boolean }[] = [];
for (const parent of topLevel) {
rows.push({ category: parent, indented: false });
for (const child of items) {
if (child.parent_id === parent.id) rows.push({ category: child, indented: true });
}
}
for (const category of orphaned) rows.push({ category, indented: false });
return (
<ul className="divide-y divide-gridline">
{items.map((category) => {
const Icon = getCategoryIcon(category.icon);
return (
<li key={category.id} className="flex items-center gap-3 py-2.5">
<span
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full"
style={{ backgroundColor: `${category.color}1a`, color: category.color }}
>
<Icon size={15} />
</span>
<span className="flex min-w-0 flex-1 items-center gap-1.5 truncate text-sm font-medium text-text-primary">
<span className="truncate">{translateCategoryName(category.name)}</span>
{category.is_default && (
<span className="shrink-0 rounded bg-surface-2 px-1 py-0.5 text-[10px] leading-none text-text-muted">
{t("category.defaultBadge")}
</span>
)}
</span>
<span className="flex shrink-0 gap-1">
<button
type="button"
aria-label={t("common.edit")}
onClick={() => onEdit(category)}
className="rounded-md p-1.5 text-text-muted hover:bg-surface-2 hover:text-text-primary"
>
<Pencil size={15} />
</button>
<button
type="button"
aria-label={t("common.delete")}
title={category.is_default ? t("category.defaultCannotDelete") : undefined}
disabled={category.is_default}
onClick={() => onDelete(category)}
className="rounded-md p-1.5 text-text-muted hover:bg-surface-2 hover:text-danger disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent disabled:hover:text-text-muted"
>
<Trash2 size={15} />
</button>
</span>
</li>
);
})}
{rows.map(({ category, indented }) => (
<CategoryRow key={category.id} category={category} indented={indented} onEdit={onEdit} onDelete={onDelete} />
))}
</ul>
);
}

View File

@@ -9,6 +9,7 @@ const ALERT_MESSAGE_KEYS: Record<string, TranslationKey> = {
net_worth_decline_streak: "insights.netWorthDecline",
budget_exceeded: "insights.budgetExceeded",
risky_allocation_exceeded: "insights.riskyAllocationExceeded",
idle_cash: "insights.idleCash",
};
function alertMessage(alert: FinancialAlert, t: ReturnType<typeof useTranslation>["t"]): string {
@@ -18,6 +19,9 @@ function alertMessage(alert: FinancialAlert, t: ReturnType<typeof useTranslation
if (alert.key === "budget_exceeded" && alert.params.count === 1) {
return t("insights.budgetExceededOne");
}
if (alert.key === "idle_cash" && alert.params.count === 1) {
return t("insights.idleCashOne", alert.params);
}
const messageKey = ALERT_MESSAGE_KEYS[alert.key];
if (!messageKey) return alert.key;
return t(messageKey, alert.params);

View File

@@ -8,6 +8,9 @@ const MIN_MONTHS = 1;
const MAX_MONTHS = 24;
const MIN_PERCENT = 1;
const MAX_PERCENT = 100;
const MIN_AMOUNT = 0;
const MIN_DAYS = 1;
const MAX_DAYS = 365;
function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
@@ -21,12 +24,16 @@ export function AlertThresholdsCard() {
const [cashFlowMonths, setCashFlowMonths] = useState("");
const [netWorthMonths, setNetWorthMonths] = useState("");
const [riskyPercent, setRiskyPercent] = useState("");
const [idleCashAmount, setIdleCashAmount] = useState("");
const [idleCashDays, setIdleCashDays] = useState("");
useEffect(() => {
if (!settings) return;
setCashFlowMonths(String(settings.negative_cash_flow_threshold_months));
setNetWorthMonths(String(settings.net_worth_decline_threshold_months));
setRiskyPercent(String(settings.risky_allocation_threshold_percent));
setIdleCashAmount(settings.idle_cash_threshold_amount);
setIdleCashDays(String(settings.idle_cash_threshold_days));
}, [settings]);
function commitCashFlowMonths() {
@@ -53,13 +60,29 @@ export function AlertThresholdsCard() {
}
}
function commitIdleCashAmount() {
const value = Math.max(MIN_AMOUNT, Number(idleCashAmount) || MIN_AMOUNT).toFixed(2);
setIdleCashAmount(value);
if (settings && value !== settings.idle_cash_threshold_amount) {
updateSettings.mutate({ idle_cash_threshold_amount: value });
}
}
function commitIdleCashDays() {
const value = clamp(Number(idleCashDays) || MIN_DAYS, MIN_DAYS, MAX_DAYS);
setIdleCashDays(String(value));
if (settings && value !== settings.idle_cash_threshold_days) {
updateSettings.mutate({ idle_cash_threshold_days: value });
}
}
return (
<Card>
<CardHeader>
<CardTitle>{t("settings.alertsTitle")}</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
<div>
<Label htmlFor="cash-flow-threshold">{t("settings.negativeCashFlowThresholdLabel")}</Label>
<Input
@@ -99,6 +122,31 @@ export function AlertThresholdsCard() {
onBlur={commitRiskyPercent}
/>
</div>
<div>
<Label htmlFor="idle-cash-amount-threshold">{t("settings.idleCashThresholdAmountLabel")}</Label>
<Input
id="idle-cash-amount-threshold"
type="number"
min={MIN_AMOUNT}
step={0.01}
value={idleCashAmount}
onChange={(event) => setIdleCashAmount(event.target.value)}
onBlur={commitIdleCashAmount}
/>
</div>
<div>
<Label htmlFor="idle-cash-days-threshold">{t("settings.idleCashThresholdDaysLabel")}</Label>
<Input
id="idle-cash-days-threshold"
type="number"
min={MIN_DAYS}
max={MAX_DAYS}
step={1}
value={idleCashDays}
onChange={(event) => setIdleCashDays(event.target.value)}
onBlur={commitIdleCashDays}
/>
</div>
</div>
<p className="text-xs text-text-muted">{t("settings.alertsHint")}</p>
</CardContent>

View File

@@ -7,7 +7,7 @@ import { useCategories } from "@/hooks/useCategories";
import { useCreateTransaction, useUpdateTransaction } from "@/hooks/useTransactions";
import { useTranslation } from "@/lib/i18n";
import { translateCategoryName } from "@/lib/categoryLabels";
import type { Transaction, TransactionInput, TransactionType } from "@/types";
import type { Category, Transaction, TransactionInput, TransactionType } from "@/types";
interface TransactionFormModalProps {
open: boolean;
@@ -32,7 +32,7 @@ const EMPTY_FORM = {
};
export function TransactionFormModal({ open, onClose, transaction }: TransactionFormModalProps) {
const { t } = useTranslation();
const { t, language } = useTranslation();
const { data: accounts } = useAccounts();
const { data: categories } = useCategories();
const createTransaction = useCreateTransaction();
@@ -61,9 +61,19 @@ export function TransactionFormModal({ open, onClose, transaction }: Transaction
setError(null);
}, [open, transaction, accounts]);
const relevantCategories = (categories ?? []).filter((category) =>
form.type === "income" ? category.kind === "income" : category.kind === "expense"
);
// Subcategories are listed right under their parent (not scattered by
// sort_order) so the hierarchy set up on the Categories page reads the
// same way here.
const kindCategories = (categories ?? [])
.filter((category) => (form.type === "income" ? category.kind === "income" : category.kind === "expense"))
.sort((a, b) => translateCategoryName(a.name).localeCompare(translateCategoryName(b.name), language));
const relevantCategories: (Category & { indented?: boolean })[] = [];
for (const parent of kindCategories.filter((category) => category.parent_id === null)) {
relevantCategories.push(parent);
for (const child of kindCategories.filter((category) => category.parent_id === parent.id)) {
relevantCategories.push({ ...child, indented: true });
}
}
const isSaving = createTransaction.isPending || updateTransaction.isPending;
@@ -217,6 +227,7 @@ export function TransactionFormModal({ open, onClose, transaction }: Transaction
<option value="">{t("transactions.form.noCategory")}</option>
{relevantCategories.map((category) => (
<option key={category.id} value={category.id}>
{category.indented ? `    ↳ ` : ""}
{translateCategoryName(category.name)}
</option>
))}

View File

@@ -203,6 +203,8 @@ const ru = {
"settings.negativeCashFlowThresholdLabel": "Расходы превышают доходы, мес. подряд",
"settings.netWorthDeclineThresholdLabel": "Капитал снижается, мес. подряд",
"settings.riskyAllocationThresholdLabel": "Допустимая доля рискового капитала, %",
"settings.idleCashThresholdAmountLabel": "Простаивающий кэш — сумма от",
"settings.idleCashThresholdDaysLabel": "Простаивающий кэш — без движения, дней",
"backup.title": "Резервное копирование",
"backup.description":
@@ -226,6 +228,8 @@ const ru = {
"insights.budgetExceededOne": "Превышен бюджет в 1 категории в этом месяце",
"insights.budgetExceeded": "Превышен бюджет в {{count}} категориях в этом месяце",
"insights.riskyAllocationExceeded": "Рискованная часть капитала — {{percent}}%, превышает установленный лимит {{threshold}}%",
"insights.idleCashOne": "На 1 счёте лежит крупная сумма без движения {{days}}+ дней — возможно, эти деньги стоит куда-то вложить",
"insights.idleCash": "На {{count}} счетах лежит крупная сумма без движения {{days}}+ дней — возможно, эти деньги стоит куда-то вложить",
"budget.empty": "Бюджетов пока нет. Задайте лимит для категории расходов, чтобы отслеживать её здесь.",
"budget.remaining": "{{amount}} осталось",
@@ -314,6 +318,9 @@ const ru = {
"category.form.nameLabel": "Название",
"category.form.namePlaceholder": "Продукты, транспорт, подписки…",
"category.form.kindLabel": "Тип",
"category.form.parentLabel": "Родительская категория",
"category.form.noParent": "— без родителя (верхний уровень) —",
"category.form.hasChildrenHint": "У категории уже есть подкатегории — она не может сама стать подкатегорией.",
"category.form.iconLabel": "Иконка",
"category.form.colorLabel": "Цвет",
"category.form.customColorLabel": "Свой цвет",
@@ -559,6 +566,8 @@ const en: Record<keyof typeof ru, string> = {
"settings.negativeCashFlowThresholdLabel": "Expenses exceed income, consecutive months",
"settings.netWorthDeclineThresholdLabel": "Net worth declining, consecutive months",
"settings.riskyAllocationThresholdLabel": "Max % of capital allowed at risk",
"settings.idleCashThresholdAmountLabel": "Idle cash — balance at or above",
"settings.idleCashThresholdDaysLabel": "Idle cash — no activity for, days",
"backup.title": "Backup & Restore",
"backup.description":
@@ -582,6 +591,8 @@ const en: Record<keyof typeof ru, string> = {
"insights.budgetExceededOne": "Budget exceeded in 1 category this month",
"insights.budgetExceeded": "Budget exceeded in {{count}} categories this month",
"insights.riskyAllocationExceeded": "Risky share of capital is {{percent}}%, above your {{threshold}}% limit",
"insights.idleCashOne": "1 account is holding a large balance with no activity for {{days}}+ days — that money might be worth putting to work",
"insights.idleCash": "{{count}} accounts are holding a large balance with no activity for {{days}}+ days — that money might be worth putting to work",
"budget.empty": "No budgets yet. Set a limit for an expense category to start tracking it here.",
"budget.remaining": "{{amount}} left",
@@ -670,6 +681,9 @@ const en: Record<keyof typeof ru, string> = {
"category.form.nameLabel": "Name",
"category.form.namePlaceholder": "Groceries, transport, subscriptions…",
"category.form.kindLabel": "Type",
"category.form.parentLabel": "Parent category",
"category.form.noParent": "— no parent (top level) —",
"category.form.hasChildrenHint": "This category already has subcategories — it can't become a subcategory itself.",
"category.form.iconLabel": "Icon",
"category.form.colorLabel": "Color",
"category.form.customColorLabel": "Custom color",

View File

@@ -34,6 +34,9 @@ export interface Category {
color: string;
sort_order: number;
is_default: boolean;
// Subcategory parent, one level deep only (a category whose own parent_id
// is set can't itself have children — enforced backend-side).
parent_id: number | null;
}
export interface CategoryInput {
@@ -42,6 +45,7 @@ export interface CategoryInput {
icon?: string | null;
color: string;
sort_order?: number;
parent_id?: number | null;
}
// kind is fixed at creation on the backend (CategoryUpdate has no kind field).
@@ -50,6 +54,7 @@ export interface CategoryUpdateInput {
icon?: string | null;
color?: string;
sort_order?: number;
parent_id?: number | null;
}
export interface Transaction {
@@ -365,5 +370,7 @@ export interface AppSettings {
negative_cash_flow_threshold_months: number;
net_worth_decline_threshold_months: number;
risky_allocation_threshold_percent: number;
idle_cash_threshold_amount: string;
idle_cash_threshold_days: number;
}