Making things nicer

This commit is contained in:
2025-08-01 10:18:29 -04:00
parent 416ef3bc37
commit 7ca5f67c1b
11 changed files with 300 additions and 258 deletions

View File

@ -4,7 +4,7 @@
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite dev --host",
"dev": "vite dev --host --port 3000",
"build": "vite build",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
@ -40,4 +40,4 @@
"puppeteer-extra-plugin-stealth": "^2.11.2",
"tailwindcss": "^4.1.11"
}
}
}

View File

@ -1,12 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
<head>
<meta charset="utf-8" />
<link rel="icon" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

234
src/lib/budgetlist.svelte Normal file
View File

@ -0,0 +1,234 @@
<script>
import { EditSymbol } from '$lib/editSymbol.svelte';
import { TrashBin } from '$lib/trashbin.svelte';
import { getContext } from 'svelte';
import { invalidate, invalidateAll } from '$app/navigation';
import { loadingModal } from '$lib/loadingModal.svelte';
let { transactions } = $props();
const addToast = getContext('addToast');
let newData = $state({
amount: 0,
notes: '',
name: '',
id: null
});
let toDelete = $state('');
let toDeleteName = $state('');
let loading = $state(false);
let newTransaction = $state({
name: '',
id: null
});
let searchString = $state('');
let debounceString = $state('');
let searchResults = $state([]);
let searching = $state(false);
async function search() {
if (searching) return; // Prevent multiple searches at the same time
searching = true;
const res = await fetch(`/api/transactions?c=100&p=${searchString}`);
const data = await res.json();
searchResults = data;
searching = false;
}
async function updateTransactionID() {
loading = true;
EditBudgetTransactionModal.close();
if (!newTransaction.id) {
addToast('Please select a transaction to update', 'warning');
return;
}
if (newTransaction.id == newData.id) {
addToast('No changes made to the transaction', 'info');
return;
}
const response = await fetch(`/api/budget/transaction/${newData.id}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
transactionId: newTransaction.id
})
});
if (response.ok) {
addToast('Transaction updated successfully', 'success');
invalidateAll();
} else {
addToast('Failed to update transaction', 'error');
}
loading = false;
}
async function saveTransaction() {
loading = true;
EditBudgetTransactionModal.close();
let res = await fetch(`/api/budget/${budget.id}/transaction`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
amount: $state.snapshot(newData.amount),
notes: $state.snapshot(newData.notes),
transactionId: $state.snapshot(newData.id)
})
});
loading = false;
console.log(res.ok);
if (res.ok) {
// Optionally, you can refresh the UI or show a success message
addToast('Transaction updated successfully', 'success');
invalidateAll();
} else {
console.error('Failed to update transaction');
addToast('Failed to update transaction', 'error');
}
}
function edit(transaction) {
newData.amount = transaction.budget_amount;
newData.name = transaction.description;
newData.notes = transaction.notes || '';
newData.id = transaction.budget_transaction_id;
searchString = transaction.notes || '';
search();
EditBudgetTransactionModal.showModal();
}
function deleteTransaction(transaction) {
toDelete = transaction.budget_transaction_id;
DeleteTransactionModal.showModal();
}
</script>
<ul class="list bg-base-100 rounded-box shadow-md">
{#each transactions as tras}
<li class="list-row">
<div>
{#if tras.id == null}
{#if tras.budget_name}
<div>{tras.budget_name}</div>
{:else}
<span class="badge badge-warning">Orphan</span>
{/if}
{:else}
<div>{tras?.description}</div>
<div class="text-xs uppercase font-semibold opacity-60">
{tras?.date?.toDateString()}
</div>
{/if}
</div>
<div class="text-center">{tras.notes}</div>
<div class="text-lg uppercase font-semibold text-right w-32">{tras?.budget_amount}</div>
<div>
<button class="btn btn-square btn-ghost" onclick={() => edit(tras)}
>{@render EditSymbol()}</button
>
</div>
<div>
<button class="btn btn-square btn-ghost" onclick={() => deleteTransaction(tras)}
>{@render TrashBin()}</button
>
</div>
</li>
{/each}
</ul>
<dialog id="EditBudgetTransactionModal" class="modal">
<div class="modal-box">
<h1>{newData.name}</h1>
<fieldset class="fieldset bg-base-200 border-base-300 rounded-box w-full border p-4">
<legend class="fieldset-legend">Reassign</legend>
<div class="join w-full">
<label class="input">
<svg class="h-[1em] opacity-50" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<g
stroke-linejoin="round"
stroke-linecap="round"
stroke-width="2.5"
fill="none"
stroke="currentColor"
>
<circle cx="11" cy="11" r="8"></circle>
<path d="m21 21-4.3-4.3"></path>
</g>
</svg>
<input type="search" required placeholder="Search" bind:value={searchString} />
</label>
<button class="btn btn-neutral join-item" onclick={() => search()} disabled={searching}
>Search</button
>
</div>
<div class="join w-full">
<select class="select" bind:value={newTransaction.id} disabled={searching}>
<option value="" disabled selected>Select a Transaction</option>
{#each searchResults as res}
<option value={res?.id}
>{res?.description} - {new Date(res?.date).toDateString()} - {res?.amount}}</option
>
{/each}
</select>
<button class="btn btn-neutral join-item" onclick={() => updateTransactionID()}
>Update</button
>
</div>
</fieldset>
<fieldset class="fieldset bg-base-200 border-base-300 rounded-box w-full border p-4">
<legend class="fieldset-legend">Edit</legend>
<label class="label">Amount</label>
<input
bind:value={newData.amount}
type="text"
placeholder="Amount"
class="input input-bordered w-full"
/>
<label class="label">Notes</label>
<textarea bind:value={newData.notes} class="textarea w-full" placeholder="Budget Notes"
></textarea>
<button onclick={() => saveTransaction()} class="btn btn-primary mt-4">Save</button>
</fieldset>
</div>
<form method="dialog" class="modal-backdrop">
<button>close</button>
</form>
</dialog>
<dialog id="DeleteTransactionModal" class="modal">
<div class="modal-box">
<p>Are you sure you want to delete</p>
<span>{toDelete}</span>
<button
class="btn btn-error mt-4"
onclick={async () => {
let res = await fetch(`/api/budget/${toDelete}/transaction/`, {
method: 'DELETE'
});
if (res.ok) {
console.log('Rule deleted successfully');
DeleteTransactionModal.close();
invalidateAll();
addToast('Transaction deleted successfully', 'success');
} else {
console.error('Failed to delete transaction');
addToast('Failed to delete transaction', 'error');
}
}}>Delete</button
>
</div>
<form method="dialog" class="modal-backdrop">
<button>close</button>
</form>
</dialog>
{#if loading}
{@render loadingModal()}
{/if}

View File

@ -568,6 +568,22 @@ export async function getUnderallocatedTransactions() {
return result;
}
export async function getOrphanedTransactions() {
const result = await db`
SELECT budget.name as budget_name,
budget_transaction.id as trans_id,
budget_transaction.budget_id as budget_id,
budget_transaction.transaction_id as transaction_id,
budget_transaction.amount as budget_amount,
budget_transaction.notes as notes
FROM budget_transaction
LEFT JOIN budget on budget_id = budget.id
WHERE transaction_id IS NULL
`;
// 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)

View File

@ -8,7 +8,7 @@
let { transaction, close, budgets } = $props();
let id = $derived(transaction.id);
let description = $derived(transaction.description || 'No description');
let amount = $derived(transaction.amount || 0);
let amount = $derived(transaction.amount.toFixed(2) || 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);

View File

@ -21,6 +21,7 @@ export function isNeedCode() {
export function isRunning() {
return running;
}
const browser = await chromium.launchPersistentContext('context', {
channel: 'chrome',
headless: false,
@ -186,9 +187,9 @@ export async function pullData(amount = 100) {
state.push(`Checking for pending transactions for account ID: ${account.id}`);
let currentPend =
await db`SELECT id, amount, description, date from transaction where account_id = ${account.id} AND pending=true`;
await db`SELECT id, amount, description, date from transaction where account_id = ${account.id} AND pending=true`;
for (const pend of currentPend) {
const found = transactions.find((t) => `${t.postedDate}:${t.amount}` === pend.id && t.transactionType == "Memo");
const found = transactions.find((t) => `${t.description}:${t.amount}` === pend.id && t.transactionType == "Memo");
if (found && found.transactionType != "Memo")
{state.push(
@ -200,7 +201,7 @@ export async function pullData(amount = 100) {
{
state.push(`Orphaning no longer pending budget transaction with no new parent`);
await db`UPDATE budget_transaction SET transaction_id = null WHERE transaction_id = ${pend.id}`;
await db`UPDATE budget_transaction SET transaction_id = null, notes = notes || ${pend.description} WHERE transaction_id = ${pend.id}`;
state.push(`Removing pending transaction: ${pend.id}`);
await db`DELETE FROM transaction WHERE id = ${pend.id}`;
}
@ -218,7 +219,7 @@ export async function pullData(amount = 100) {
const accountId = transaction.accountId;
const id = pending
? `${transaction.postedDate}:${transaction.amount}`
? `${transaction.description}:${transaction.amount}`
: transaction.hostTranNumber;
await db`INSERT INTO transaction (
@ -251,7 +252,7 @@ export async function pullData(amount = 100) {
pending = EXCLUDED.pending`;
}
}
/*
state.push('Orphaning transactions');
const orphaned = await db`SELECT bt.id as id
@ -262,7 +263,7 @@ export async function pullData(amount = 100) {
state.push(`Orphaning transaction: ${orphan.id}`);
await db`UPDATE budget_transaction set transaction_id = null where id = ${orphan.id}`;
}
*/
state.push('Done');
} catch (error) {
console.error('Error in pullData:', error);

View File

@ -91,7 +91,7 @@
<li><div class="divider"></div></li>
<li>
<button
class="btn btn-primary btn-square w-100% grow"
class="btn btn-primary btn-square w-70 grow"
onclick={() =>
authClient.signOut({
fetchOptions: {

View File

@ -4,7 +4,8 @@ import {
getUnderallocatedTransactions,
getTotal,
getBudgets,
getAllBudgetTransactions
getAllBudgetTransactions,
getOrphanedTransactions
} from '$lib/db';
@ -15,6 +16,7 @@ export async function load({ params }) {
const budgets = await getBudgets();
const budgetTransactions = await getAllBudgetTransactions();
const last30DaysTransactionsSums = await getLast30DaysTransactionsSums();
const orphanedTransactions = await getOrphanedTransactions();
return { unallocatedTrans, underAllocatedTrans, total, budgets, budgetTransactions, last30DaysTransactionsSums };
return { unallocatedTrans, underAllocatedTrans, total, budgets, budgetTransactions, last30DaysTransactionsSums, orphanedTransactions };
}

View File

@ -1,5 +1,6 @@
<script>
import TransactionList from '$lib/transactionList.svelte';
import Budgetlist from '$lib/budgetlist.svelte';
import { echarts } from '$lib/echarts';
let { data } = $props();
@ -9,23 +10,31 @@
let budgets = $derived(data.budgets);
let budgetTransactions = $derived(data.budgetTransactions);
let last30days = $derived(data.last30DaysTransactionsSums.reverse());
let orphanedTransactions = $derived(data.orphanedTransactions);
</script>
<span class="font-sans text-xl"
<span class="font-sans text-3xl p-4"
>Total Net Worth: <span class="{total > 0 ? 'bg-green-500' : 'bg-red-500'} pl-2 pr-2 rounded-lg"
>${total}</span
></span
>
{#each budgets as budget}
<a
href="/budget/{budget.id}"
class="block p-4 mb-2 bg-base-200 rounded-lg hover:bg-base-300 transition duration-200"
><div class="flex flex-row justify-between items-center text-2xl">
<div>{budget.name}</div>
<div>{budget.sum}</div>
</div></a
>
{/each}
<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>
<div class="text-xl divider">Orphans</div>
<Budgetlist transactions={orphanedTransactions} />

View File

@ -4,100 +4,12 @@
import { loadingModal } from '$lib/loadingModal.svelte';
import { getContext } from 'svelte';
import { invalidate, invalidateAll } from '$app/navigation';
import BudgetList from '$lib/budgetlist.svelte';
let { data } = $props();
const addToast = getContext('addToast');
let budget = $derived(data.budget);
let transactions = $derived(data.transactions.transactions || []);
let newData = $state({
amount: 0,
notes: '',
name: '',
id: null
});
let toDelete = $state('');
let toDeleteName = $state('');
let loading = $state(false);
let newTransaction = $state({
name: '',
id: null
});
let searchString = $state('');
let debounceString = $state('');
let searchResults = $state([]);
let searching = $state(false);
async function search() {
if (searching) return; // Prevent multiple searches at the same time
searching = true;
const res = await fetch(`/api/transactions?c=100&p=${searchString}`);
const data = await res.json();
searchResults = data;
searching = false;
}
async function updateTransactionID() {
loading = true;
EditBudgetTransactionModal.close();
if (!newTransaction.id) {
addToast('Please select a transaction to update', 'warning');
return;
}
if (newTransaction.id == newData.id) {
addToast('No changes made to the transaction', 'info');
return;
}
const response = await fetch(`/api/budget/transaction/${newData.id}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
transactionId: newTransaction.id
})
});
if (response.ok) {
addToast('Transaction updated successfully', 'success');
invalidateAll();
} else {
addToast('Failed to update transaction', 'error');
}
loading = false;
}
async function saveTransaction() {
loading = true;
EditBudgetTransactionModal.close();
let res = await fetch(`/api/budget/${budget.id}/transaction`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
amount: $state.snapshot(newData.amount),
notes: $state.snapshot(newData.notes),
transactionId: $state.snapshot(newData.id)
})
});
loading = false;
console.log(res.ok);
if (res.ok) {
// Optionally, you can refresh the UI or show a success message
addToast('Transaction updated successfully', 'success');
invalidateAll();
} else {
console.error('Failed to update transaction');
addToast('Failed to update transaction', 'error');
}
}
function edit(transaction) {
newData.amount = transaction.budget_amount;
newData.name = transaction.description;
newData.notes = transaction.notes || '';
newData.id = transaction.budget_transaction_id;
EditBudgetTransactionModal.showModal();
}
async function restoreBudget() {
let res = await fetch(`/api/budget/${budget.id}`, {
@ -108,11 +20,6 @@
body: JSON.stringify({ delete: false })
});
}
function deleteTransaction(transaction) {
toDelete = transaction.budget_transaction_id;
DeleteTransactionModal.showModal();
}
</script>
{#if budget.delete}
@ -139,98 +46,7 @@
<div class="w-64 grow">{budget.amount}</div>
</div>
<ul class="list bg-base-100 rounded-box shadow-md">
{#each transactions as tras}
<li class="list-row">
<div>
{#if tras.id == null}
<span class="badge badge-warning">Orphan</span>
{:else}
<div>{tras?.description}</div>
<div class="text-xs uppercase font-semibold opacity-60">
{tras?.date?.toDateString()}
</div>
{/if}
</div>
<div class="text-center">{tras.notes}</div>
<div class="text-lg uppercase font-semibold text-right w-32">{tras?.budget_amount}</div>
<div>
<button class="btn btn-square btn-ghost" onclick={() => edit(tras)}
>{@render EditSymbol()}</button
>
</div>
<div>
<button class="btn btn-square btn-ghost" onclick={() => deleteTransaction(tras)}
>{@render TrashBin()}</button
>
</div>
</li>
{/each}
</ul>
<dialog id="EditBudgetTransactionModal" class="modal">
<div class="modal-box">
<h1>{newData.name}</h1>
<fieldset class="fieldset bg-base-200 border-base-300 rounded-box w-xs border p-4">
<legend class="fieldset-legend">Reassign</legend>
<div class="join">
<label class="input">
<svg class="h-[1em] opacity-50" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<g
stroke-linejoin="round"
stroke-linecap="round"
stroke-width="2.5"
fill="none"
stroke="currentColor"
>
<circle cx="11" cy="11" r="8"></circle>
<path d="m21 21-4.3-4.3"></path>
</g>
</svg>
<input type="search" required placeholder="Search" bind:value={searchString} />
</label>
<button class="btn btn-neutral join-item" onclick={() => search()} disabled={searching}
>Search</button
>
</div>
<div class="join">
<select class="select" bind:value={newTransaction.id} disabled={searching}>
<option value="" disabled selected>Select a Transaction</option>
{#each searchResults as res}
<option value={res?.id}
>{res?.description} - {new Date(res?.date).toDateString()} - {res?.amount}}</option
>
{/each}
</select>
<button class="btn btn-neutral join-item" onclick={() => updateTransactionID()}
>Update</button
>
</div>
</fieldset>
<fieldset class="fieldset bg-base-200 border-base-300 rounded-box w-xs border p-4">
<legend class="fieldset-legend">Edit</legend>
<label class="label">Amount</label>
<input
bind:value={newData.amount}
type="text"
placeholder="Amount"
class="input input-bordered w-full max-w-xs"
/>
<label class="label">Notes</label>
<textarea bind:value={newData.notes} class="textarea w-100" placeholder="Budget Notes"
></textarea>
<button onclick={() => saveTransaction()} class="btn btn-primary mt-4">Save</button>
</fieldset>
</div>
<form method="dialog" class="modal-backdrop">
<button>close</button>
</form>
</dialog>
<BudgetList {transactions} />
<dialog id="RestoreModal" class="modal">
<div class="modal-box">
@ -244,46 +60,3 @@
<button>close</button>
</form>
</dialog>
<dialog id="DeleteTransactionModal" class="modal">
<div class="modal-box">
<p>Are you sure you want to delete</p>
<span>{toDelete}</span>
<p>Type it in the box to confirm</p>
<input
bind:value={toDeleteName}
type="text"
placeholder="Type the name to confirm"
class="input input-bordered w-full max-w-xs"
/>
<button
class="btn btn-error mt-4"
onclick={async () => {
if (toDelete == toDeleteName) {
let res = await fetch(`/api/budget/${toDelete}/transaction/`, {
method: 'DELETE'
});
if (res.ok) {
console.log('Rule deleted successfully');
DeleteTransactionModal.close();
invalidateAll();
addToast('Transaction deleted successfully', 'success');
} else {
console.error('Failed to delete transaction');
addToast('Failed to delete transaction', 'error');
}
} else {
console.error('Name does not match');
addToast('Name does not match', 'warning');
}
}}>Delete</button
>
</div>
<form method="dialog" class="modal-backdrop">
<button>close</button>
</form>
</dialog>
{#if loading}
{@render loadingModal()}
{/if}

View File

@ -5,4 +5,8 @@ import { SvelteKitPWA } from '@vite-pwa/sveltekit';
export default defineConfig({
plugins: [tailwindcss(), sveltekit(), SvelteKitPWA({registerType: 'autoUpdate', pwaAssets: { config: 'pwa-assets.config.js' }})],
server: {
allowedHosts: ['budget.caseytimm.com']
}
});