Compare commits

...

2 Commits

Author SHA1 Message Date
3e68f9af5b This shouldn't be there lol 2025-07-25 16:23:11 -04:00
9b2a0b63e3 New and imporved! 2025-07-25 16:22:57 -04:00
15 changed files with 439 additions and 190 deletions

28
package-lock.json generated
View File

@ -11,6 +11,7 @@
"@auth/sveltekit": "^1.10.0",
"@tailwindcss/vite": "^4.1.11",
"cron": "^4.3.2",
"echarts": "^5.6.0",
"patchright": "^1.52.5",
"postgres": "^3.4.7",
"puppeteer": "^24.14.0",
@ -3934,6 +3935,20 @@
"node": ">= 0.4"
}
},
"node_modules/echarts": {
"version": "5.6.0",
"resolved": "https://registry.npmjs.org/echarts/-/echarts-5.6.0.tgz",
"integrity": "sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA==",
"dependencies": {
"tslib": "2.3.0",
"zrender": "5.6.1"
}
},
"node_modules/echarts/node_modules/tslib": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg=="
},
"node_modules/ejs": {
"version": "3.1.10",
"resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz",
@ -8475,6 +8490,19 @@
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
},
"node_modules/zrender": {
"version": "5.6.1",
"resolved": "https://registry.npmjs.org/zrender/-/zrender-5.6.1.tgz",
"integrity": "sha512-OFXkDJKcrlx5su2XbzJvj/34Q3m6PvyCZkVPHGYpcCJ52ek4U/ymZyfuV1nKE23AyBJ51E/6Yr0mhZ7xGTO4ag==",
"dependencies": {
"tslib": "2.3.0"
}
},
"node_modules/zrender/node_modules/tslib": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg=="
}
}
}

View File

@ -28,6 +28,7 @@
"@auth/sveltekit": "^1.10.0",
"@tailwindcss/vite": "^4.1.11",
"cron": "^4.3.2",
"echarts": "^5.6.0",
"patchright": "^1.52.5",
"postgres": "^3.4.7",
"puppeteer": "^24.14.0",

View File

