feat: initial BudgetWise budgeting app with Appwrite sync

Full-stack PWA budgeting app installable on Android via Chrome.

Features implemented:
- Monthly balance sheet: income sources (monthly/yearly), buffer (fixed $ or % of income)
- Buckets: regular, savings-goal, and investment types with custom goals
  (amount or % of income, monthly or yearly schedule)
- Debt tracker: manual or auto-EMI (amortization formula), interest rate,
  remaining balance tracking, avalanche-method repayment suggestions
- Loan calculator: max affordable principal and EMI checker based on
  remaining income after all expenses
- Investment projections: compound interest at monthly/yearly rate,
  1/5/10-year projections per bucket
- Deposit / withdraw transactions per bucket with balance history
- Appwrite self-hosted backend: auth, 5 collections (balance_sheets,
  incomes, buckets, debts, transactions)
- scripts/setup-appwrite.cjs: one-command DB setup via node-appwrite
- PWA manifest + service worker (vite-plugin-pwa) for Android install
- Dark mobile-first UI with TailwindCSS, Zustand state, React Router v6

https://claude.ai/code/session_01Ny2EMaZYvzk5SSVDAPgxpP
This commit is contained in:
Claude
2026-02-22 14:10:44 +00:00
commit 37bc35f53f
40 changed files with 11266 additions and 0 deletions

245
scripts/setup-appwrite.cjs Normal file
View File

