Compare commits

..

15 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
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
c12131e0c2 Moved to playwright 2025-07-24 20:17:11 -04:00
3791d4c9c9 Merge branch 'main' of https://git.caseytimm.com/cmtimm/budget 2025-07-23 21:11:35 -04:00
5e2aaca280 some fixes 2025-07-23 21:11:34 -04:00
55 changed files with 2228 additions and 577 deletions

2
.gitignore vendored
View File

@ -21,3 +21,5 @@ Thumbs.db
# Vite # Vite
vite.config.js.timestamp-* vite.config.js.timestamp-*
vite.config.ts.timestamp-* vite.config.ts.timestamp-*
context

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);

1249
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,11 +29,15 @@
"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",
"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",
"puppeteer-extra-plugin-stealth": "^2.11.2", "puppeteer-extra-plugin-stealth": "^2.11.2",
"tailwindcss": "^4.1.11" "tailwindcss": "^4.1.11"
} }
} }

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">
</html> <div style="display: contents">%sveltekit.body%</div>
</body>
</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

@ -86,7 +86,7 @@ export async function updateBudget(id, name, amount, notes) {
return result; return result;
} }
export async function getBudgetTransactions(id) { export async function getBudgetTransactionsByid(id) {
// Fetch all transactions associated with a specific budget // Fetch all transactions associated with a specific budget
try { try {
let transactions = await db` let transactions = await db`
@ -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)
@ -258,6 +249,22 @@ export async function getBudgetTransactionsForAccount(accountID) {
return transactions; 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) { export async function getHiddenAccounts(age) {
const accounts = await db` const accounts = await db`
select select
@ -514,4 +521,79 @@ 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 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)
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; 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,189 @@
<script>
import { invalidateAll } from '$app/navigation';
import { getContext } from 'svelte';
import { EditSymbol } from '$lib/editSymbol.svelte';
import { TrashBin } from '$lib/trashbin.svelte';
const addToast = getContext('addToast');
let { transaction, close, budgets } = $props();
let id = $derived(transaction.id);
let description = $derived(transaction.description || 'No description');
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);
let loading = $state(false);
let deleting = $state(false);
let deletingText = $state('');
let deletebt = $state(null);
let notes = $state(transaction.notes || '');
function editBudget(budgetTransaction) {
budget_id = budgetTransaction.budget_id;
amount = budgetTransaction.amount;
notes = budgetTransaction.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();
}
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>
<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 Transaction</button>
<legend class="fieldset-legend">Current Budgets</legend>
<div class="flex flex-col">
{#each transaction.budgetTransactions as budgetTransaction}
<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}
</div>
{#if deleting}
<legend class="fieldset-legend">Delete Budget Transaction</legend>
<span class="text-xl">Deleting budget transaction - {deletingText}</span>
<span class="text-xl">Are you sure?</span>
<button class="text-xl btn btn-success" onclick={() => (deleting = false)}>Cancel</button>
<button class="text-xl btn btn-error" onclick={() => sendDeletebt()}>Delete</button>
{:else}
<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" required placeholder="Amount" title="Amount" />
<legend class="fieldset-legend">Notes</legend>
<textarea bind:value={notes} class="textarea w-100"></textarea>
<button class="btn btn-primary" onclick={() => saveBudget()}>Save Budget</button>
{/if}
</fieldset>
{/if}
</div>
<form method="dialog" class="modal-backdrop">
<button onclick={() => close()}>close</button>
</form>
</dialog>

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

@ -0,0 +1,81 @@
<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, budgetTransactions) {
currentTransaction = transaction;
currentTransaction.amount = remaining;
currentTransaction.budgetTransactions = budgetTransactions;
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, applicableBudgets)}
>
{@render EditSymbol()}
</button>
</div>
</div>
</div>
{/each}
{#if editing}
<EditTransaction transaction={currentTransaction} close={() => (editing = false)} {budgets} />
{/if}
</div>

View File

@ -1,9 +1,7 @@
import puppeteer from 'puppeteer-extra';
import StealthPlugin from 'puppeteer-extra-plugin-stealth';
import db from './db.js'; import db from './db.js';
import { json } from '@sveltejs/kit'; import { chromium } from 'patchright';
puppeteer.use(StealthPlugin()); //const browser = await chromium.launch();
let state = []; let state = [];
let code = null; let code = null;
let needCode = false; let needCode = false;
@ -24,117 +22,89 @@ export function isRunning() {
return running; return running;
} }
const browser = await chromium.launchPersistentContext('context', {
channel: 'chrome',
headless: false,
viewport: null
// do NOT add custom browser headers or userAgent
});
export async function pullData(amount = 100) { export async function pullData(amount = 100) {
running = true; running = true;
try {
state = []; state = [];
code = null; code = null;
needCode = false; needCode = false;
state.push('Starting Browser'); state.push('Starting Browser');
const browser = await puppeteer.launch({
args: ['--no-sandbox']
//headless: false
//defaultViewport: null,
//args: ['--disable-blink-features=PrettyPrintJSONDocument']
});
const page = await browser.newPage(); const page = await browser.newPage();
try {
state.push('Loading Cookies');
const cookiesdb = await db`SELECT
name,
value,
domain,
path,
expires,
size,
httpOnly,
secure,
session,
priority,
sameParty,
sourceScheme,
sourcePort
FROM cookies`;
cookiesdb.forEach((cookie) => {
cookie.expires = cookie.expires ? Number(cookie.expires) : undefined;
cookie.size = cookie.size ? Number(cookie.size) : undefined;
cookie.sourcePort = cookie.sourcePort ? Number(cookie.sourcePort) : undefined;
state.push('Loading cookie: ' + cookie.name);
browser.setCookie(cookie);
});
state.push('Navigating to United FCU'); state.push('Navigating to United FCU');
// Navigate the page to a URL.
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.waitForNavigation(); await page.waitForLoadState();
state.push('Already logged in, navigating to dashboard'); }
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');
// Type into search box using accessible input name.
await new Promise((resolve) => setTimeout(resolve, Math.random() * 5000)); await new Promise((resolve) => setTimeout(resolve, Math.random() * 5000));
await page.locator('aria/Login ID').fill('92830'); await page.getByLabel('Login ID').fill('92830');
await new Promise((resolve) => setTimeout(resolve, Math.random() * 5000)); await new Promise((resolve) => setTimeout(resolve, Math.random() * 5000));
await page.getByLabel('Password').fill('Cmtjlt13');
await page.locator('aria/Password').fill('Cmtjlt13');
await new Promise((resolve) => setTimeout(resolve, Math.random() * 5000)); await new Promise((resolve) => setTimeout(resolve, Math.random() * 5000));
state.push('Submitting login form');
await page.keyboard.press('Enter'); await page.keyboard.press('Enter');
await page.waitForNavigation(); //await page.waitForLoadState();
//await new Promise((resolve) => setTimeout(resolve, 5 * 5000));
while (page.url().includes('interstitial')) {
await new Promise((resolve) => setTimeout(resolve, 1000));
await page.waitForLoadState();
}
const url = page.url(); const url = page.url();
state.push(`Current URL after login: ${url}`); state.push(`Current URL after login: ${url}`);
if (url.includes('mfa/targets')) { if (url.includes('mfa/targets')) {
state.push('MFA required, selecting SMS option'); state.push('MFA required, selecting SMS option');
console.log('MFA required, please complete the authentication process.'); console.log('MFA required, please complete the authentication process.');
await page.locator('aria/SMS: (XXX) XXX-4029').click(); await new Promise((resolve) => setTimeout(resolve, Math.random() * 5000));
await page.waitForNavigation(); await page.getByText('SMS: (XXX) XXX-4029').click();
//need to do some stuff ehre await page.waitForURL('**/mfa/entertarget');
await page.keyboard.press('Tab'); await page.getByPlaceholder('Secure Access Code');
//await page.keyboard.press('Tab');
state.push('Waiting for code input'); state.push('Waiting for code input');
needCode = true; needCode = true;
for (let i = 0; i < 5 * 60; i++) { for (let i = 0; i < 5 * 60; i++) {
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');
} }
state.push(`Got code: ${code}`); state.push(`Got code: ${code}`);
await page.getByPlaceholder('Secure Access Code').fill(code);
await page.keyboard.type(code); await new Promise((resolve) => setTimeout(resolve, Math.random() * 5000));
code = null;
needCode = false;
await page.keyboard.press('Enter'); await page.keyboard.press('Enter');
await page.locator('aria/Register Device').click();
await page.waitForNavigation(); await page.getByText('Access Code Accepted.').waitFor();
await page.keyboard.press('Tab');
await page.keyboard.press('Tab');
await page.keyboard.press('Enter');
await page.waitForURL('**/dashboard');
await page.screenshot({ path: 'united-login.png' });
state.push('Logged in successfully');
} }
state.push('Saving cookies');
let cookies = await browser.cookies();
cookies.forEach(async (cookie) => {
state.push('Saving cookie: ' + cookie.name);
// Insert or update the cookie in the database
await db`DELETE FROM cookies WHERE name = ${cookie.name}`;
await db`INSERT INTO cookies (name, value, domain, path, expires, size, httpOnly, secure, session, priority, sameParty, sourceScheme, sourcePort)
VALUES (${cookie.name}, ${cookie.value}, ${cookie.domain}, ${cookie.path}, ${cookie.expires}, ${cookie.size}, ${cookie.httpOnly}, ${cookie.secure}, ${cookie.session}, ${cookie.priority}, ${cookie.sameParty}, ${cookie.sourceScheme}, ${cookie.sourcePort})`;
});
} }
state.push('Fetching q2token'); state.push('Fetching q2token');
@ -217,26 +187,24 @@ export async function pullData(amount = 100) {
state.push(`Checking for pending transactions for account ID: ${account.id}`); state.push(`Checking for pending transactions for account ID: ${account.id}`);
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}`;
} }
} }
for (const transaction of transactions) { for (const transaction of transactions) {
@ -251,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 (
@ -284,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
@ -295,10 +263,12 @@ 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');
} catch (error) { } catch (error) {
console.error('Error in pullData:', error); console.error('Error in pullData:', error);
state.push(`Error: ${error.message}`); state.push(`Error: ${error.message}`);
} }
page.close();
running = false; 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'; import { get } from 'svelte/store';
export async function load({ params }) { export async function load({ params }) {
let accounts = await getAccounts(); let accounts = await getAccounts();
let budgets = await getBudgets(); let budgets = await getBudgets();
let total = await getTotal();
return { accounts, budgets, total }; return { accounts, budgets };
} }

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

@ -0,0 +1,22 @@
import {
getLast30DaysTransactionsSums,
getUnallocatedTransactions,
getUnderallocatedTransactions,
getTotal,
getBudgets,
getAllBudgetTransactions,
getOrphanedTransactions
} 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();
const orphanedTransactions = await getOrphanedTransactions();
return { unallocatedTrans, underAllocatedTrans, total, budgets, budgetTransactions, last30DaysTransactionsSums, orphanedTransactions };
}

View File

@ -0,0 +1,40 @@
<script>
import TransactionList from '$lib/transactionList.svelte';
import Budgetlist from '$lib/budgetlist.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.reverse());
let orphanedTransactions = $derived(data.orphanedTransactions);
</script>
<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} />
<div class="text-xl divider">Orphans</div>
<Budgetlist transactions={orphanedTransactions} />

View File

@ -0,0 +1,95 @@
<script>
import { EditSymbol } from '$lib/editSymbol.svelte';
import { settingsSymbol } from '$lib/settingsSymbol.svelte';
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 transactions = $derived(data.transactions);
let budgets = $derived(data.budgets);
let budgetTransactions = $derived(data.budgetTransactions);
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) {
currentTransaction = transaction;
currentTransaction.amount = remaining;
editing = true;
}
async function saveSettings() {
loading = true;
settings_modal.close();
let res = await fetch(`/api/account/${account.id}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
hide: $state.snapshot(hide),
in_total: $state.snapshot(inTotal)
})
});
loading = false;
invalidateAll();
if (res.ok) {
// Optionally, you can refresh the account data or show a success message
} else {
console.error('Failed to save settings');
}
}
</script>
<div class="flex mb-4">
<div class="w-128 flex-none justify-bottom">
<h1 class="text-lg font-semibold">{account?.name}</h1>
</div>
<div class="w-64 grow text-lg uppercase font-semibold">{account?.balance}</div>
<div class="w-14 flex-none text-right">
<button class="btn btn-square btn-ghost" onclick={() => settings_modal.showModal()}
>{@render settingsSymbol()}
</button>
</div>
</div>
<h1>Transcations</h1>
<TransactionList {budgets} {budgetTransactions} {transactions} />
<dialog id="settings_modal" 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>
<h3>{account?.name}</h3>
<fieldset class="fieldset bg-base-100 border-base-300 rounded-box w-64 border p-4">
<legend class="fieldset-legend">Options</legend>
<label class="label">
<input type="checkbox" bind:checked={hide} class="toggle" />
Hide
</label>
<label class="label">
<input type="checkbox" bind:checked={inTotal} class="toggle" />
Use in total
</label>
</fieldset>
<button onclick={() => saveSettings()} class="btn btn-primary mt-4">Save</button>
</div>
<form method="dialog" class="modal-backdrop">
<button>close</button>
</form>
</dialog>
{#if loading}
{@render loadingModal()}
{/if}

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

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">Total: {total}</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>

View File

@ -1,256 +0,0 @@
<script>
import { EditSymbol } from '$lib/editSymbol.svelte';
import { settingsSymbol } from '$lib/settingsSymbol.svelte';
import { invalidate, invalidateAll } from '$app/navigation';
import { loadingModal } from '$lib/loadingModal.svelte';
import { getContext } from 'svelte';
let { data } = $props();
const addToast = getContext('addToast');
let trans = $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);
function editNotes(transaction, remaining) {
my_modal_3.showModal();
currentTransaction = transaction;
notes = transaction.notes;
currentTransaction.amount = remaining;
}
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();
let res = await fetch(`/api/account/${account.id}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
hide: $state.snapshot(hide),
in_total: $state.snapshot(inTotal)
})
});
loading = false;
invalidateAll();
if (res.ok) {
// Optionally, you can refresh the account data or show a success message
} else {
console.error('Failed to save settings');
}
}
</script>
<div class="flex mb-4">
<div class="w-128 flex-none justify-bottom">
<h1 class="text-lg font-semibold">{account?.name}</h1>
</div>
<div class="w-64 grow text-lg uppercase font-semibold">{account?.balance}</div>
<div class="w-14 flex-none text-right">
<button class="btn btn-square btn-ghost" onclick={() => settings_modal.showModal()}
>{@render settingsSymbol()}
</button>
</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>
<dialog id="settings_modal" 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>
<h3>{account?.name}</h3>
<fieldset class="fieldset bg-base-100 border-base-300 rounded-box w-64 border p-4">
<legend class="fieldset-legend">Options</legend>
<label class="label">
<input type="checkbox" bind:checked={hide} class="toggle" />
Hide
</label>
<label class="label">
<input type="checkbox" bind:checked={inTotal} class="toggle" />
Use in total
</label>
</fieldset>
<button onclick={() => saveSettings()} class="btn btn-primary mt-4">Save</button>
</div>
<form method="dialog" class="modal-backdrop">
<button>close</button>
</form>
</dialog>
{#if loading}
{@render loadingModal()}
{/if}

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']
}
}); });