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
12 changes: 10 additions & 2 deletions web/public/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@
"ai": "AI access",
"assistant": "Assistant",
"more": "More",
"main": "Main navigation"
"main": "Main navigation",
"general": "General"
},
"common": {
"save": "Save",
Expand Down Expand Up @@ -358,7 +359,14 @@
"saveVersion": "Save budget version",
"saved": "Budget saved",
"versions": "Versions",
"none": "No budgets yet."
"none": "No budgets yet.",
"split": {
"invest": "Invest",
"save": "Save",
"expenses": "Expenses",
"fixed": "in fixed amounts",
"over": "Adds up to more than 100% of income"
}
},
"goals": {
"subtitle": "Savings linked to a goal count towards it automatically.",
Expand Down
12 changes: 10 additions & 2 deletions web/public/i18n/pt-PT.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@
"ai": "Acesso de IA",
"assistant": "Assistente",
"more": "Mais",
"main": "Navegação principal"
"main": "Navegação principal",
"general": "Geral"
},
"common": {
"save": "Guardar",
Expand Down Expand Up @@ -358,7 +359,14 @@
"saveVersion": "Guardar versão do orçamento",
"saved": "Orçamento guardado",
"versions": "Versões",
"none": "Ainda sem orçamentos."
"none": "Ainda sem orçamentos.",
"split": {
"invest": "Investir",
"save": "Poupar",
"expenses": "Despesas",
"fixed": "em valores fixos",
"over": "Soma mais de 100% do rendimento"
}
},
"goals": {
"subtitle": "Poupanças associadas a um objetivo contam automaticamente.",
Expand Down
3 changes: 3 additions & 0 deletions web/src/app/features/ai/ai-access.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,9 +89,11 @@ interface RecycledItem {
* Owner-controlled AI access: every client gets its own revocable token and explicit scopes. Reading is the
* default; write scopes are opt-in, flagged and confirmed, and AI deletions land in a 30-day recycle bin.
*/
import { SettingsTabsComponent } from '../../shared/settings-tabs';
@Component({
selector: 'app-ai-access',
imports: [
SettingsTabsComponent,
NgIcon,
HlmTableImports,
HlmInputImports,
Expand All @@ -115,6 +117,7 @@ interface RecycledItem {
],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<app-settings-tabs />
<div class="mb-6 flex flex-wrap items-center justify-between gap-3">
<div>
<h1 class="text-2xl font-semibold tracking-tight">{{ 'nav.ai' | translate }}</h1>
Expand Down
45 changes: 45 additions & 0 deletions web/src/app/features/budgets/allocation-split.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { allocationSplit, SplitRow } from './allocation-split';

const bucket = (id: string, mode: string, value: string): SplitRow => ({
target: 'Bucket',
bucketId: id,
mode,
value,
});
const isInvestment = (id: string) => id.startsWith('inv');

describe('allocationSplit', () => {
it('adds percentages per group and gives expenses the remainder', () => {
const split = allocationSplit(
[
bucket('inv-etf', 'PercentOfIncome', '25'),
bucket('inv-crypto', 'PercentOfIncome', '0'),
bucket('sav-trip', 'PercentOfIncome', '5'),
bucket('sav-other', 'PercentOfIncome', '5,5'),
bucket('sav-none', 'None', ''),
{ target: 'ExpensePool', bucketId: null, mode: 'Remainder', value: '' },
],
isInvestment,
);
expect(split).toEqual({ invest: 25, save: 10.5, expenses: 64.5, fixed: 0, over: false });
});

it('keeps fixed amounts apart and flags more than 100%', () => {
const split = allocationSplit(
[
bucket('inv-etf', 'PercentOfIncome', '60'),
bucket('sav-trip', 'FixedAmount', '150'),
{ target: 'ExpensePool', bucketId: null, mode: 'PercentOfIncome', value: '50' },
],
isInvestment,
);
expect(split.fixed).toBe(150);
expect(split.over).toBe(true);
});

it('has no expense share without an expense budget', () => {
expect(
allocationSplit([bucket('inv-etf', 'PercentOfIncome', '20')], isInvestment).expenses,
).toBeNull();
});
});
50 changes: 50 additions & 0 deletions web/src/app/features/budgets/allocation-split.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { parseAmount } from '../../shared/parse-amount';

export interface SplitRow {
target: 'Bucket' | 'ExpensePool' | 'Category';
bucketId: string | null;
mode: string;
value: string;
}

export interface AllocationSplit {
/** Shares of income in percent. */
invest: number;
save: number;
/** null when the expense budget is not set. */
expenses: number | null;
/** Lines set as fixed euro amounts, which no percentage can show. */
fixed: number;
/** Percentages add up to more than 100. */
over: boolean;
}

/** How the edited budget divides income, live: percentages per group, the remainder, fixed amounts apart. */
export function allocationSplit(
rows: readonly SplitRow[],
isInvestment: (bucketId: string) => boolean,
): AllocationSplit {
let invest = 0;
let save = 0;
let fixed = 0;
for (const r of rows.filter((x) => x.target === 'Bucket' && x.bucketId)) {
const v = parseAmount(r.value) ?? 0;
if (r.mode === 'PercentOfIncome') {
if (isInvestment(r.bucketId!)) invest += v;
else save += v;
} else if (r.mode === 'FixedAmount') fixed += v;
}
const pool = rows.find((r) => r.target === 'ExpensePool');
let expenses: number | null = null;
if (pool?.mode === 'Remainder') expenses = Math.max(100 - invest - save, 0);
else if (pool?.mode === 'PercentOfIncome') expenses = parseAmount(pool.value) ?? 0;
else if (pool?.mode === 'FixedAmount') fixed += parseAmount(pool.value) ?? 0;
const round = (n: number) => Math.round(n * 10) / 10;
return {
invest: round(invest),
save: round(save),
expenses: expenses === null ? null : round(expenses),
fixed: Math.round(fixed * 100) / 100,
over: invest + save + (expenses ?? 0) > 100.0001,
};
}
57 changes: 56 additions & 1 deletion web/src/app/features/budgets/budgets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,13 @@ import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { firstValueFrom } from 'rxjs';
import { Api } from '../../core/api';
import { DataEvents } from '../../core/data-events';
import { MonthNamePipe, currentPeriod } from '../../core/format';
import { MoneyPipe, MonthNamePipe, currentPeriod } from '../../core/format';
import { Budget, BudgetItem, BudgetMode } from '../../core/models';
import { Toasts } from '../../core/toast';
import { CategoryLabelPipe, categoryLabel } from '../../shared/category-label';
import { MonthPickerComponent } from '../../shared/month-picker';
import { parseAmount } from '../transactions/quick-add';
import { allocationSplit } from './allocation-split';
import { HlmButtonImports } from '@spartan-ng/helm/button';
import { HlmInputImports } from '@spartan-ng/helm/input';
import { SelectComponent, SelectOption } from '../../shared/select';
Expand Down Expand Up @@ -50,6 +51,7 @@ interface Row {
TranslatePipe,
MonthPickerComponent,
MonthNamePipe,
MoneyPipe,
CategoryLabelPipe,
],
providers: [APP_ICONS],
Expand Down Expand Up @@ -124,6 +126,42 @@ interface Row {
</div>
}
</div>
<!-- Live preview of how income is divided by what is on screen (before saving). -->
@let sp = split();
<div class="mt-4" role="img" [attr.aria-label]="splitLabel()">
<div class="bg-muted flex h-2.5 overflow-hidden rounded-full">
<div class="bg-invest" [style.width.%]="sp.invest"></div>
<div class="bg-save" [style.width.%]="sp.save"></div>
@if (sp.expenses !== null) {
<div class="bg-expense/70" [style.width.%]="sp.expenses"></div>
}
</div>
<p class="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-xs">
<span class="flex items-center gap-1.5"
><span class="bg-invest size-2 rounded-full"></span
>{{ 'budgets.split.invest' | translate }} <b class="num">{{ sp.invest }}%</b></span
>
<span class="flex items-center gap-1.5"
><span class="bg-save size-2 rounded-full"></span
>{{ 'budgets.split.save' | translate }} <b class="num">{{ sp.save }}%</b></span
>
@if (sp.expenses !== null) {
<span class="flex items-center gap-1.5"
><span class="bg-expense/70 size-2 rounded-full"></span
>{{ 'budgets.split.expenses' | translate }}
<b class="num">{{ sp.expenses }}%</b></span
>
}
@if (sp.fixed) {
<span class="text-muted-foreground"
>+ {{ sp.fixed | money }} {{ 'budgets.split.fixed' | translate }}</span
>
}
@if (sp.over) {
<span class="tone-neg font-medium">{{ 'budgets.split.over' | translate }}</span>
}
</p>
</div>
<p class="text-muted-foreground mt-3 flex gap-1.5 text-xs">
<ng-icon name="lucideInfo" class="mt-px shrink-0" aria-hidden="true" />{{
'budgets.percentHint' | translate
Expand Down Expand Up @@ -276,6 +314,23 @@ export class BudgetsComponent {
protected readonly categories = liveResource({ stream: () => this.api.categories() });

protected readonly bucketRows = computed(() => this.rows().filter((r) => r.target === 'Bucket'));
protected readonly split = computed(() => {
const investment = new Set(
(this.buckets.value() ?? []).filter((b) => b.group === 'Investment').map((b) => b.id),
);
return allocationSplit(this.rows(), (id) => investment.has(id));
});
protected readonly splitLabel = computed(() => {
this.prefs.translations();
const sp = this.split();
return [
`${this.i18n.instant('budgets.split.invest')} ${sp.invest}%`,
`${this.i18n.instant('budgets.split.save')} ${sp.save}%`,
sp.expenses === null ? '' : `${this.i18n.instant('budgets.split.expenses')} ${sp.expenses}%`,
]
.filter(Boolean)
.join(', ');
});
protected readonly poolRow = computed(() => this.rows().find((r) => r.target === 'ExpensePool'));
protected readonly categoryRows = computed(() =>
this.rows().filter((r) => r.target === 'Category'),
Expand Down
Loading
Loading