@ -86,7 +86,7 @@ export async function updateBudget(id, name, amount, notes) {
return result;
}
export async function getBudgetTransactions(id) {
export async function getBudgetTransactionsByid(id) {
// Fetch all transactions associated with a specific budget
try {
let transactions = await db`
@ -258,6 +258,22 @@ export async function getBudgetTransactionsForAccount(accountID) {
return transactions;
}
export async function getAllBudgetTransactions(accountID) {
const transactions = await db`
select budget_transaction.id as id,
budget_transaction.budget_id as budget_id,
budget_transaction.transaction_id as transaction_id,
budget_transaction.amount as amount,
budget_transaction.notes as notes,
budget.name as budget_name
from budget_transaction
join transaction on budget_transaction.transaction_id = transaction.id
join budget on budget_transaction.budget_id = budget.id
`;
return transactions;
}
export async function getHiddenAccounts(age) {
const accounts = await db`
select
@ -514,4 +530,63 @@ export async function runRules() {
}
}
export async function getUnallocatedTotal() {
const result = await db`
select sum(amount) as total
from budget_transaction
where transaction_id is null
`;
// result = Result [{ total: 1000.00 }]
return result[0].total;
}
export async function getUnallocatedTransactions() {
const result = await db`
SELECT TRANSACTION.id as id,
TRANSACTION.amount as amount,
TRANSACTION.description as description,
TRANSACTION.date as date,
TRANSACTION.account_id
FROM TRANSACTION
full join budget_transaction
ON TRANSACTION.id = budget_transaction.transaction_id
WHERE budget_transaction.amount IS NULL
AND TRANSACTION.out_of_budget = FALSE
`;
// result = Result [{ id: 1, amount: 100.00, notes: "Unallocated funds" }, ...]
return result;
}
export async function getUnderallocatedTransactions() {
const result = await db`
SELECT *
FROM (SELECT TRANSACTION.id,
TRANSACTION.amount AS amount,
TRANSACTION.description,
TRANSACTION.DATE AS date,
TRANSACTION.account_id,
SUM(budget_transaction.amount) AS budget_total
FROM TRANSACTION
full join budget_transaction
ON TRANSACTION.id = budget_transaction.transaction_id
WHERE TRANSACTION.out_of_budget = FALSE
GROUP BY TRANSACTION.id) AS t
WHERE t.amount != t.budget_total
`;
// result = Result [{ id: 1, amount: 100.00, notes: "Unallocated funds" }, ...]
return result;
}
export async function getLast30DaysTransactionsSums() {
let result = await db`
SELECT cast(date as date) as date, SUM(amount)
from transaction
where date >= (CURRENT_DATE - interval '30 days')
GROUP BY cast(date as date)
order by date desc;
`;
// result = Result [{ id: 1, amount: 100.00, notes: "Recent transaction" }, ...]
return result;
}
export default db;

6
src/lib/echarts.js Normal file
View File

@ -0,0 +1,6 @@
import * as charts from 'echarts';
export function echarts(node, option) {
const chart = charts.init(node);
chart.setOption(option);
}

View File

@ -0,0 +1,128 @@
<script>
import { invalidateAll } from '$app/navigation';
let { transaction, close, budgets } = $props();
import { getContext } from 'svelte';
const addToast = getContext('addToast');
let id = $derived(transaction.id);
let description = $derived(transaction.description || 'No description');
let amount = $derived(transaction.amount || 0);
let date = $derived(new Date(transaction.date || Date.now()));
let out_of_budget = $derived(transaction.out_of_budget || false);
let budget_id = $derived(transaction.budget_id || null);
let loading = $state(false);
let notes = $state(transaction.notes || '');
async function saveNotes() {
loading = true;
let res = fetch(`/transcation/${id}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
notes: $state.snapshot(notes),
out_of_budget: out_of_budget
})
});
let result = await res;
if (result.ok) {
addToast('Notes saved successfully', 'success');
invalidateAll();
} else {
// Handle error case
addToast('Failed to save notes', 'error');
}
loading = false;
close();
}
</script>
<dialog id="transactionEditModal" class="modal modal-open modal-top}">
<div class="modal-box">
{#if loading}
<div class="flex items-center justify-center">
<span class="loading loading-spinner loading-xl"></span>
</div>
{:else}
<form method="dialog">
<button
onclick={() => close()}
class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2"></button
>
</form>
<fieldset class="fieldset">
<p class="label">{description}</p>
<p class="label">${amount}</p>
<p class="label">{date.toDateString()}</p>
<legend class="fieldset-legend">Notes</legend>
<textarea bind:value={notes} class="textarea w-100"></textarea>
<legend class="fieldset-legend">Transaction Properties</legend>
<label class="label">
<input type="checkbox" bind:checked={out_of_budget} class="toggle" />
Out of Budgets
</label>
<button class="btn btn-neutral" onclick={() => saveNotes()}>Save</button>
<legend class="fieldset-legend">Add to budget</legend>
<select bind:value={budget_id} class="select">
<option disabled selected>Pick a budget</option>
{#each budgets as budget}
<option value={budget.id}>{budget.name} - {budget.sum}</option>
{/each}
</select>
<legend class="fieldset-legend">Amount</legend>
<input
bind:value={amount}
type="number"
class="input validator"
required
placeholder="Amount"
title="Amount"
/>
<legend class="fieldset-legend">Notes</legend>
<textarea bind:value={notes} class="textarea w-100"></textarea>
<p class="validator-hint">Must a sensible number</p>
<button
class="btn btn-primary"
onclick={() => {
if (budget_id) {
loading = true;
fetch(`/api/budget/${budget_id}/transaction`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
transactionId: id,
amount: amount,
notes: notes
})
}).then((res) => {
invalidateAll();
if (res.ok) {
// Optionally, you can refresh the UI or show a success message
addToast('Transaction added to budget', 'success');
console.log('Transaction added to budget successfully');
} else {
addToast('Failed to add transaction to budget', 'error');
console.error('Failed to add transaction to budget');
}
});
} else {
console.error('No budget selected');
}
loading = false;
close();
}}>Add to Budget</button
>
</fieldset>
{/if}
</div>
<form method="dialog" class="modal-backdrop">
<button onclick={() => close()}>close</button>
</form>
</dialog>

View File

@ -0,0 +1,80 @@
<script>
import { EditSymbol } from '$lib/editSymbol.svelte';
import EditTransaction from '$lib/editTransaction.svelte';
let currentTransaction = $state({ budget_id: null, amount: 0, notes: '', out_of_budget: false });
let { transactions, budgetTransactions, budgets } = $props();
let editing = $state(false);
function editNotes(transaction, remaining) {
currentTransaction = transaction;
currentTransaction.amount = remaining;
editing = true;
}
</script>
<div>
{#each transactions as transaction}
{@const applicableBudgets = budgetTransactions.filter(
(bt) => bt.transaction_id === transaction.id
)}
{@const budgetTotal = applicableBudgets.reduce(
(accumulator, currentValue) => accumulator + Number(currentValue.amount),
0
)}
{@const remaining = transaction.amount - budgetTotal}
<div
class=" p-2 {remaining != 0 && !transaction.out_of_budget
? 'bg-warning-content'
: ''} {transaction.pending ? 'opacity-50' : ''}"
>
<div class="h-full flex flex-row justify-between items-center">
<div class="h-full flex flex-col md:flex-row justify-between md:items-center md:grow">
<div>
<div>{transaction.description}</div>
<div class="text-xs uppercase font-semibold opacity-60">
{transaction.date.toDateString()}
</div>
{#if !transaction.out_of_budget}
<div class="text-xs uppercase font-semibold text-left opacity-60">
In Budget: {budgetTotal.toFixed(2)}
</div>
<div class="text-xs uppercase font-semibold text-left opacity-60">
Remaining {remaining.toFixed(2)}
</div>
{:else}
<div class="text-xs uppercase font-semibold text-left opacity-60">Out of budget</div>
{/if}
</div>
{#if applicableBudgets.length > 0}
<div class="flex grow flex-col">
{#each applicableBudgets as budgetTransaction}
<div class="md:text-right">
{`${budgetTransaction.budget_name}: ${budgetTransaction.amount}`}
</div>
{/each}
</div>
{/if}
<div class="md:text-right text-2xl md:p-4 md:w-35">
<div class="">
{transaction.amount}
</div>
</div>
</div>
<div class="">
<button
class="btn btn-square btn-ghost"
onclick={() => editNotes(transaction, remaining)}
>
{@render EditSymbol()}
</button>
</div>
</div>
</div>
{/each}
{#if editing}
<EditTransaction transaction={currentTransaction} close={() => (editing = false)} budgets />
{/if}
</div>

View File

@ -21,22 +21,23 @@ export function isNeedCode() {
export function isRunning() {
return running;
}
export async function pullData(amount = 100) {
running = true;
try {
state = [];
code = null;
needCode = false;
state.push('Starting Browser');
const browser = await chromium.launchPersistentContext('context', {
channel: 'chrome',
headless: true,
headless: false,
viewport: null
// do NOT add custom browser headers or userAgent
});
export async function pullData(amount = 100) {
running = true;
state = [];
code = null;
needCode = false;
state.push('Starting Browser');
const page = await browser.newPage();
try {
state.push('Navigating to United FCU');
await page.goto('https://online.unitedfcu.com/unitedfederalcredituniononline/uux.aspx#/login');
@ -260,11 +261,10 @@ export async function pullData(amount = 100) {
}
state.push('Done');
await browser.close();
} catch (error) {
console.error('Error in pullData:', error);
state.push(`Error: ${error.message}`);
}
page.close();
running = false;
}

View File

@ -1,10 +1,9 @@
import { getAccounts, getBudgets, getTotal } from '$lib/db';
import { getAccounts, getBudgets } from '$lib/db';
import { get } from 'svelte/store';
export async function load({ params }) {
let accounts = await getAccounts();
let budgets = await getBudgets();
let total = await getTotal();
return { accounts, budgets, total };
return { accounts, budgets };
}

View File

@ -51,7 +51,7 @@
<ul class="menu bg-base-200 text-base-content min-h-full w-80 p-4">
<li>
<a href="/">
<span class="text-lg font-bold">Total: {total}</span>
<span class="text-lg font-bold">Timm Budget</span>
</a>
</li>
<li><div class="divider">Budgets</div></li>

View File

@ -0,0 +1,20 @@
import {
getLast30DaysTransactionsSums,
getUnallocatedTransactions,
getUnderallocatedTransactions,
getTotal,
getBudgets,
getAllBudgetTransactions
} from '$lib/db';
export async function load({ params }) {
const unallocatedTrans = await getUnallocatedTransactions();
const underAllocatedTrans = await getUnderallocatedTransactions();
const total = await getTotal();
const budgets = await getBudgets();
const budgetTransactions = await getAllBudgetTransactions();
const last30DaysTransactionsSums = await getLast30DaysTransactionsSums();
return { unallocatedTrans, underAllocatedTrans, total, budgets, budgetTransactions, last30DaysTransactionsSums };
}

View File

@ -0,0 +1,61 @@
<script>
import TransactionList from '$lib/transactionList.svelte';
import { echarts } from '$lib/echarts';
let { data } = $props();
let total = $derived(data.total);
let unallocatedTrans = $derived(data.unallocatedTrans);
let underAllocatedTrans = $derived(data.underAllocatedTrans);
let budgets = $derived(data.budgets);
let budgetTransactions = $derived(data.budgetTransactions);
let last30days = $derived(data.last30DaysTransactionsSums);
let chartData = $derived(
last30days
.reduce((acc, curr) => [...acc, acc[acc.length - 1] + Number(curr.sum)], [Number(total)])
.reverse()
);
let chartDates = $derived(
[
'now',
...last30days.map((day) => `${day.date.getMonth() + 1}/${day.date.getDate()}`)
].reverse()
);
const option = $derived({
xAxis: {
type: 'category',
data: chartDates
},
yAxis: {
type: 'value'
},
series: [
{
data: chartData,
type: 'line'
}
]
});
</script>
<span class="font-sans text-xl"
>Total Net Worth: <span class="{total > 0 ? 'bg-green-500' : 'bg-red-500'} pl-2 pr-2 rounded-lg"
>${total}</span
></span
>
<div class="container" use:echarts={option} />
<div class="text-xl divider">Unallocated Transactions</div>
<TransactionList {budgets} {budgetTransactions} transactions={unallocatedTrans} />
<div class="text-xl divider">Underallocated Transactions</div>
<TransactionList {budgets} {budgetTransactions} transactions={underAllocatedTrans} />
<style>
.container {
height: 500px;
width: 100%;
}
</style>

View File

@ -4,53 +4,30 @@
import { invalidate, invalidateAll } from '$app/navigation';
import { loadingModal } from '$lib/loadingModal.svelte';
import { getContext } from 'svelte';
import TransactionList from '$lib/transactionList.svelte';
import EditTransaction from '$lib/editTransaction.svelte';
import { init } from 'echarts';
let { data } = $props();
const addToast = getContext('addToast');
let trans = $derived(data.transactions);
let transactions = $derived(data.transactions);
let budgets = $derived(data.budgets);
let budgetTransactions = $derived(data.budgetTransactions);
let notes = $state('');
let currentTransaction = $state({ budget_id: null, amount: 0, notes: '', out_of_budget: false });
let account = $derived(data.account);
let hide = $derived(account?.hide || false);
let inTotal = $derived(account?.in_total || false);
let expanded = $state([]);
let loading = $state(false);
let editing = $state(false);
function editNotes(transaction, remaining) {
my_modal_3.showModal();
currentTransaction = transaction;
notes = transaction.notes;
currentTransaction.amount = remaining;
editing = true;
}
async function saveNotes() {
my_modal_3.close();
loading = true;
let res = fetch(`/transcation/${currentTransaction.id}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
notes: $state.snapshot(notes),
out_of_budget: currentTransaction.out_of_budget
})
});
loading = false;
let result = await res;
if (result.ok) {
// Update the transaction in the data
currentTransaction.notes = notes;
// Optionally, you can also update the UI or show a success message
addToast('Notes saved successfully', 'success');
invalidateAll();
} else {
// Handle error case
console.error('Failed to save notes');
addToast('Failed to save notes', 'error');
}
}
async function saveSettings() {
loading = true;
settings_modal.close();
@ -86,146 +63,8 @@
</div>
</div>
<h1>Transcations</h1>
<div>
{#each trans as transaction}
{@const applicableBudgets = budgetTransactions.filter(
(bt) => bt.transaction_id === transaction.id
)}
{@const budgetTotal = applicableBudgets.reduce(
(accumulator, currentValue) => accumulator + Number(currentValue.amount),
0
)}
{@const remaining = transaction.amount - budgetTotal}
<div
class=" p-2 {remaining != 0 && !transaction.out_of_budget
? 'bg-warning-content'
: ''} {transaction.pending ? 'opacity-50' : ''}"
>
<div class="h-full flex flex-row justify-between items-center">
<div class="h-full flex flex-col md:flex-row justify-between md:items-center md:grow">
<div>
<div>{transaction.description}</div>
<div class="text-xs uppercase font-semibold opacity-60">
{transaction.date.toDateString()}
</div>
{#if !transaction.out_of_budget}
<div class="text-xs uppercase font-semibold text-left opacity-60">
In Budget: {budgetTotal.toFixed(2)}
</div>
<div class="text-xs uppercase font-semibold text-left opacity-60">
Remaining {remaining.toFixed(2)}
</div>
{:else}
<div class="text-xs uppercase font-semibold text-left opacity-60">Out of budget</div>
{/if}
</div>
{#if applicableBudgets.length > 0}
<div class="flex grow flex-col">
{#each applicableBudgets as budgetTransaction}
<div class="md:text-right">
{`${budgetTransaction.budget_name}: ${budgetTransaction.amount}`}
</div>
{/each}
</div>
{/if}
<div class="md:text-right text-2xl md:p-4 md:w-35">
<div class="">
{transaction.amount}
</div>
</div>
</div>
<div class="">
<button
class="btn btn-square btn-ghost"
onclick={() => editNotes(transaction, remaining)}
>
{@render EditSymbol()}
</button>
</div>
</div>
</div>
{/each}
</div>
<dialog id="my_modal_3" class="modal">
<div class="modal-box">
<form method="dialog">
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2"></button>
</form>
<fieldset class="fieldset">
<p class="label">{currentTransaction?.description}</p>
<p class="label">${currentTransaction?.amount}</p>
<p class="label">{currentTransaction?.date?.toDateString()}</p>
<legend class="fieldset-legend">Notes</legend>
<textarea bind:value={notes} class="textarea w-100"></textarea>
<legend class="fieldset-legend">Login options</legend>
<label class="label">
<input type="checkbox" bind:checked={currentTransaction.out_of_budget} class="toggle" />
Out of Budgets
</label>
<button class="btn btn-neutral" onclick={() => saveNotes()}>Save</button>
<legend class="fieldset-legend">Add to budget</legend>
<select bind:value={currentTransaction.budget_id} class="select">
<option disabled selected>Pick a budget</option>
{#each budgets as budget}
<option value={budget.id}>{budget.name} - {budget.sum}</option>
{/each}
</select>
<legend class="fieldset-legend">Amount</legend>
<input
bind:value={currentTransaction.amount}
type="number"
class="input validator"
required
placeholder="Amount"
title="Amount"
/>
<legend class="fieldset-legend">Notes</legend>
<textarea bind:value={currentTransaction.notes} class="textarea w-100"></textarea>
<p class="validator-hint">Must a sensible number</p>
<button
class="btn btn-primary"
onclick={() => {
if (currentTransaction.budget_id) {
loading = true;
my_modal_3.close();
fetch(`/api/budget/${currentTransaction.budget_id}/transaction`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
transactionId: currentTransaction.id,
amount: currentTransaction.amount,
notes: currentTransaction.notes
})
}).then((res) => {
loading = false;
invalidateAll();
if (res.ok) {
// Optionally, you can refresh the UI or show a success message
addToast('Transaction added to budget', 'success');
console.log('Transaction added to budget successfully');
} else {
addToast('Failed to add transaction to budget', 'error');
console.error('Failed to add transaction to budget');
}
});
} else {
console.error('No budget selected');
}
}}>Add to Budget</button
>
</fieldset>
</div>
<form method="dialog" class="modal-backdrop">
<button>close</button>
</form>
</dialog>
<TransactionList {budgets} {budgetTransactions} {transactions} />
<dialog id="settings_modal" class="modal">
<div class="modal-box">

View File

@ -0,0 +1,6 @@
import { getUnallocatedTransactions } from '$lib/db.js';
export async function GET({ url }) {
const transacations = await getUnallocatedTransactions();
return new Response(JSON.stringify(transacations));
}

View File

@ -0,0 +1,6 @@
import { getUnderallocatedTransactions } from '$lib/db.js';
export async function GET({ url }) {
const transacations = await getUnderallocatedTransactions();
return new Response(JSON.stringify(transacations));
}

View File

@ -1,10 +1,10 @@
import { error } from '@sveltejs/kit';
import { getBudget, getBudgetTransactions } from '$lib/db.js';
import { getBudget, getBudgetTransactionsByid } from '$lib/db.js';
export async function load({ params }) {
const { slug } = params;
console.log(`Loading transactions for budget: ${slug}`);
const transactions = await getBudgetTransactions(slug);
const transactions = await getBudgetTransactionsByid(slug);
const budget = await getBudget(slug);
return { transactions, slug, budget };