chore: convert to Turborepo + npm workspaces monorepo

- Move React/Vite frontend to apps/web/ (@budgetwise/web)
- Add apps/appwrite/ (@budgetwise/appwrite) to source-control the
  Appwrite backend: declarative schema in appwrite.json (5 collections),
  CLI-based deploy.sh for containerized use, functions/ dir for future
  Appwrite Functions
- Add turbo.json for task orchestration (build, deploy, dev)
- Replace .gitlab-ci.yml with Woodpecker CI pipelines in .woodpecker/:
    web-production.yml  — push to main → build + rsync to prod
    web-staging.yml     — push to staging → build + rsync to staging
    web-preview.yml     — PR open → deploy to {pr}.{domain}; PR close → cleanup
    appwrite.yml        — schema changes in apps/appwrite/ → CLI deploy
- All secrets injected via Woodpecker CI (no committed .env files)
This commit is contained in:
Kushal Gaywala
2026-02-28 19:16:26 +01:00
parent 8009c11581
commit e0e0cc65f1
49 changed files with 729 additions and 226 deletions

View File

@@ -0,0 +1,205 @@
import type {
Income,
BalanceSheet,
Bucket,
Debt,
MonthlySnapshot,
BucketAllocation,
LoanAnalysis,
DebtRepaymentPlan,
InvestmentProjection,
} from '../types';
// ─── Income ───────────────────────────────────────────────────────────────────
export function toMonthly(amount: number, frequency: 'monthly' | 'yearly'): number {
return frequency === 'yearly' ? amount / 12 : amount;
}
export function totalMonthlyIncome(incomes: Income[]): number {
return incomes.reduce((sum, i) => sum + toMonthly(i.amount, i.frequency), 0);
}
// ─── Buffer ───────────────────────────────────────────────────────────────────
export function bufferAmount(sheet: BalanceSheet, monthlyIncome: number): number {
if (sheet.buffer_type === 'percent') {
return monthlyIncome * (sheet.buffer_value / 100);
}
return sheet.buffer_value;
}
// ─── Bucket allocations ───────────────────────────────────────────────────────
export function bucketMonthlyAllocation(bucket: Bucket, monthlyIncome: number): number {
if (bucket.goal_amount <= 0) return 0;
let amount = bucket.goal_amount;
if (bucket.goal_type === 'percent') {
amount = monthlyIncome * (bucket.goal_amount / 100);
}
if (bucket.goal_frequency === 'yearly') {
amount = amount / 12;
}
return amount;
}
export function allBucketAllocations(
buckets: Bucket[],
monthlyIncome: number,
): BucketAllocation[] {
return buckets.map((b) => ({
bucket: b,
monthlyAmount: bucketMonthlyAllocation(b, monthlyIncome),
}));
}
// ─── Debt ─────────────────────────────────────────────────────────────────────
/**
* EMI formula: P * r * (1+r)^n / ((1+r)^n - 1)
* r = monthly interest rate, n = term in months
*/
export function calculateEMI(principal: number, annualRate: number, termMonths: number): number {
if (annualRate === 0) return principal / termMonths;
const r = annualRate / 100 / 12;
const emi = (principal * r * Math.pow(1 + r, termMonths)) / (Math.pow(1 + r, termMonths) - 1);
return emi;
}
export function totalDebtPayments(debts: Debt[]): number {
return debts.reduce((sum, d) => sum + d.monthly_payment, 0);
}
// ─── Monthly Snapshot ─────────────────────────────────────────────────────────
export function buildMonthlySnapshot(
sheet: BalanceSheet | null,
incomes: Income[],
buckets: Bucket[],
debts: Debt[],
): MonthlySnapshot {
const monthlyIncome = totalMonthlyIncome(incomes);
const buffer = sheet ? bufferAmount(sheet, monthlyIncome) : 0;
const allocations = allBucketAllocations(buckets, monthlyIncome);
const totalBucket = allocations.reduce((s, a) => s + a.monthlyAmount, 0);
const debtPayments = totalDebtPayments(debts);
const available = monthlyIncome - buffer - totalBucket - debtPayments;
return {
balanceSheet: sheet,
incomes,
totalMonthlyIncome: monthlyIncome,
bufferAmount: buffer,
bucketAllocations: allocations,
totalBucketAllocation: totalBucket,
debtPayments,
available,
};
}
// ─── Loan Calculator ──────────────────────────────────────────────────────────
/**
* Given a monthly payment P, monthly rate r and term n, the max principal is:
* principal = P * (1 - (1+r)^-n) / r
*/
export function maxLoanPrincipal(
monthlyPayment: number,
annualRate: number,
termMonths: number,
): number {
if (annualRate === 0) return monthlyPayment * termMonths;
const r = annualRate / 100 / 12;
return (monthlyPayment * (1 - Math.pow(1 + r, -termMonths))) / r;
}
export function analyzeLoan(
availableMonthly: number,
annualRate: number,
termMonths: number,
desiredPrincipal?: number,
): LoanAnalysis {
const r = annualRate / 100 / 12;
const maxPrincipal = maxLoanPrincipal(availableMonthly, annualRate, termMonths);
const principalForCalc = desiredPrincipal ?? maxPrincipal;
const monthly = calculateEMI(principalForCalc, annualRate, termMonths);
return {
maxAffordablePrincipal: maxPrincipal,
monthlyPaymentForMax: availableMonthly,
availableForLoan: availableMonthly,
rateMonthly: r * 100,
termMonths,
};
}
// ─── Debt repayment plan (avalanche method) ───────────────────────────────────
export function buildRepaymentPlan(
debts: Debt[],
extraMonthly: number,
): DebtRepaymentPlan[] {
if (debts.length === 0) return [];
// Sort by highest interest rate first (avalanche)
const sorted = [...debts].sort((a, b) => b.interest_rate - a.interest_rate);
let remaining = extraMonthly;
return sorted.map((debt, idx) => {
const extra = idx === 0 ? remaining : 0; // put all extra on highest-rate debt
const r = debt.interest_rate / 100 / 12;
const payment = debt.monthly_payment + extra;
let balance = debt.remaining_balance;
let months = 0;
let totalInterest = 0;
while (balance > 0 && months < 600) {
const interest = balance * r;
totalInterest += interest;
const principal = Math.min(payment - interest, balance);
balance -= principal;
months++;
if (payment <= interest) {
months = 9999; // never paid off
break;
}
}
return {
debt,
suggestedExtra: extra,
monthsToPayoff: months,
totalInterest,
};
});
}
// ─── Investment projection ────────────────────────────────────────────────────
export function projectInvestment(
principal: number,
annualReturnPercent: number,
years: number,
): number {
const r = annualReturnPercent / 100;
return principal * Math.pow(1 + r, years);
}
export function buildInvestmentProjection(bucket: Bucket): InvestmentProjection {
const annual =
bucket.return_frequency === 'monthly'
? bucket.return_percent * 12
: bucket.return_percent;
const monthly = bucket.return_frequency === 'monthly'
? bucket.current_balance * (bucket.return_percent / 100)
: bucket.current_balance * (bucket.return_percent / 100 / 12);
return {
bucket,
monthlyReturn: monthly,
projectedValue1Y: projectInvestment(bucket.current_balance, annual, 1),
projectedValue5Y: projectInvestment(bucket.current_balance, annual, 5),
projectedValue10Y: projectInvestment(bucket.current_balance, annual, 10),
};
}