@@ -0,0 +1,245 @@
#!/usr/bin/env node
/**
* BudgetWise Appwrite database setup script
*
* Run once to create the database, collections, and attributes
* in your self-hosted Appwrite instance.
*
* Usage:
* cp .env.example .env
* # Fill in VITE_APPWRITE_ENDPOINT, VITE_APPWRITE_PROJECT_ID, APPWRITE_API_KEY
* npm run setup:appwrite
*/
const { Client, Databases, Permission, Role, IndexType } = require('node-appwrite');
const fs = require('fs');
const path = require('path');
// ── Load .env manually (no dotenv dependency needed) ─────────────────────────
function loadEnv() {
const envPath = path.join(__dirname, '..', '.env');
if (!fs.existsSync(envPath)) {
console.error('❌ .env file not found. Copy .env.example to .env and fill in the values.');
process.exit(1);
}
const lines = fs.readFileSync(envPath, 'utf8').split('\n');
const env = {};
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const [key, ...rest] = trimmed.split('=');
env[key.trim()] = rest.join('=').trim();
}
return env;
}
const env = loadEnv();
const ENDPOINT = env.VITE_APPWRITE_ENDPOINT;
const PROJECT_ID = env.VITE_APPWRITE_PROJECT_ID;
const API_KEY = env.APPWRITE_API_KEY;
if (!ENDPOINT || !PROJECT_ID || !API_KEY) {
console.error('❌ Missing VITE_APPWRITE_ENDPOINT, VITE_APPWRITE_PROJECT_ID, or APPWRITE_API_KEY in .env');
process.exit(1);
}
const client = new Client()
.setEndpoint(ENDPOINT)
.setProject(PROJECT_ID)
.setKey(API_KEY);
const databases = new Databases(client);
const DB_ID = 'budget_db';
const DB_NAME = 'BudgetWise';
// ── Permissions: only authenticated users can access their own docs ──────────
// We rely on document-level security with user_id filtering.
const PERMISSIONS = [
Permission.read(Role.users()),
Permission.create(Role.users()),
Permission.update(Role.users()),
Permission.delete(Role.users()),
];
async function createCollection(id, name, attrs, indexes = []) {
console.log(`\n📁 Creating collection: ${name}`);
try {
await databases.createCollection(DB_ID, id, name, PERMISSIONS);
console.log(` ✓ Collection created`);
} catch (err) {
if (err.code === 409) {
console.log(` ⚠ Collection already exists, skipping...`);
} else {
throw err;
}
}
// Add attributes
for (const attr of attrs) {
try {
if (attr.type === 'string') {
await databases.createStringAttribute(DB_ID, id, attr.key, attr.size ?? 255, attr.required ?? false, attr.default ?? null);
} else if (attr.type === 'float') {
await databases.createFloatAttribute(DB_ID, id, attr.key, attr.required ?? false, undefined, undefined, attr.default ?? null);
} else if (attr.type === 'integer') {
await databases.createIntegerAttribute(DB_ID, id, attr.key, attr.required ?? false, undefined, undefined, attr.default ?? null);
} else if (attr.type === 'boolean') {
await databases.createBooleanAttribute(DB_ID, id, attr.key, attr.required ?? false, attr.default ?? null);
}
process.stdout.write(`${attr.key}\n`);
// Wait a bit between attributes (Appwrite rate limits)
await sleep(300);
} catch (err) {
if (err.code === 409) {
process.stdout.write(`${attr.key} (already exists)\n`);
} else {
console.error(`${attr.key}: ${err.message}`);
}
}
}
// Add indexes
for (const idx of indexes) {
try {
await databases.createIndex(DB_ID, id, idx.key, IndexType.Key, idx.attributes, idx.orders ?? []);
process.stdout.write(` ✓ index: ${idx.key}\n`);
await sleep(300);
} catch (err) {
if (err.code === 409) {
process.stdout.write(` ⚠ index ${idx.key} (already exists)\n`);
} else {
console.error(` ❌ index ${idx.key}: ${err.message}`);
}
}
}
}
function sleep(ms) {
return new Promise((r) => setTimeout(r, ms));
}
async function main() {
console.log('🚀 BudgetWise Appwrite Setup');
console.log(` Endpoint : ${ENDPOINT}`);
console.log(` Project : ${PROJECT_ID}`);
// Create database
console.log('\n🗄 Creating database...');
try {
await databases.create(DB_ID, DB_NAME);
console.log(' ✓ Database created');
} catch (err) {
if (err.code === 409) {
console.log(' ⚠ Database already exists, continuing...');
} else {
throw err;
}
}
// ── balance_sheets ────────────────────────────────────────────────────────
await createCollection(
'balance_sheets',
'Balance Sheets',
[
{ type: 'integer', key: 'month', required: true },
{ type: 'integer', key: 'year', required: true },
{ type: 'string', key: 'buffer_type', required: true, size: 20 },
{ type: 'float', key: 'buffer_value', required: true, default: 0 },
{ type: 'string', key: 'user_id', required: true, size: 64 },
],
[
{ key: 'user_month_year', attributes: ['user_id', 'month', 'year'], orders: ['ASC', 'DESC', 'DESC'] },
],
);
// ── incomes ───────────────────────────────────────────────────────────────
await createCollection(
'incomes',
'Incomes',
[
{ type: 'string', key: 'balance_sheet_id', required: true, size: 64 },
{ type: 'string', key: 'name', required: true, size: 128 },
{ type: 'float', key: 'amount', required: true },
{ type: 'string', key: 'frequency', required: true, size: 20 },
{ type: 'string', key: 'user_id', required: true, size: 64 },
],
[
{ key: 'user_sheet', attributes: ['user_id', 'balance_sheet_id'] },
],
);
// ── buckets ───────────────────────────────────────────────────────────────
await createCollection(
'buckets',
'Buckets',
[
{ type: 'string', key: 'name', required: true, size: 128 },
{ type: 'string', key: 'description', required: false, size: 512, default: '' },
{ type: 'string', key: 'type', required: true, size: 32 },
{ type: 'float', key: 'current_balance', required: true, default: 0 },
{ type: 'float', key: 'goal_amount', required: false, default: 0 },
{ type: 'string', key: 'goal_type', required: false, size: 20, default: 'amount' },
{ type: 'string', key: 'goal_frequency', required: false, size: 20, default: 'monthly' },
{ type: 'float', key: 'return_percent', required: false, default: 0 },
{ type: 'string', key: 'return_frequency', required: false, size: 20, default: 'yearly' },
{ type: 'string', key: 'color', required: true, size: 16 },
{ type: 'integer', key: 'sort_order', required: false, default: 0 },
{ type: 'string', key: 'user_id', required: true, size: 64 },
],
[
{ key: 'user_order', attributes: ['user_id', 'sort_order'], orders: ['ASC', 'ASC'] },
],
);
// ── debts ─────────────────────────────────────────────────────────────────
await createCollection(
'debts',
'Debts',
[
{ type: 'string', key: 'name', required: true, size: 128 },
{ type: 'float', key: 'principal', required: true },
{ type: 'float', key: 'remaining_balance', required: true },
{ type: 'float', key: 'interest_rate', required: true },
{ type: 'string', key: 'interest_frequency', required: true, size: 20 },
{ type: 'integer', key: 'term_months', required: true },
{ type: 'float', key: 'monthly_payment', required: true },
{ type: 'boolean', key: 'is_auto_calculated', required: true, default: false },
{ type: 'string', key: 'start_date', required: true, size: 24 },
{ type: 'string', key: 'user_id', required: true, size: 64 },
],
[
{ key: 'user_id_idx', attributes: ['user_id'] },
],
);
// ── transactions ──────────────────────────────────────────────────────────
await createCollection(
'transactions',
'Transactions',
[
{ type: 'string', key: 'bucket_id', required: true, size: 64 },
{ type: 'string', key: 'type', required: true, size: 20 },
{ type: 'float', key: 'amount', required: true },
{ type: 'string', key: 'date', required: true, size: 24 },
{ type: 'string', key: 'notes', required: false, size: 512, default: '' },
{ type: 'float', key: 'balance_after', required: true },
{ type: 'string', key: 'user_id', required: true, size: 64 },
],
[
{ key: 'user_bucket_date', attributes: ['user_id', 'bucket_id', 'date'], orders: ['ASC', 'ASC', 'DESC'] },
],
);
console.log('\n\n✅ Setup complete!');
console.log('\nNext steps:');
console.log(' 1. npm run build');
console.log(' 2. Deploy the dist/ folder to your web server');
console.log(' 3. Open the app in Chrome on Android → "Add to Home Screen"');
}
main().catch((err) => {
console.error('\n❌ Setup failed:', err.message ?? err);
process.exit(1);
});