Files
budget2.0/src/domain/views.ts
T
Lucas Winther b0e8145d2e
Continuous Integration / lint-and-test (push) Successful in 13s
Continuous Integration / docker-build-and-push (push) Successful in 35s
feat: support unassigned purchases and allocation status
Allow month-only purchases to start finance plans. Keep unbudgeted posts distinct from explicit zero rules in overspend reporting.
2026-09-30 13:10:10 +02:00

253 lines
7.5 KiB
TypeScript

import { incomeFor, isOverridden, ruleForPost } from "./allocation.ts";
import { looseChargesForMonth } from "./charges.ts";
import { figuresFor, foldBalances, type Fold, type PostMonthFigures } from "./fold.ts";
import { roundMoney } from "./money.ts";
import { compareMonths, monthOf, monthRange, monthsOfYear } from "./months.ts";
import type { Dataset, MonthId, Post } from "./types.ts";
export interface MonthPostRow {
post: Post;
figures: PostMonthFigures;
overridden: boolean;
/** An applicable standing rule or monthly override, including an explicit zero. */
hasAllocationRule: boolean;
}
export interface MonthViewModel {
monthId: MonthId;
income: number;
totalAllocation: number;
totalCharges: number;
looseCharges: number;
/** income - totalAllocation. Negative when allocations exceed income. */
unallocated: number;
/**
* How many budgeted rows are overspent — `remaining < 0` with an applicable
* allocation rule. A post with no rule still shows its negative balance,
* but has no budget to overspend. Computed here because it is
* the month-level answer to "did I overspend", and it belongs where the
* rest of the month's arithmetic is tested.
*/
overspentCount: number;
rows: MonthPostRow[];
}
function hasActivity(figures: PostMonthFigures): boolean {
// `remaining` and `projected` are both fully derived from these five raw
// fields, so checking all five is what keeps a post visible whenever either
// column would show a nonzero figure. `expected` and `projectedCarriedIn`
// are the two the recurring-costs feature added: an archived post whose
// only activity this month is an unconfirmed occurrence has a zero
// `remaining` but a nonzero `projected`, and must not be dropped.
return (
figures.carriedIn !== 0 ||
figures.allocation !== 0 ||
figures.charges !== 0 ||
figures.expected !== 0 ||
figures.projectedCarriedIn !== 0
);
}
function visiblePosts(dataset: Dataset): Post[] {
return [...dataset.posts].sort((a, b) => a.order - b.order);
}
export function monthView(dataset: Dataset, monthId: MonthId): MonthViewModel {
const fold = foldBalances(dataset, monthId);
const month = dataset.months.find((m) => m.id === monthId);
const income = incomeFor(dataset, monthId);
const rows: MonthPostRow[] = [];
for (const post of visiblePosts(dataset)) {
const figures = figuresFor(fold, post.id, monthId);
// Archived posts stay visible only while they still have activity.
if (post.archived && !hasActivity(figures)) continue;
rows.push({
post,
figures,
overridden: isOverridden(month, post.id),
hasAllocationRule: ruleForPost(month, post, monthId) !== null,
});
}
const baseDigits = dataset.settings.digits;
const looseCharges = looseChargesForMonth(dataset, monthId);
const totalAllocation = roundMoney(
rows.reduce((sum, r) => sum + r.figures.allocation, 0),
baseDigits,
);
const totalCharges = roundMoney(
rows.reduce((sum, r) => sum + r.figures.charges, looseCharges),
baseDigits,
);
return {
monthId,
income,
totalAllocation,
totalCharges,
looseCharges,
unallocated: roundMoney(income - totalAllocation, baseDigits),
overspentCount: rows.filter((r) => r.hasAllocationRule && r.figures.remaining < 0).length,
rows,
};
}
export interface YearPostRow {
post: Post;
/** Twelve entries, January to December. */
byMonth: PostMonthFigures[];
hasAllocationRuleByMonth: boolean[];
totalAllocation: number;
totalCharges: number;
/** December's remaining balance. */
closingBalance: number;
}
export interface YearViewModel {
year: number;
months: MonthId[];
incomeByMonth: number[];
totalIncome: number;
totalCharges: number;
looseByMonth: number[];
totalLooseCharges: number;
rows: YearPostRow[];
}
export function yearView(dataset: Dataset, year: number): YearViewModel {
const months = monthsOfYear(year);
const fold = foldBalances(dataset, months[11]!);
const baseDigits = dataset.settings.digits;
const incomeByMonth = months.map((m) => incomeFor(dataset, m));
const looseByMonth = months.map((m) => looseChargesForMonth(dataset, m));
const rows: YearPostRow[] = visiblePosts(dataset).map((post) => {
const byMonth = months.map((m) => figuresFor(fold, post.id, m));
return {
post,
byMonth,
hasAllocationRuleByMonth: months.map((monthId) =>
ruleForPost(dataset.months.find((m) => m.id === monthId), post, monthId) !== null,
),
totalAllocation: roundMoney(
byMonth.reduce((sum, f) => sum + f.allocation, 0),
baseDigits,
),
totalCharges: roundMoney(
byMonth.reduce((sum, f) => sum + f.charges, 0),
baseDigits,
),
closingBalance: byMonth[11]!.remaining,
};
});
return {
year,
months,
incomeByMonth,
totalIncome: roundMoney(
incomeByMonth.reduce((a, b) => a + b, 0),
baseDigits,
),
totalCharges: roundMoney(
rows.reduce((sum, r) => sum + r.totalCharges, looseByMonth.reduce((a, b) => a + b, 0)),
baseDigits,
),
looseByMonth,
totalLooseCharges: roundMoney(looseByMonth.reduce((a, b) => a + b, 0), baseDigits),
rows,
};
}
export interface SummaryViewModel {
from: MonthId;
to: MonthId;
byPost: { post: Post; charges: number }[];
byMonth: { monthId: MonthId; charges: number }[];
totalCharges: number;
looseCharges: number;
totalIncome: number;
difference: number;
}
export function summaryView(
dataset: Dataset,
from: MonthId,
to: MonthId,
): SummaryViewModel {
const months = monthRange(from, to);
const base = dataset.settings.baseCurrency;
const baseDigits = dataset.settings.digits;
const fold: Fold =
months.length > 0 ? foldBalances(dataset, months[months.length - 1]!) : new Map();
const byPost = visiblePosts(dataset).map((post) => ({
post,
charges: roundMoney(
months.reduce((sum, m) => sum + figuresFor(fold, post.id, m).charges, 0),
baseDigits,
),
}));
const byMonth = months.map((monthId) => ({
monthId,
charges: roundMoney(
dataset.posts.reduce(
(sum, p) => sum + figuresFor(fold, p.id, monthId).charges,
looseChargesForMonth(dataset, monthId),
),
baseDigits,
),
}));
const totalCharges = roundMoney(
byMonth.reduce((sum, m) => sum + m.charges, 0),
baseDigits,
);
const looseCharges = roundMoney(
months.reduce((sum, monthId) => sum + looseChargesForMonth(dataset, monthId), 0),
baseDigits,
);
const totalIncome = roundMoney(
months.reduce((sum, m) => sum + incomeFor(dataset, m), 0),
baseDigits,
);
return {
from,
to,
byPost,
byMonth,
totalCharges,
looseCharges,
totalIncome,
difference: roundMoney(totalIncome - totalCharges, baseDigits),
};
}
/**
* The months worth rendering: from the fold start to the latest month touched
* by any month record or purchase slice, including future finance-plan slices.
*/
export function datasetMonthSpan(dataset: Dataset): { from: MonthId; to: MonthId } {
const from = dataset.settings.foldStartMonth;
let to = from;
const consider = (candidate: MonthId) => {
if (compareMonths(candidate, to) > 0) to = candidate;
};
for (const month of dataset.months) consider(month.id);
for (const purchase of dataset.purchases) {
if (purchase.schedule) {
for (const slice of purchase.schedule.slices) consider(slice.month);
} else {
consider(monthOf(purchase.date));
}
}
return { from, to };
}