62
apps/web/src/lib/utils.ts Normal file
View File

@@ -0,0 +1,62 @@
import { format, parseISO } from 'date-fns';
export function formatCurrency(amount: number, currency = 'USD'): string {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency,
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(amount);
}
export function formatPercent(value: number, decimals = 1): string {
return `${value.toFixed(decimals)}%`;
}
export function formatDate(dateString: string): string {
try {
return format(parseISO(dateString), 'MMM d, yyyy');
} catch {
return dateString;
}
}
export function monthName(month: number): string {
return new Date(2000, month - 1, 1).toLocaleString('default', { month: 'long' });
}
export function currentMonthYear(): { month: number; year: number } {
const now = new Date();
return { month: now.getMonth() + 1, year: now.getFullYear() };
}
export function clsx(...classes: (string | boolean | undefined | null)[]): string {
return classes.filter(Boolean).join(' ');
}
export const BUCKET_COLORS = [
'#6366f1', // indigo
'#8b5cf6', // violet
'#ec4899', // pink
'#f59e0b', // amber
'#10b981', // emerald
'#3b82f6', // blue
'#ef4444', // red
'#14b8a6', // teal
'#f97316', // orange
'#84cc16', // lime
];
export function randomBucketColor(): string {
return BUCKET_COLORS[Math.floor(Math.random() * BUCKET_COLORS.length)];
}
export function monthsToReadable(months: number): string {
if (months >= 9999) return 'Never (payment too low)';
if (months >= 12) {
const y = Math.floor(months / 12);
const m = months % 12;
return m > 0 ? `${y}y ${m}m` : `${y} year${y > 1 ? 's' : ''}`;
}
return `${months} month${months !== 1 ? 's' : ''}`;
}