Compare commits

...

10 Commits

Author SHA1 Message Date
7ca5f67c1b Making things nicer 2025-08-01 10:18:29 -04:00
416ef3bc37 fixes and such 2025-07-31 19:22:59 -04:00
82dbefa565 added auth 2025-07-27 12:12:22 -04:00
1d2e90a183 Fixed thinking we are on login screen when really loading dashboard 2025-07-27 08:40:38 -04:00
ded6489edf better pending handling 2025-07-26 09:15:29 -04:00
cf6fed50d3 some better logging here 2025-07-26 09:12:18 -04:00
9ec5fbf75a Revert "Added logging to united"
This reverts commit d9b1a8430d.
2025-07-26 09:10:24 -04:00
d9b1a8430d Added logging to united 2025-07-26 08:34:40 -04:00
f4103953f6 some more updates 2025-07-25 17:34:32 -04:00
63a6694507 fixed budgets glictch 2025-07-25 16:42:02 -04:00
51 changed files with 1728 additions and 340 deletions

View File

@ -0,0 +1,7 @@
create table "user" ("id" text not null primary key, "name" text not null, "email" text not null unique, "emailVerified" boolean not null, "image" text, "createdAt" timestamp not null, "updatedAt" timestamp not null);
create table "session" ("id" text not null primary key, "expiresAt" timestamp not null, "token" text not null unique, "createdAt" timestamp not null, "updatedAt" timestamp not null, "ipAddress" text, "userAgent" text, "userId" text not null references "user" ("id"));
create table "auth_accounts" ("id" text not null primary key, "accountId" text not null, "providerId" text not null, "userId" text not null references "user" ("id"), "accessToken" text, "refreshToken" text, "idToken" text, "accessTokenExpiresAt" timestamp, "refreshTokenExpiresAt" timestamp, "scope" text, "password" text, "createdAt" timestamp not null, "updatedAt" timestamp not null);
create table "verification" ("id" text not null primary key, "identifier" text not null, "value" text not null, "expiresAt" timestamp not null, "createdAt" timestamp, "updatedAt" timestamp);

1176
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -4,12 +4,13 @@
"version": "0.0.1", "version": "0.0.1",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite dev --host", "dev": "vite dev --host --port 3000",
"build": "vite build", "build": "vite build",
"preview": "vite preview", "preview": "vite preview",
"prepare": "svelte-kit sync || echo ''", "prepare": "svelte-kit sync || echo ''",
"format": "prettier --write .", "format": "prettier --write .",
"lint": "prettier --check ." "lint": "prettier --check .",
"generate-pwa-assets": "pwa-assets-generator"
}, },
"devDependencies": { "devDependencies": {
"@sveltejs/adapter-auto": "^6.0.0", "@sveltejs/adapter-auto": "^6.0.0",
@ -17,6 +18,7 @@
"@sveltejs/kit": "^2.16.0", "@sveltejs/kit": "^2.16.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0", "@sveltejs/vite-plugin-svelte": "^5.0.0",
"@tailwindcss/typography": "^0.5.16", "@tailwindcss/typography": "^0.5.16",
"@vite-pwa/assets-generator": "^1.0.0",
"@vite-pwa/sveltekit": "^1.0.0", "@vite-pwa/sveltekit": "^1.0.0",
"daisyui": "^5.0.43", "daisyui": "^5.0.43",
"prettier": "^3.4.2", "prettier": "^3.4.2",
@ -27,9 +29,11 @@
"dependencies": { "dependencies": {
"@auth/sveltekit": "^1.10.0", "@auth/sveltekit": "^1.10.0",
"@tailwindcss/vite": "^4.1.11", "@tailwindcss/vite": "^4.1.11",
"better-auth": "^1.3.4",
"cron": "^4.3.2", "cron": "^4.3.2",
"echarts": "^5.6.0", "echarts": "^5.6.0",
"patchright": "^1.52.5", "patchright": "^1.52.5",
"pg": "^8.16.3",
"postgres": "^3.4.7", "postgres": "^3.4.7",
"puppeteer": "^24.14.0", "puppeteer": "^24.14.0",
"puppeteer-extra": "^3.3.6", "puppeteer-extra": "^3.3.6",

8
pwa-assets.config.js Normal file
View File

@ -0,0 +1,8 @@
import { defineConfig, minimal2023Preset as preset } from '@vite-pwa/assets-generator/config'
export default defineConfig({
preset,
images: [
'static/favicon.svg',
]
})

View File

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

View File

@ -1,5 +1,9 @@
import { CronJob } from 'cron'; import { CronJob } from 'cron';
import { pullData } from './lib/united.js'; import { pullData } from './lib/united.js';
import { auth } from '$lib/auth'; // path to your auth file
import { svelteKitHandler } from 'better-auth/svelte-kit';
import { building } from '$app/environment';
import { redirect } from '@sveltejs/kit';
const job = new CronJob( const job = new CronJob(
'0,30 * * * *', // cronTime '0,30 * * * *', // cronTime
@ -10,3 +14,23 @@ const job = new CronJob(
true, // start true, // start
'America/Detroit' // timeZone 'America/Detroit' // timeZone
); );
export async function handle({ event, resolve }) {
console.log('Handling request:', event.request.method, event.url.pathname);
if (event.route.id?.startsWith('/(protected)/') && !event.url.pathname.startsWith('/api/auth')) {
const session = await auth.api.getSession({
headers: event.request.headers
});
if (session) {
event.locals.session = session?.session;
event.locals.user = session?.user;
return svelteKitHandler({ event, resolve, auth });
} else {
redirect(307, '/sign-in');
}
} else {
console.log('Not a protected route or API auth request:', event.url.pathname);
return svelteKitHandler({ event, resolve, auth });
}
}

7
src/lib/auth-client.ts Normal file
View File

@ -0,0 +1,7 @@
import { createAuthClient } from "better-auth/svelte"
export const authClient = createAuthClient({
/** The base URL of the server (optional if you're using the same domain) */
baseURL: "https://budget.caseytimm.com",
})
export const { signIn, signUp, useSession } = authClient;

17
src/lib/auth.ts Normal file
View File

@ -0,0 +1,17 @@
import { betterAuth } from "better-auth";
import { Pool } from "pg";
import { sveltekitCookies } from "better-auth/svelte-kit";
import { getRequestEvent } from "$app/server";
export const auth = betterAuth({
database: new Pool({
connectionString: 'postgresql://budget:budget@sql.caseytimm.com:5432/budget',
}),
account: {
modelName: 'auth_accounts',
},
emailAndPassword: {
enabled: true,
},
plugins: [sveltekitCookies(getRequestEvent)],
})

View File

@ -1,14 +1,12 @@
<script> <script>
import { EditSymbol } from '$lib/editSymbol.svelte'; import { EditSymbol } from '$lib/editSymbol.svelte';
import { TrashBin } from '$lib/trashbin.svelte'; import { TrashBin } from '$lib/trashbin.svelte';
import { loadingModal } from '$lib/loadingModal.svelte';
import { getContext } from 'svelte'; import { getContext } from 'svelte';
import { invalidate, invalidateAll } from '$app/navigation'; import { invalidate, invalidateAll } from '$app/navigation';
import { loadingModal } from '$lib/loadingModal.svelte';
let { data } = $props(); let { transactions } = $props();
const addToast = getContext('addToast'); const addToast = getContext('addToast');
let budget = $derived(data.budget);
let transactions = $derived(data.transactions.transactions || []);
let newData = $state({ let newData = $state({
amount: 0, amount: 0,
notes: '', notes: '',
@ -96,55 +94,27 @@
newData.name = transaction.description; newData.name = transaction.description;
newData.notes = transaction.notes || ''; newData.notes = transaction.notes || '';
newData.id = transaction.budget_transaction_id; newData.id = transaction.budget_transaction_id;
searchString = transaction.notes || '';
search();
EditBudgetTransactionModal.showModal(); EditBudgetTransactionModal.showModal();
} }
async function restoreBudget() {
let res = await fetch(`/api/budget/${budget.id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ delete: false })
});
}
function deleteTransaction(transaction) { function deleteTransaction(transaction) {
toDelete = transaction.budget_transaction_id; toDelete = transaction.budget_transaction_id;
DeleteTransactionModal.showModal(); DeleteTransactionModal.showModal();
} }
</script> </script>
{#if budget.delete}
<div role="alert" class="alert alert-error" onclick={() => RestoreModal.showModal()}>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6 shrink-0 stroke-current"
fill="none"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span>This budget has been deleted</span>
</div>
{/if}
<div class="flex mb-4">
<div class="w-32 flex-none justify-bottom"><h1 class="text-2xl font-bold">{budget.name}</h1></div>
<div class="w-64 grow">{budget.amount}</div>
</div>
<ul class="list bg-base-100 rounded-box shadow-md"> <ul class="list bg-base-100 rounded-box shadow-md">
{#each transactions as tras} {#each transactions as tras}
<li class="list-row"> <li class="list-row">
<div> <div>
{#if tras.id == null} {#if tras.id == null}
<span class="badge badge-warning">Orphan</span> {#if tras.budget_name}
<div>{tras.budget_name}</div>
{:else}
<span class="badge badge-warning">Orphan</span>
{/if}
{:else} {:else}
<div>{tras?.description}</div> <div>{tras?.description}</div>
<div class="text-xs uppercase font-semibold opacity-60"> <div class="text-xs uppercase font-semibold opacity-60">
@ -173,9 +143,9 @@
<dialog id="EditBudgetTransactionModal" class="modal"> <dialog id="EditBudgetTransactionModal" class="modal">
<div class="modal-box"> <div class="modal-box">
<h1>{newData.name}</h1> <h1>{newData.name}</h1>
<fieldset class="fieldset bg-base-200 border-base-300 rounded-box w-xs border p-4"> <fieldset class="fieldset bg-base-200 border-base-300 rounded-box w-full border p-4">
<legend class="fieldset-legend">Reassign</legend> <legend class="fieldset-legend">Reassign</legend>
<div class="join"> <div class="join w-full">
<label class="input"> <label class="input">
<svg class="h-[1em] opacity-50" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"> <svg class="h-[1em] opacity-50" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<g <g
@ -195,7 +165,7 @@
>Search</button >Search</button
> >
</div> </div>
<div class="join"> <div class="join w-full">
<select class="select" bind:value={newTransaction.id} disabled={searching}> <select class="select" bind:value={newTransaction.id} disabled={searching}>
<option value="" disabled selected>Select a Transaction</option> <option value="" disabled selected>Select a Transaction</option>
{#each searchResults as res} {#each searchResults as res}
@ -209,7 +179,7 @@
> >
</div> </div>
</fieldset> </fieldset>
<fieldset class="fieldset bg-base-200 border-base-300 rounded-box w-xs border p-4"> <fieldset class="fieldset bg-base-200 border-base-300 rounded-box w-full border p-4">
<legend class="fieldset-legend">Edit</legend> <legend class="fieldset-legend">Edit</legend>
<label class="label">Amount</label> <label class="label">Amount</label>
@ -217,11 +187,11 @@
bind:value={newData.amount} bind:value={newData.amount}
type="text" type="text"
placeholder="Amount" placeholder="Amount"
class="input input-bordered w-full max-w-xs" class="input input-bordered w-full"
/> />
<label class="label">Notes</label> <label class="label">Notes</label>
<textarea bind:value={newData.notes} class="textarea w-100" placeholder="Budget Notes" <textarea bind:value={newData.notes} class="textarea w-full" placeholder="Budget Notes"
></textarea> ></textarea>
<button onclick={() => saveTransaction()} class="btn btn-primary mt-4">Save</button> <button onclick={() => saveTransaction()} class="btn btn-primary mt-4">Save</button>
@ -232,49 +202,24 @@
</form> </form>
</dialog> </dialog>
<dialog id="RestoreModal" class="modal">
<div class="modal-box">
<fieldset class="fieldset bg-base-200 border-base-300 rounded-box w-xs border p-4">
<legend class="fieldset-legend">Restore?</legend>
<button onclick={() => restoreBudget()} class="btn btn-primary mt-4">Restore</button>
</fieldset>
</div>
<form method="dialog" class="modal-backdrop">
<button>close</button>
</form>
</dialog>
<dialog id="DeleteTransactionModal" class="modal"> <dialog id="DeleteTransactionModal" class="modal">
<div class="modal-box"> <div class="modal-box">
<p>Are you sure you want to delete</p> <p>Are you sure you want to delete</p>
<span>{toDelete}</span> <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 <button
class="btn btn-error mt-4" class="btn btn-error mt-4"
onclick={async () => { onclick={async () => {
if (toDelete == toDeleteName) { let res = await fetch(`/api/budget/${toDelete}/transaction/`, {
let res = await fetch(`/api/budget/${toDelete}/transaction/`, { method: 'DELETE'
method: 'DELETE' });
}); if (res.ok) {
if (res.ok) { console.log('Rule deleted successfully');
console.log('Rule deleted successfully'); DeleteTransactionModal.close();
DeleteTransactionModal.close(); invalidateAll();
invalidateAll(); addToast('Transaction deleted successfully', 'success');
addToast('Transaction deleted successfully', 'success');
} else {
console.error('Failed to delete transaction');
addToast('Failed to delete transaction', 'error');
}
} else { } else {
console.error('Name does not match'); console.error('Failed to delete transaction');
addToast('Name does not match', 'warning'); addToast('Failed to delete transaction', 'error');
} }
}}>Delete</button }}>Delete</button
> >

View File

@ -138,24 +138,15 @@ export async function deleteBudgetTransaction(id) {
} }
export async function addBudgetTransaction(budgetId, transactionId, amount, notes, ruleId = null) { export async function addBudgetTransaction(budgetId, transactionId, amount, notes, ruleId = null) {
const existingTransactions = await db`
select amount from budget_transaction const exsisting = await db`
where transaction_id = ${transactionId} select id from budget_transaction
where budget_id = ${budgetId} and transaction_id = ${transactionId}
`; `;
const realTransactionAmount = await db` if (exsisting.length > 0) {
select amount from transaction // If the transaction already exists in the budget, update it
where id = ${transactionId} return updateBudgetTransaction(exsisting[0].id, amount, notes);
`; }
if (existingTransactions.length > 0) {
if (
existingTransactions.reduce((acc, curr) => acc + curr.amount, 0) + amount >
realTransactionAmount
) {
return -1;
}
}
// Add a transaction to a budget // Add a transaction to a budget
const result = await db` const result = await db`
insert into budget_transaction (budget_id, transaction_id, amount, notes, rule_id) insert into budget_transaction (budget_id, transaction_id, amount, notes, rule_id)
@ -577,6 +568,22 @@ export async function getUnderallocatedTransactions() {
return result; 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() { export async function getLast30DaysTransactionsSums() {
let result = await db` let result = await db`
SELECT cast(date as date) as date, SUM(amount) SELECT cast(date as date) as date, SUM(amount)

View File

@ -1,20 +1,30 @@
<script> <script>
import { invalidateAll } from '$app/navigation'; import { invalidateAll } from '$app/navigation';
let { transaction, close, budgets } = $props();
import { getContext } from 'svelte'; import { getContext } from 'svelte';
import { EditSymbol } from '$lib/editSymbol.svelte';
import { TrashBin } from '$lib/trashbin.svelte';
const addToast = getContext('addToast'); const addToast = getContext('addToast');
let { transaction, close, budgets } = $props();
let id = $derived(transaction.id); let id = $derived(transaction.id);
let description = $derived(transaction.description || 'No description'); 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 date = $derived(new Date(transaction.date || Date.now()));
let out_of_budget = $derived(transaction.out_of_budget || false); let out_of_budget = $derived(transaction.out_of_budget || false);
let budget_id = $derived(transaction.budget_id || null); let budget_id = $derived(transaction.budget_id || null);
let loading = $state(false); let loading = $state(false);
let deleting = $state(false);
let deletingText = $state('');
let deletebt = $state(null);
let notes = $state(transaction.notes || ''); let notes = $state(transaction.notes || '');
function editBudget(budgetTransaction) {
budget_id = budgetTransaction.budget_id;
amount = budgetTransaction.amount;
notes = budgetTransaction.notes || '';
}
async function saveNotes() { async function saveNotes() {
loading = true; loading = true;
let res = fetch(`/transcation/${id}`, { let res = fetch(`/transcation/${id}`, {
@ -38,6 +48,61 @@
loading = false; loading = false;
close(); close();
} }
async function saveBudget() {
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();
}
async function deleteBudgetTransaction(bt) {
deleting = true;
deletingText = `${bt.budget_name}: ${bt.amount}`;
deletebt = bt;
}
async function sendDeletebt() {
loading = true;
if (deletebt) {
let res = await fetch(`/api/budget/${deletebt.id}/transaction`, {
method: 'DELETE'
});
if (res.ok) {
addToast('Budget transaction deleted successfully', 'success');
invalidateAll();
} else {
addToast('Failed to delete budget transaction', 'error');
}
}
deleting = false;
loading = false;
close();
}
</script> </script>
<dialog id="transactionEditModal" class="modal modal-open modal-top}"> <dialog id="transactionEditModal" class="modal modal-open modal-top}">
@ -65,60 +130,56 @@
Out of Budgets Out of Budgets
</label> </label>
<button class="btn btn-neutral" onclick={() => saveNotes()}>Save</button> <button class="btn btn-neutral" onclick={() => saveNotes()}>Save Transaction</button>
<legend class="fieldset-legend">Add to budget</legend> <legend class="fieldset-legend">Current Budgets</legend>
<select bind:value={budget_id} class="select">
<option disabled selected>Pick a budget</option> <div class="flex flex-col">
{#each budgets as budget} {#each transaction.budgetTransactions as budgetTransaction}
<option value={budget.id}>{budget.name} - {budget.sum}</option> <div class="flex justify-between">
<span class="text-lg"
>{budgetTransaction.budget_name}: {budgetTransaction.amount}</span
>
<div class="grow justify-end flex">
<button
class="btn btn-square btn-ghost"
onclick={() => editBudget(budgetTransaction)}
>
{@render EditSymbol()}
</button>
</div>
<div>
<button
class="btn btn-square btn-ghost"
onclick={() => deleteBudgetTransaction(budgetTransaction)}
>
{@render TrashBin()}
</button>
</div>
</div>
{/each} {/each}
</select> </div>
<legend class="fieldset-legend">Amount</legend>
<input {#if deleting}
bind:value={amount} <legend class="fieldset-legend">Delete Budget Transaction</legend>
type="number" <span class="text-xl">Deleting budget transaction - {deletingText}</span>
class="input validator" <span class="text-xl">Are you sure?</span>
required <button class="text-xl btn btn-success" onclick={() => (deleting = false)}>Cancel</button>
placeholder="Amount" <button class="text-xl btn btn-error" onclick={() => sendDeletebt()}>Delete</button>
title="Amount" {:else}
/> <legend class="fieldset-legend">Add to budget</legend>
<legend class="fieldset-legend">Notes</legend> <select bind:value={budget_id} class="select">
<textarea bind:value={notes} class="textarea w-100"></textarea> <option disabled selected>Pick a budget</option>
<p class="validator-hint">Must a sensible number</p> {#each budgets as budget}
<button <option value={budget.id}>{budget.name} - {budget.sum}</option>
class="btn btn-primary" {/each}
onclick={() => { </select>
if (budget_id) { <legend class="fieldset-legend">Amount</legend>
loading = true; <input bind:value={amount} type="number" required placeholder="Amount" title="Amount" />
fetch(`/api/budget/${budget_id}/transaction`, { <legend class="fieldset-legend">Notes</legend>
method: 'POST', <textarea bind:value={notes} class="textarea w-100"></textarea>
headers: { <button class="btn btn-primary" onclick={() => saveBudget()}>Save Budget</button>
'Content-Type': 'application/json' {/if}
},
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> </fieldset>
{/if} {/if}
</div> </div>

26
src/lib/login.svelte Normal file
View File

@ -0,0 +1,26 @@
<script>
import { signIn, signUp } from '$lib/auth-client';
let email = $state('');
let password = $state('');
async function handleLogin() {
let res = await signIn.email({ email, password });
console.log('Login response:', res);
}
</script>
<form>
<div class="flex justify-center items-center h-screen">
<fieldset class="fieldset bg-base-200 border-base-300 rounded-box w-xs border p-4">
<legend class="fieldset-legend">Login</legend>
<label class="label">Username</label>
<input type="email" class="input" placeholder="Username" bind:value={email} />
<label class="label">Password</label>
<input type="password" class="input" placeholder="Password" bind:value={password} />
<button class="btn btn-neutral mt-4" onclick={() => handleLogin()}>Login</button>
</fieldset>
</div>
</form>

View File

@ -5,9 +5,10 @@
let { transactions, budgetTransactions, budgets } = $props(); let { transactions, budgetTransactions, budgets } = $props();
let editing = $state(false); let editing = $state(false);
function editNotes(transaction, remaining) { function editNotes(transaction, remaining, budgetTransactions) {
currentTransaction = transaction; currentTransaction = transaction;
currentTransaction.amount = remaining; currentTransaction.amount = remaining;
currentTransaction.budgetTransactions = budgetTransactions;
editing = true; editing = true;
} }
</script> </script>
@ -65,7 +66,7 @@
<div class=""> <div class="">
<button <button
class="btn btn-square btn-ghost" class="btn btn-square btn-ghost"
onclick={() => editNotes(transaction, remaining)} onclick={() => editNotes(transaction, remaining, applicableBudgets)}
> >
{@render EditSymbol()} {@render EditSymbol()}
</button> </button>
@ -75,6 +76,6 @@
{/each} {/each}
{#if editing} {#if editing}
<EditTransaction transaction={currentTransaction} close={() => (editing = false)} budgets /> <EditTransaction transaction={currentTransaction} close={() => (editing = false)} {budgets} />
{/if} {/if}
</div> </div>

View File

@ -21,6 +21,7 @@ export function isNeedCode() {
export function isRunning() { export function isRunning() {
return running; return running;
} }
const browser = await chromium.launchPersistentContext('context', { const browser = await chromium.launchPersistentContext('context', {
channel: 'chrome', channel: 'chrome',
headless: false, headless: false,
@ -42,10 +43,15 @@ export async function pullData(amount = 100) {
state.push('Navigating to United FCU'); state.push('Navigating to United FCU');
await page.goto('https://online.unitedfcu.com/unitedfederalcredituniononline/uux.aspx#/login'); await page.goto('https://online.unitedfcu.com/unitedfederalcredituniononline/uux.aspx#/login');
state.push(`Current URL: ${page.url()}`); state.push(`Current URL: ${page.url()}`);
await new Promise((resolve) => setTimeout(resolve, 5000));
if (page.url().includes('interstitial')) { if (page.url().includes('interstitial')) {
await page.waitForLoadState(); await page.waitForLoadState();
} }
while (page.url().includes('interstitial')) {
await new Promise((resolve) => setTimeout(resolve, 1000));
await page.waitForLoadState();
}
if (!page.url().includes('dashboard')) { if (!page.url().includes('dashboard')) {
state.push('Logging in to United FCU'); state.push('Logging in to United FCU');
@ -81,8 +87,8 @@ export async function pullData(amount = 100) {
await new Promise((resolve) => setTimeout(resolve, 1000)); await new Promise((resolve) => setTimeout(resolve, 1000));
if (code != null) break; if (code != null) break;
} }
needCode = false;
if (code == null) { if (code == null) {
needCode = false;
state.push('Code not provided within 5 minutes'); state.push('Code not provided within 5 minutes');
throw new Error('Code not provided within 5 minutes'); throw new Error('Code not provided within 5 minutes');
} }
@ -183,21 +189,19 @@ export async function pullData(amount = 100) {
let currentPend = 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) { for (const pend of currentPend) {
const found = transactions.find((t) => t.transactionId === pend.id); const found = transactions.find((t) => `${t.description}:${t.amount}` === pend.id && t.transactionType == "Memo");
if (!found) {
const updated = transactions.find(
(t) => t.amount == pend.amount && new Date(t.postedDate) == pend.date
);
if (updated) {
state.push(
`I think I found an updated transaction: ${updated.statementDescription} ${updated.amount} for ${pend.description} ${pend.amount}`
);
await db`UPDATE budget_transaction SET transaction_id = ${updated.transactionId} WHERE transaction_id = ${pend.id}`; if (found && found.transactionType != "Memo")
} else { {state.push(
state.push(`Orphaning no longer pending budget transaction with no new parent`); `I think I found an updated transaction: ${found.statementDescription} ${found.amount} for ${pend.description} ${pend.amount}`
await db`UPDATE budget_transaction SET transaction_id = null WHERE transaction_id = ${pend.id}`; );
} await db`UPDATE budget_transaction SET transaction_id = ${found.transactionId} WHERE transaction_id = ${pend.id}`;
await db`DELETE FROM transaction WHERE id = ${pend.id}`;
} else if (!found)
{
state.push(`Orphaning no longer pending budget transaction with no new parent`);
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}`); state.push(`Removing pending transaction: ${pend.id}`);
await db`DELETE FROM transaction WHERE id = ${pend.id}`; await db`DELETE FROM transaction WHERE id = ${pend.id}`;
} }
@ -215,7 +219,7 @@ export async function pullData(amount = 100) {
const accountId = transaction.accountId; const accountId = transaction.accountId;
const id = pending const id = pending
? `${transaction.postedDate}:${transaction.amount}` ? `${transaction.description}:${transaction.amount}`
: transaction.hostTranNumber; : transaction.hostTranNumber;
await db`INSERT INTO transaction ( await db`INSERT INTO transaction (
@ -248,7 +252,7 @@ export async function pullData(amount = 100) {
pending = EXCLUDED.pending`; pending = EXCLUDED.pending`;
} }
} }
/*
state.push('Orphaning transactions'); state.push('Orphaning transactions');
const orphaned = await db`SELECT bt.id as id const orphaned = await db`SELECT bt.id as id
@ -259,7 +263,7 @@ export async function pullData(amount = 100) {
state.push(`Orphaning transaction: ${orphan.id}`); state.push(`Orphaning transaction: ${orphan.id}`);
await db`UPDATE budget_transaction set transaction_id = null where id = ${orphan.id}`; await db`UPDATE budget_transaction set transaction_id = null where id = ${orphan.id}`;
} }
*/
state.push('Done'); state.push('Done');
} catch (error) { } catch (error) {
console.error('Error in pullData:', error); console.error('Error in pullData:', error);

View File

@ -0,0 +1,118 @@
<script>
import '../../app.css';
import { setContext } from 'svelte';
import { pwaInfo } from 'virtual:pwa-info';
import { authClient } from '$lib/auth-client';
import Login from '$lib/login.svelte';
import { goto } from '$app/navigation';
const session = authClient.useSession();
console.log('PWA Info:', pwaInfo);
const webManifestLink = $state(pwaInfo ? pwaInfo.webManifest.linkTag : '');
$inspect(webManifestLink);
let { children, data } = $props();
let budgets = $derived(data.budgets);
let total = $derived(data.total);
let toast = $state([]);
function addToast(message, type = 'info') {
toast.push({ message, type });
setTimeout(() => {
toast.pop();
}, 3000);
}
setContext('addToast', addToast);
</script>
<svelte:head>
{@html webManifestLink}
</svelte:head>
{#if $session.data}
<div class="drawer lg:drawer-open">
<input id="my-drawer-2" type="checkbox" class="drawer-toggle" />
<div class="drawer-content flex flex-col m-5">
<div class="navbar bg-base-100 shadow-sm lg:hidden">
<div class="flex-none">
<label
for="my-drawer-2"
class="btn btn-primary drawer-button lg:hidden btn-square btn-ghost"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
class="inline-block h-5 w-5 stroke-current"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 6h16M4 12h16M4 18h16"
></path>
</svg>
</label>
</div>
<div class="flex-1">
<a class="btn btn-ghost text-xl" href="/">Timm Budget</a>
</div>
</div>
{@render children()}
</div>
<div class="drawer-side">
<label for="my-drawer-2" aria-label="close sidebar" class="drawer-overlay"></label>
<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">Timm Budget</span>
</a>
</li>
<li><div class="divider">Budgets</div></li>
{#each budgets as budget}
<li>
<a href={`/budget/${budget.id}`}>
{budget.name} ({budget.sum})
</a>
</li>
{/each}
<li><div class="divider">Accounts</div></li>
{#each data.accounts as account}
<li>
<a href={`/account/${account.id}`}>
{account.name} ({account.balance})
</a>
</li>
{/each}
<li><div class="divider"></div></li>
<li><a href="/rules">Rules</a></li>
<li><a href="/settings">Settings</a></li>
<li><a href="/united">United</a></li>
<li><div class="divider"></div></li>
<li>
<button
class="btn btn-primary btn-square w-70 grow"
onclick={() =>
authClient.signOut({
fetchOptions: {
onSuccess: () => goto('/')
}
})}
>
Logout</button
>
</li>
</ul>
</div>
</div>
<div class="toast toast-top toast-center">
{#each toast as t}
<div class="alert alert-{t.type}">
<span>{t.message}</span>
</div>
{/each}
</div>
{:else}
<Login></Login>
{/if}

View File

@ -4,7 +4,8 @@ import {
getUnderallocatedTransactions, getUnderallocatedTransactions,
getTotal, getTotal,
getBudgets, getBudgets,
getAllBudgetTransactions getAllBudgetTransactions,
getOrphanedTransactions
} from '$lib/db'; } from '$lib/db';
@ -15,6 +16,7 @@ export async function load({ params }) {
const budgets = await getBudgets(); const budgets = await getBudgets();
const budgetTransactions = await getAllBudgetTransactions(); const budgetTransactions = await getAllBudgetTransactions();
const last30DaysTransactionsSums = await getLast30DaysTransactionsSums(); 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> <script>
import TransactionList from '$lib/transactionList.svelte'; import TransactionList from '$lib/transactionList.svelte';
import Budgetlist from '$lib/budgetlist.svelte';
import { echarts } from '$lib/echarts'; import { echarts } from '$lib/echarts';
let { data } = $props(); let { data } = $props();
@ -8,44 +9,26 @@
let underAllocatedTrans = $derived(data.underAllocatedTrans); let underAllocatedTrans = $derived(data.underAllocatedTrans);
let budgets = $derived(data.budgets); let budgets = $derived(data.budgets);
let budgetTransactions = $derived(data.budgetTransactions); let budgetTransactions = $derived(data.budgetTransactions);
let last30days = $derived(data.last30DaysTransactionsSums); let last30days = $derived(data.last30DaysTransactionsSums.reverse());
let orphanedTransactions = $derived(data.orphanedTransactions);
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> </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 Net Worth: <span class="{total > 0 ? 'bg-green-500' : 'bg-red-500'} pl-2 pr-2 rounded-lg"
>${total}</span >${total}</span
></span ></span
> >
<div class="container" use:echarts={option} /> {#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> <div class="text-xl divider">Unallocated Transactions</div>
<TransactionList {budgets} {budgetTransactions} transactions={unallocatedTrans} /> <TransactionList {budgets} {budgetTransactions} transactions={unallocatedTrans} />
@ -53,9 +36,5 @@
<div class="text-xl divider">Underallocated Transactions</div> <div class="text-xl divider">Underallocated Transactions</div>
<TransactionList {budgets} {budgetTransactions} transactions={underAllocatedTrans} /> <TransactionList {budgets} {budgetTransactions} transactions={underAllocatedTrans} />
<style> <div class="text-xl divider">Orphans</div>
.container { <Budgetlist transactions={orphanedTransactions} />
height: 500px;
width: 100%;
}
</style>

View File

@ -22,7 +22,6 @@ export async function PATCH({ params, request }) {
const { amount, notes, transactionId } = body; const { amount, notes, transactionId } = body;
console.log({ slug, transactionId, amount }); console.log({ slug, transactionId, amount });
// Call the deleteBudget function from db.js (budgetId, transactionId, amount)
return updateBudgetTransaction(transactionId, amount, notes) return updateBudgetTransaction(transactionId, amount, notes)
.then(() => new Response(`Budget transaction updated successfully`, { status: 200 })) .then(() => new Response(`Budget transaction updated successfully`, { status: 200 }))
.catch( .catch(
@ -36,7 +35,6 @@ export async function DELETE({ params, request }) {
const { transactionId } = slug; const { transactionId } = slug;
console.log({ slug }); console.log({ slug });
// Call the deleteBudget function from db.js (budgetId, transactionId)
return deleteBudgetTransaction(slug) return deleteBudgetTransaction(slug)
.then(() => new Response(`Budget transaction deleted successfully`, { status: 200 })) .then(() => new Response(`Budget transaction deleted successfully`, { status: 200 }))
.catch( .catch(

View File

@ -0,0 +1,62 @@
<script>
import { EditSymbol } from '$lib/editSymbol.svelte';
import { TrashBin } from '$lib/trashbin.svelte';
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 || []);
async function restoreBudget() {
let res = await fetch(`/api/budget/${budget.id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ delete: false })
});
}
</script>
{#if budget.delete}
<div role="alert" class="alert alert-error" onclick={() => RestoreModal.showModal()}>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6 shrink-0 stroke-current"
fill="none"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span>This budget has been deleted</span>
</div>
{/if}
<div class="flex mb-4">
<div class="w-32 flex-none justify-bottom"><h1 class="text-2xl font-bold">{budget.name}</h1></div>
<div class="w-64 grow">{budget.amount}</div>
</div>
<BudgetList {transactions} />
<dialog id="RestoreModal" class="modal">
<div class="modal-box">
<fieldset class="fieldset bg-base-200 border-base-300 rounded-box w-xs border p-4">
<legend class="fieldset-legend">Restore?</legend>
<button onclick={() => restoreBudget()} class="btn btn-primary mt-4">Restore</button>
</fieldset>
</div>
<form method="dialog" class="modal-backdrop">
<button>close</button>
</form>
</dialog>

View File

@ -53,26 +53,28 @@
{#if needCode} {#if needCode}
<div class="alert alert-warning"> <div class="alert alert-warning">
<span>Need Code</span> <form>
<input <span>Need Code</span>
type="text" <input
bind:value={code} type="text"
placeholder="Enter code" bind:value={code}
class="input input-bordered w-full max-w-xs" placeholder="Enter code"
/> class="input input-bordered w-full max-w-xs"
<button />
class="btn btn-primary" <button
onclick={() => { class="btn btn-primary"
fetch('/api/united/code', { onclick={() => {
method: 'POST', fetch('/api/united/code', {
headers: { 'Content-Type': 'application/json' }, method: 'POST',
body: JSON.stringify({ code }) headers: { 'Content-Type': 'application/json' },
}).then(() => { body: JSON.stringify({ code })
code = ''; }).then(() => {
}); code = '';
}} });
> }}
Send Code >
</button> Send Code
</button>
</form>
</div> </div>
{/if} {/if}

View File

@ -1,87 +0,0 @@
<script>
import '../app.css';
import { setContext } from 'svelte';
let { children, data } = $props();
let budgets = $derived(data.budgets);
let total = $derived(data.total);
let toast = $state([]);
function addToast(message, type = 'info') {
toast.push({ message, type });
setTimeout(() => {
toast.pop();
}, 3000);
}
setContext('addToast', addToast);
</script>
<div class="drawer lg:drawer-open">
<input id="my-drawer-2" type="checkbox" class="drawer-toggle" />
<div class="drawer-content flex flex-col m-5">
<div class="navbar bg-base-100 shadow-sm lg:hidden">
<div class="flex-none">
<label
for="my-drawer-2"
class="btn btn-primary drawer-button lg:hidden btn-square btn-ghost"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
class="inline-block h-5 w-5 stroke-current"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 6h16M4 12h16M4 18h16"
></path>
</svg>
</label>
</div>
<div class="flex-1">
<a class="btn btn-ghost text-xl" href="/">Timm Budget</a>
</div>
</div>
{@render children()}
</div>
<div class="drawer-side">
<label for="my-drawer-2" aria-label="close sidebar" class="drawer-overlay"></label>
<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">Timm Budget</span>
</a>
</li>
<li><div class="divider">Budgets</div></li>
{#each budgets as budget}
<li>
<a href={`/budget/${budget.id}`}>
{budget.name} ({budget.sum})
</a>
</li>
{/each}
<li><div class="divider">Accounts</div></li>
{#each data.accounts as account}
<li>
<a href={`/account/${account.id}`}>
{account.name} ({account.balance})
</a>
</li>
{/each}
<li><div class="divider"></div></li>
<li><a href="/rules">Rules</a></li>
<li><a href="/settings">Settings</a></li>
<li><a href="/united">United</a></li>
</ul>
</div>
</div>
<div class="toast toast-top toast-center">
{#each toast as t}
<div class="alert alert-{t.type}">
<span>{t.message}</span>
</div>
{/each}
</div>

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 B

BIN
static/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 277 B

View File

@ -1 +1,21 @@
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg> <?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="144"
height="144"
viewBox="0 0 38.099999 38.1"
version="1.1"
id="svg1"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs1" />
<g
id="layer1">
<path
style="fill:#5599ff;stroke:none;stroke-width:1.05833"
d="M 14.365039,11.959191 H 1.9180591 L 2.0404884,3.5912594 35.259638,3.672879 35.300448,12 22.93509,12.04081 l 0.08162,23.163238 -8.753695,0.06121 z"
id="path1" />
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 606 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

BIN
static/pwa-192x192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 344 B

BIN
static/pwa-512x512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

BIN
static/pwa-64x64.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 217 B

View File

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