Merge pull request #15 from acedanger/acedanger/issue14

Add VSCode task for automatic build on project open and update dependencies
This commit is contained in:
Peter Wood
2025-05-01 17:07:22 -04:00
committed by GitHub
30 changed files with 562 additions and 405 deletions

8
.env.example Normal file
View File

@@ -0,0 +1,8 @@
# Development server port (default is 3000)
PORT=3000
# API Base URL for development
API_BASE_URL=http://localhost:3000
# Environment (development/production)
NODE_ENV=development

1
.gitignore vendored
View File

@@ -22,7 +22,6 @@ pnpm-debug.log*
# IDE specific files
.idea/
.vscode/
*.swp
*.swo

7
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,7 @@
{
"terminal.integrated.cwd": "${workspaceFolder}",
"terminal.integrated.enablePersistentSessions": true,
"terminal.integrated.persistentSessionReviveProcess": "onExitAndWindowClose",
"terminal.integrated.enableMultiLinePasteWarning": "auto",
"terminal.integrated.splitCwd": "workspaceRoot"
}

20
.vscode/tasks.json vendored Normal file
View File

@@ -0,0 +1,20 @@
{
"version": "2.0.0",
"tasks": [
{
"type": "npm",
"script": "dev",
"problemMatcher": [],
"label": "npm: dev",
"detail": "astro dev",
"runOptions": {
"runOn": "folderOpen"
},
"isBackground": true,
"presentation": {
"reveal": "always",
"panel": "new"
}
}
]
}

View File

@@ -1,14 +1,16 @@
// @ts-check
import { defineConfig } from 'astro/config';
import cloudflare from '@astrojs/cloudflare';
import node from '@astrojs/node'; // Import Node adapter
import react from '@astrojs/react';
// https://astro.build/config
export default defineConfig({
output: 'server',
adapter: cloudflare(),
adapter: node({
mode: 'standalone'
}),
integrations: [react()],
vite: {
resolve: {

179
package-lock.json generated
View File

@@ -9,6 +9,7 @@
"version": "0.0.1",
"dependencies": {
"@astrojs/cloudflare": "^12.5.1",
"@astrojs/node": "^9.2.1",
"@astrojs/react": "^4.2.5",
"@nanostores/react": "^1.0.0",
"@types/react": "^19.1.2",
@@ -98,6 +99,20 @@
"vfile": "^6.0.3"
}
},
"node_modules/@astrojs/node": {
"version": "9.2.1",
"resolved": "https://registry.npmjs.org/@astrojs/node/-/node-9.2.1.tgz",
"integrity": "sha512-kEHLB37ooW91p7FLGalqa3jVQRIafntfKiZgCnjN1lEYw+j8NP6VJHQbLHmzzbtKUI0J+srGiTnGZmaHErHE5w==",
"license": "MIT",
"dependencies": {
"@astrojs/internal-helpers": "0.6.1",
"send": "^1.1.0",
"server-destroy": "^1.0.1"
},
"peerDependencies": {
"astro": "^5.3.0"
}
},
"node_modules/@astrojs/prism": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/@astrojs/prism/-/prism-3.2.0.tgz",
@@ -3273,6 +3288,15 @@
"node": ">=0.4.0"
}
},
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/dequal": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
@@ -3392,6 +3416,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
"license": "MIT"
},
"node_modules/electron-to-chromium": {
"version": "1.5.142",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.142.tgz",
@@ -3410,6 +3440,15 @@
"integrity": "sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg==",
"license": "MIT"
},
"node_modules/encodeurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/entities": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.0.tgz",
@@ -3526,6 +3565,12 @@
"node": ">=6"
}
},
"node_modules/escape-html": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
"license": "MIT"
},
"node_modules/escape-string-regexp": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz",
@@ -3547,6 +3592,15 @@
"@types/estree": "^1.0.0"
}
},
"node_modules/etag": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
"integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/eventemitter3": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz",
@@ -3691,6 +3745,15 @@
"url": "https://ko-fi.com/tunnckoCore/commissions"
}
},
"node_modules/fresh": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
"integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -4118,6 +4181,22 @@
"integrity": "sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==",
"license": "BSD-2-Clause"
},
"node_modules/http-errors": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz",
"integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==",
"license": "MIT",
"dependencies": {
"depd": "2.0.0",
"inherits": "2.0.4",
"setprototypeof": "1.2.0",
"statuses": "2.0.1",
"toidentifier": "1.0.1"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/import-meta-resolve": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.1.0.tgz",
@@ -4128,6 +4207,12 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/iron-webcrypto": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/iron-webcrypto/-/iron-webcrypto-1.2.1.tgz",
@@ -5504,6 +5589,18 @@
"integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==",
"license": "MIT"
},
"node_modules/on-finished": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
"integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
"license": "MIT",
"dependencies": {
"ee-first": "1.1.1"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
@@ -5788,6 +5885,15 @@
"integrity": "sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA==",
"license": "MIT"
},
"node_modules/range-parser": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
"integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/react": {
"version": "19.1.0",
"resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz",
@@ -6121,6 +6227,61 @@
"node": ">=10"
}
},
"node_modules/send": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz",
"integrity": "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==",
"license": "MIT",
"dependencies": {
"debug": "^4.3.5",
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"etag": "^1.8.1",
"fresh": "^2.0.0",
"http-errors": "^2.0.0",
"mime-types": "^3.0.1",
"ms": "^2.1.3",
"on-finished": "^2.4.1",
"range-parser": "^1.2.1",
"statuses": "^2.0.1"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/send/node_modules/mime-db": {
"version": "1.54.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
"integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/send/node_modules/mime-types": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz",
"integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==",
"license": "MIT",
"dependencies": {
"mime-db": "^1.54.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/server-destroy": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/server-destroy/-/server-destroy-1.0.1.tgz",
"integrity": "sha512-rb+9B5YBIEzYcD6x2VKidaa+cqYBJQKnU4oe4E3ANwRRN56yk/ua1YCJT1n21NTS8w6CcOclAKNP3PhdCXKYtQ==",
"license": "ISC"
},
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"license": "ISC"
},
"node_modules/sharp": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz",
@@ -6369,6 +6530,15 @@
"get-source": "^2.0.12"
}
},
"node_modules/statuses": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
"integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/std-env": {
"version": "3.9.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.9.0.tgz",
@@ -6630,6 +6800,15 @@
"node": ">=14.0.0"
}
},
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
"license": "MIT",
"engines": {
"node": ">=0.6"
}
},
"node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",

View File

@@ -15,6 +15,7 @@
},
"dependencies": {
"@astrojs/cloudflare": "^12.5.1",
"@astrojs/node": "^9.2.1",
"@astrojs/react": "^4.2.5",
"@nanostores/react": "^1.0.0",
"@types/react": "^19.1.2",
@@ -32,4 +33,4 @@
"vitest": "^3.1.2",
"wrangler": "^4.13.1"
}
}
}

View File

@@ -3,7 +3,7 @@ import { formatCurrency } from '../utils';
import type { Account } from '../types';
interface Props {
account: Account;
account: Account;
}
const { account } = Astro.props;
---

View File

@@ -1,11 +1,8 @@
import React, { useState, useEffect } from "react";
import { useStore } from "@nanostores/react";
import type { Account } from "../types";
import { formatCurrency } from "../utils";
import {
currentAccountId as currentAccountIdStore,
refreshKey,
} from "../stores/transactionStore";
import React, { useState, useEffect } from 'react';
import { useStore } from '@nanostores/react';
import type { Account } from '../types';
import { formatCurrency } from '../utils';
import { currentAccountId as currentAccountIdStore, refreshKey } from '../stores/transactionStore';
interface AccountSummaryProps {
// No props needed, data comes from store and fetch
@@ -32,14 +29,12 @@ export default function AccountSummary({}: AccountSummaryProps) {
try {
const response = await fetch(`/api/accounts/${currentAccountId}`);
if (!response.ok) {
throw new Error("Failed to fetch account details");
throw new Error('Failed to fetch account details');
}
const data: Account = await response.json();
setAccount(data);
} catch (err) {
setError(
err instanceof Error ? err.message : "An unknown error occurred"
);
setError(err instanceof Error ? err.message : 'An unknown error occurred');
setAccount(null);
} finally {
setIsLoading(false);
@@ -58,7 +53,7 @@ export default function AccountSummary({}: AccountSummaryProps) {
} else if (account) {
balanceContent = formatCurrency(account.balance);
} else {
balanceContent = "N/A"; // Or some placeholder
balanceContent = 'N/A'; // Or some placeholder
}
return (

View File

@@ -1,13 +1,13 @@
import React, { useState, useEffect } from "react";
import { useStore } from "@nanostores/react";
import type { Transaction } from "../types";
import React, { useState, useEffect } from 'react';
import { useStore } from '@nanostores/react';
import type { Transaction } from '../types';
// Import store atoms and actions
import {
currentAccountId as currentAccountIdStore,
transactionToEdit as transactionToEditStore,
cancelEditingTransaction,
transactionSaved,
} from "../stores/transactionStore";
} from '../stores/transactionStore';
// Remove props that now come from the store
interface AddTransactionFormProps {}
@@ -18,9 +18,9 @@ export default function AddTransactionForm({}: AddTransactionFormProps) {
const transactionToEdit = useStore(transactionToEditStore);
// --- State Variables ---
const [date, setDate] = useState("");
const [description, setDescription] = useState("");
const [amount, setAmount] = useState("");
const [date, setDate] = useState('');
const [description, setDescription] = useState('');
const [amount, setAmount] = useState('');
const [editingId, setEditingId] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -32,7 +32,7 @@ export default function AddTransactionForm({}: AddTransactionFormProps) {
useEffect(() => {
// Only set default date if not editing
if (!transactionToEdit) {
setDate(new Date().toISOString().split("T")[0]);
setDate(new Date().toISOString().split('T')[0]);
}
}, [transactionToEdit]); // Rerun if edit mode changes
@@ -48,17 +48,14 @@ export default function AddTransactionForm({}: AddTransactionFormProps) {
// Directly format the date object (usually interpreted as UTC midnight)
// into the YYYY-MM-DD format required by the input.
// No timezone adjustment needed here.
setDate(dateObj.toISOString().split("T")[0]);
setDate(dateObj.toISOString().split('T')[0]);
} else {
console.warn(
"Invalid date received for editing:",
transactionToEdit.date
);
setDate(""); // Set to empty if invalid
console.warn('Invalid date received for editing:', transactionToEdit.date);
setDate(''); // Set to empty if invalid
}
} catch (e) {
console.error("Error parsing date for editing:", e);
setDate(""); // Set to empty on error
console.error('Error parsing date for editing:', e);
setDate(''); // Set to empty on error
}
setDescription(transactionToEdit.description);
setAmount(transactionToEdit.amount.toString());
@@ -75,9 +72,9 @@ export default function AddTransactionForm({}: AddTransactionFormProps) {
// --- Helper Functions ---
const resetForm = () => {
setEditingId(null);
setDate(new Date().toISOString().split("T")[0]);
setDescription("");
setAmount("");
setDate(new Date().toISOString().split('T')[0]);
setDescription('');
setAmount('');
setError(null);
// Don't reset isLoading here, it's handled in submit/cancel
};
@@ -85,28 +82,28 @@ export default function AddTransactionForm({}: AddTransactionFormProps) {
const validateForm = (): string[] => {
const errors: string[] = [];
if (!description || description.trim().length < 2) {
errors.push("Description must be at least 2 characters long");
errors.push('Description must be at least 2 characters long');
}
if (!amount) {
errors.push("Amount is required");
errors.push('Amount is required');
} else {
const amountNum = parseFloat(amount);
if (isNaN(amountNum)) {
errors.push("Amount must be a valid number");
errors.push('Amount must be a valid number');
} else if (amountNum === 0) {
errors.push("Amount cannot be zero");
errors.push('Amount cannot be zero');
}
}
if (!date) {
errors.push("Date is required");
errors.push('Date is required');
} else {
try {
const dateObj = new Date(date + "T00:00:00"); // Treat input as local date
const dateObj = new Date(date + 'T00:00:00'); // Treat input as local date
if (isNaN(dateObj.getTime())) {
errors.push("Invalid date format");
errors.push('Invalid date format');
}
} catch (e) {
errors.push("Invalid date format");
errors.push('Invalid date format');
}
}
return errors;
@@ -118,13 +115,13 @@ export default function AddTransactionForm({}: AddTransactionFormProps) {
setError(null);
if (isLoading || !currentAccountId) {
if (!currentAccountId) setError("No account selected.");
if (!currentAccountId) setError('No account selected.');
return;
}
const validationErrors = validateForm();
if (validationErrors.length > 0) {
setError(validationErrors.join(". "));
setError(validationErrors.join('. '));
return;
}
@@ -140,21 +137,17 @@ export default function AddTransactionForm({}: AddTransactionFormProps) {
amount: parseFloat(amount),
};
const method = editingId ? "PUT" : "POST";
const url = editingId
? `/api/transactions/${editingId}`
: "/api/transactions";
const method = editingId ? 'PUT' : 'POST';
const url = editingId ? `/api/transactions/${editingId}` : '/api/transactions';
const response = await fetch(url, {
method,
headers: { "Content-Type": "application/json" },
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(transactionData),
});
if (!response.ok) {
let errorMsg = `Failed to ${
isEditMode ? "update" : "create"
} transaction`;
let errorMsg = `Failed to ${isEditMode ? 'update' : 'create'} transaction`;
try {
const errorData = await response.json();
errorMsg = errorData.error || errorMsg;
@@ -169,9 +162,7 @@ export default function AddTransactionForm({}: AddTransactionFormProps) {
transactionSaved(savedTransaction); // Call store action instead of prop callback
resetForm(); // Reset form on success
} catch (err) {
setError(
err instanceof Error ? err.message : "An unexpected error occurred"
);
setError(err instanceof Error ? err.message : 'An unexpected error occurred');
} finally {
setIsLoading(false);
}
@@ -185,7 +176,7 @@ export default function AddTransactionForm({}: AddTransactionFormProps) {
// --- JSX ---
return (
<form id="add-transaction-form-react" onSubmit={handleSubmit} noValidate>
<h4>{isEditMode ? "Edit Transaction" : "New Transaction"}</h4>
<h4>{isEditMode ? 'Edit Transaction' : 'New Transaction'}</h4>
{error && <div className="error-message">{error}</div>}
<div className="form-group">
@@ -228,29 +219,18 @@ export default function AddTransactionForm({}: AddTransactionFormProps) {
placeholder="e.g. -25.50 or 1200.00"
disabled={isLoading}
/>
<small className="help-text">
Use negative numbers for expenses (e.g., -50.00)
</small>
<small className="help-text">Use negative numbers for expenses (e.g., -50.00)</small>
</div>
<div className="button-group">
<button
type="submit"
className={`form-submit-btn ${isLoading ? "loading" : ""}`}
className={`form-submit-btn ${isLoading ? 'loading' : ''}`}
disabled={isLoading}
>
{isLoading
? "Saving..."
: isEditMode
? "Update Transaction"
: "Save Transaction"}
{isLoading ? 'Saving...' : isEditMode ? 'Update Transaction' : 'Save Transaction'}
</button>
{isEditMode && (
<button
type="button"
className="cancel-btn"
onClick={handleCancel}
disabled={isLoading}
>
<button type="button" className="cancel-btn" onClick={handleCancel} disabled={isLoading}>
Cancel
</button>
)}

View File

@@ -3,7 +3,7 @@ import TransactionTable from './TransactionTable.tsx';
import type { Account } from '../types';
interface Props {
account: Account;
account: Account;
}
const { account } = Astro.props;

View File

@@ -4,8 +4,8 @@ import AddTransactionForm from './AddTransactionForm.tsx';
import type { Account } from '../types';
interface Props {
accounts: Account[];
initialAccount: Account;
accounts: Account[];
initialAccount: Account;
}
const { accounts, initialAccount } = Astro.props;

View File

@@ -3,13 +3,15 @@ import { formatCurrency, formatDate } from '../utils';
import type { Transaction } from '../types';
interface Props {
transactions: Transaction[];
transactions: Transaction[];
}
const { transactions } = Astro.props;
// Sort transactions by date descending for display
const sortedTransactions = [...transactions].sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
const sortedTransactions = [...transactions].sort(
(a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()
);
// TODO: UI/UX Improvements
// - Add sorting functionality for all columns

View File

@@ -1,13 +1,13 @@
import React, { useState, useEffect } from "react";
import { useStore } from "@nanostores/react";
import type { Transaction } from "../types";
import { formatCurrency, formatDate } from "../utils";
import React, { useState, useEffect } from 'react';
import { useStore } from '@nanostores/react';
import type { Transaction } from '../types';
import { formatCurrency, formatDate } from '../utils';
import {
startEditingTransaction,
currentAccountId as currentAccountIdStore,
triggerRefresh,
refreshKey,
} from "../stores/transactionStore";
} from '../stores/transactionStore';
interface TransactionTableProps {}
@@ -29,18 +29,14 @@ export default function TransactionTable({}: TransactionTableProps) {
setIsLoading(true);
setError(null);
try {
const response = await fetch(
`/api/accounts/${currentAccountId}/transactions`
);
const response = await fetch(`/api/accounts/${currentAccountId}/transactions`);
if (!response.ok) {
throw new Error("Failed to fetch transactions");
throw new Error('Failed to fetch transactions');
}
const data: Transaction[] = await response.json();
setTransactions(data);
} catch (err) {
setError(
err instanceof Error ? err.message : "An unknown error occurred"
);
setError(err instanceof Error ? err.message : 'An unknown error occurred');
setTransactions([]);
} finally {
setIsLoading(false);
@@ -55,7 +51,7 @@ export default function TransactionTable({}: TransactionTableProps) {
);
const handleDelete = async (txnId: string) => {
if (!confirm("Are you sure you want to delete this transaction?")) {
if (!confirm('Are you sure you want to delete this transaction?')) {
return;
}
@@ -63,11 +59,11 @@ export default function TransactionTable({}: TransactionTableProps) {
try {
const response = await fetch(`/api/transactions/${txnId}`, {
method: "DELETE",
method: 'DELETE',
});
if (!response.ok) {
let errorMsg = "Failed to delete transaction";
let errorMsg = 'Failed to delete transaction';
try {
const errorData = await response.json();
errorMsg = errorData.error || errorMsg;
@@ -85,10 +81,8 @@ export default function TransactionTable({}: TransactionTableProps) {
triggerRefresh();
} catch (error) {
alert(
error instanceof Error ? error.message : "Failed to delete transaction"
);
console.error("Delete error:", error);
alert(error instanceof Error ? error.message : 'Failed to delete transaction');
console.error('Delete error:', error);
} finally {
}
};
@@ -97,16 +91,14 @@ export default function TransactionTable({}: TransactionTableProps) {
console.log(`Attempting to edit transaction: ${transaction.id}`);
startEditingTransaction(transaction);
const addTransactionSection = document.getElementById(
"add-transaction-section"
);
const toggleAddTxnBtn = document.getElementById("toggle-add-txn-btn");
if (addTransactionSection?.classList.contains("collapsed")) {
addTransactionSection.classList.replace("collapsed", "expanded");
toggleAddTxnBtn?.setAttribute("aria-expanded", "true");
const addTransactionSection = document.getElementById('add-transaction-section');
const toggleAddTxnBtn = document.getElementById('toggle-add-txn-btn');
if (addTransactionSection?.classList.contains('collapsed')) {
addTransactionSection.classList.replace('collapsed', 'expanded');
toggleAddTxnBtn?.setAttribute('aria-expanded', 'true');
addTransactionSection.scrollIntoView({
behavior: "smooth",
block: "nearest",
behavior: 'smooth',
block: 'nearest',
});
}
};
@@ -114,7 +106,7 @@ export default function TransactionTable({}: TransactionTableProps) {
// Helper function to render loading state
const renderLoading = () => (
<tr>
<td colSpan={4} style={{ textAlign: "center", padding: "2rem" }}>
<td colSpan={4} style={{ textAlign: 'center', padding: '2rem' }}>
Loading transactions...
</td>
</tr>
@@ -126,9 +118,9 @@ export default function TransactionTable({}: TransactionTableProps) {
<td
colSpan={4}
style={{
textAlign: "center",
fontStyle: "italic",
color: "#777",
textAlign: 'center',
fontStyle: 'italic',
color: '#777',
}}
>
No transactions found for this account.
@@ -142,11 +134,7 @@ export default function TransactionTable({}: TransactionTableProps) {
<tr key={txn.id} data-txn-id={txn.id}>
<td>{formatDate(txn.date)}</td>
<td>{txn.description}</td>
<td
className={`amount-col ${
txn.amount >= 0 ? "amount-positive" : "amount-negative"
}`}
>
<td className={`amount-col ${txn.amount >= 0 ? 'amount-positive' : 'amount-negative'}`}>
{formatCurrency(txn.amount)}
</td>
<td>
@@ -169,9 +157,9 @@ export default function TransactionTable({}: TransactionTableProps) {
));
return (
<div id="transaction-section" className={isLoading ? "loading" : ""}>
<div id="transaction-section" className={isLoading ? 'loading' : ''}>
{error && (
<div className="error-message" style={{ padding: "1rem" }}>
<div className="error-message" style={{ padding: '1rem' }}>
Error loading transactions: {error}
</div>
)}
@@ -188,10 +176,10 @@ export default function TransactionTable({}: TransactionTableProps) {
{isLoading
? renderLoading()
: error
? null // Error message is shown above the table
: sortedTransactions.length === 0
? renderEmpty()
: renderRows()}
? null // Error message is shown above the table
: sortedTransactions.length === 0
? renderEmpty()
: renderRows()}
</tbody>
</table>
</div>

View File

@@ -8,7 +8,7 @@
// - Implement audit trail
// - Add data archival strategy
import type { Account, Transaction } from "../types";
import type { Account, Transaction } from '../types';
// TODO: Replace in-memory store with persistent database
// - Implement database connection and configuration
@@ -19,39 +19,39 @@ import type { Account, Transaction } from "../types";
// Temporary in-memory store for development
export const accounts: Account[] = [
{
id: "1",
name: "Checking Account",
last4: "4321",
id: '1',
name: 'Checking Account',
last4: '4321',
balance: 2500.0,
},
{
id: "2",
name: "Savings Account",
last4: "8765",
id: '2',
name: 'Savings Account',
last4: '8765',
balance: 10000.0,
},
];
export const transactions: Transaction[] = [
{
id: "1",
accountId: "1",
date: "2025-04-20",
description: "Grocery Store",
id: '1',
accountId: '1',
date: '2025-04-20',
description: 'Grocery Store',
amount: -75.5,
},
{
id: "2",
accountId: "1",
date: "2025-04-21",
description: "Salary Deposit",
id: '2',
accountId: '1',
date: '2025-04-21',
description: 'Salary Deposit',
amount: 3000.0,
},
{
id: "3",
accountId: "2",
date: "2025-04-22",
description: "Transfer to Savings",
id: '3',
accountId: '2',
date: '2025-04-22',
description: 'Transfer to Savings',
amount: 500.0,
},
];

View File

@@ -1,6 +1,6 @@
---
interface Props {
title: string;
title: string;
}
// TODO: Accessibility Improvements

View File

@@ -1,14 +1,14 @@
import type { APIRoute } from "astro";
import { accounts } from "../../../../data/store";
import type { APIRoute } from 'astro';
import { accounts } from '../../../../data/store';
export const GET: APIRoute = async ({ params }) => {
const account = accounts.find((a) => a.id === params.id);
if (!account) {
return new Response(JSON.stringify({ error: "Account not found" }), {
return new Response(JSON.stringify({ error: 'Account not found' }), {
status: 404,
headers: {
"Content-Type": "application/json",
'Content-Type': 'application/json',
},
});
}
@@ -16,7 +16,7 @@ export const GET: APIRoute = async ({ params }) => {
return new Response(JSON.stringify(account), {
status: 200,
headers: {
"Content-Type": "application/json",
'Content-Type': 'application/json',
},
});
};

View File

@@ -1,15 +1,13 @@
import type { APIRoute } from "astro";
import { transactions } from "../../../../../data/store";
import type { APIRoute } from 'astro';
import { transactions } from '../../../../../data/store';
export const GET: APIRoute = async ({ params }) => {
const accountTransactions = transactions.filter(
(t) => t.accountId === params.id
);
const accountTransactions = transactions.filter((t) => t.accountId === params.id);
return new Response(JSON.stringify(accountTransactions), {
status: 200,
headers: {
"Content-Type": "application/json",
'Content-Type': 'application/json',
},
});
};

View File

@@ -1,11 +1,11 @@
import type { APIRoute } from "astro";
import { accounts } from "../../../data/store";
import type { APIRoute } from 'astro';
import { accounts } from '../../../data/store';
export const GET: APIRoute = async () => {
return new Response(JSON.stringify(accounts), {
status: 200,
headers: {
"Content-Type": "application/json",
'Content-Type': 'application/json',
},
});
};

View File

@@ -1,18 +1,15 @@
import type { APIRoute } from "astro";
import { transactions, accounts } from "../../../../data/store";
import type { Transaction } from "../../../../types";
import type { APIRoute } from 'astro';
import { transactions, accounts } from '../../../../data/store';
import type { Transaction } from '../../../../types';
export const PUT: APIRoute = async ({ request, params }) => {
const { id } = params;
if (!id) {
return new Response(
JSON.stringify({ error: "Transaction ID is required" }),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
);
return new Response(JSON.stringify({ error: 'Transaction ID is required' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
});
}
try {
@@ -20,9 +17,9 @@ export const PUT: APIRoute = async ({ request, params }) => {
const transactionIndex = transactions.findIndex((t) => t.id === id);
if (transactionIndex === -1) {
return new Response(JSON.stringify({ error: "Transaction not found" }), {
return new Response(JSON.stringify({ error: 'Transaction not found' }), {
status: 404,
headers: { "Content-Type": "application/json" },
headers: { 'Content-Type': 'application/json' },
});
}
@@ -31,9 +28,9 @@ export const PUT: APIRoute = async ({ request, params }) => {
// Get the old account first
const oldAccount = accounts.find((a) => a.id === oldTransaction.accountId);
if (!oldAccount) {
return new Response(JSON.stringify({ error: "Account not found" }), {
return new Response(JSON.stringify({ error: 'Account not found' }), {
status: 404,
headers: { "Content-Type": "application/json" },
headers: { 'Content-Type': 'application/json' },
});
}
@@ -42,9 +39,9 @@ export const PUT: APIRoute = async ({ request, params }) => {
if (updates.accountId && updates.accountId !== oldTransaction.accountId) {
const foundAccount = accounts.find((a) => a.id === updates.accountId);
if (!foundAccount) {
return new Response(JSON.stringify({ error: "Account not found" }), {
return new Response(JSON.stringify({ error: 'Account not found' }), {
status: 404,
headers: { "Content-Type": "application/json" },
headers: { 'Content-Type': 'application/json' },
});
}
newAccount = foundAccount;
@@ -74,12 +71,12 @@ export const PUT: APIRoute = async ({ request, params }) => {
return new Response(JSON.stringify(updatedTransaction), {
status: 200,
headers: { "Content-Type": "application/json" },
headers: { 'Content-Type': 'application/json' },
});
} catch (error) {
return new Response(JSON.stringify({ error: "Invalid request body" }), {
return new Response(JSON.stringify({ error: 'Invalid request body' }), {
status: 400,
headers: { "Content-Type": "application/json" },
headers: { 'Content-Type': 'application/json' },
});
}
};
@@ -88,21 +85,18 @@ export const DELETE: APIRoute = async ({ params }) => {
const { id } = params;
if (!id) {
return new Response(
JSON.stringify({ error: "Transaction ID is required" }),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
);
return new Response(JSON.stringify({ error: 'Transaction ID is required' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
});
}
const transactionIndex = transactions.findIndex((t) => t.id === id);
if (transactionIndex === -1) {
return new Response(JSON.stringify({ error: "Transaction not found" }), {
return new Response(JSON.stringify({ error: 'Transaction not found' }), {
status: 404,
headers: { "Content-Type": "application/json" },
headers: { 'Content-Type': 'application/json' },
});
}
@@ -110,9 +104,9 @@ export const DELETE: APIRoute = async ({ params }) => {
const account = accounts.find((a) => a.id === transaction.accountId);
if (!account) {
return new Response(JSON.stringify({ error: "Account not found" }), {
return new Response(JSON.stringify({ error: 'Account not found' }), {
status: 404,
headers: { "Content-Type": "application/json" },
headers: { 'Content-Type': 'application/json' },
});
}

View File

@@ -10,9 +10,9 @@
* - Set up proper CORS configuration
*/
import type { APIRoute } from "astro";
import { transactions, accounts } from "../../../data/store";
import type { Transaction } from "../../../types";
import type { APIRoute } from 'astro';
import { transactions, accounts } from '../../../data/store';
import type { Transaction } from '../../../types';
/**
* TODO: API Improvements
@@ -27,7 +27,7 @@ import type { Transaction } from "../../../types";
export const POST: APIRoute = async ({ request }) => {
try {
const transaction = (await request.json()) as Omit<Transaction, "id">;
const transaction = (await request.json()) as Omit<Transaction, 'id'>;
// Validate required fields
if (
@@ -36,21 +36,18 @@ export const POST: APIRoute = async ({ request }) => {
!transaction.description ||
transaction.amount === undefined
) {
return new Response(
JSON.stringify({ error: "Missing required fields" }),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
);
return new Response(JSON.stringify({ error: 'Missing required fields' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
});
}
// Validate account exists
const account = accounts.find((a) => a.id === transaction.accountId);
if (!account) {
return new Response(JSON.stringify({ error: "Account not found" }), {
return new Response(JSON.stringify({ error: 'Account not found' }), {
status: 404,
headers: { "Content-Type": "application/json" },
headers: { 'Content-Type': 'application/json' },
});
}
@@ -68,12 +65,12 @@ export const POST: APIRoute = async ({ request }) => {
return new Response(JSON.stringify(newTransaction), {
status: 201,
headers: { "Content-Type": "application/json" },
headers: { 'Content-Type': 'application/json' },
});
} catch (error) {
return new Response(JSON.stringify({ error: "Invalid request body" }), {
return new Response(JSON.stringify({ error: 'Invalid request body' }), {
status: 400,
headers: { "Content-Type": "application/json" },
headers: { 'Content-Type': 'application/json' },
});
}
};

View File

@@ -5,8 +5,8 @@ import MainContent from '../components/MainContent.astro';
import type { Account, Transaction } from '../types';
export interface Props {
account: Account;
transactions: Transaction[];
account: Account;
transactions: Transaction[];
}
// Get the base URL from the incoming request
@@ -18,17 +18,19 @@ const accounts: Account[] = await accountsResponse.json();
// Initialize with first account or empty account if none exist
const initialAccount: Account = accounts[0] || {
id: '',
name: 'No accounts available',
last4: '0000',
balance: 0
id: '',
name: 'No accounts available',
last4: '0000',
balance: 0,
};
// Fetch initial transactions if we have an account, using absolute URL
let initialTransactions: Transaction[] = [];
if (initialAccount.id) {
const transactionsResponse = await fetch(`${baseUrl}/api/accounts/${initialAccount.id}/transactions`);
initialTransactions = await transactionsResponse.json();
const transactionsResponse = await fetch(
`${baseUrl}/api/accounts/${initialAccount.id}/transactions`
);
initialTransactions = await transactionsResponse.json();
}
---

View File

@@ -1,5 +1,5 @@
import { atom } from "nanostores";
import type { Transaction } from "../types";
import { atom } from 'nanostores';
import type { Transaction } from '../types';
// Atom to hold the ID of the currently selected account
export const currentAccountId = atom<string | null>(null);
@@ -39,9 +39,7 @@ export function transactionSaved(transaction: Transaction) {
}
// Potentially trigger UI updates or refreshes here
// This might involve dispatching a custom event or calling a refresh function
document.dispatchEvent(
new CustomEvent("transactionSaved", { detail: { transaction } })
);
document.dispatchEvent(new CustomEvent('transactionSaved', { detail: { transaction } }));
// Trigger a general refresh after saving too, to update balance
triggerRefresh();

View File

@@ -69,8 +69,8 @@ body {
.collapsible-form {
padding: 15px;
background-color: #fdfdfd;
transition: max-height 0.3s ease-out, opacity 0.3s ease-out,
padding 0.3s ease-out, border 0.3s ease-out;
transition: max-height 0.3s ease-out, opacity 0.3s ease-out, padding 0.3s ease-out, border 0.3s
ease-out;
overflow: hidden;
max-height: 500px;
opacity: 1;

View File

@@ -1,60 +1,54 @@
import { describe, it, expect } from "vitest";
import { GET as listAccounts } from "../pages/api/accounts/index";
import { GET as getAccount } from "../pages/api/accounts/[id]/index";
import { GET as listTransactions } from "../pages/api/accounts/[id]/transactions/index";
import { createMockAPIContext } from "./setup";
import { describe, it, expect } from 'vitest';
import { GET as listAccounts } from '../pages/api/accounts/index';
import { GET as getAccount } from '../pages/api/accounts/[id]/index';
import { GET as listTransactions } from '../pages/api/accounts/[id]/transactions/index';
import { createMockAPIContext } from './setup';
describe("Accounts API", () => {
describe("GET /api/accounts", () => {
it("should return all accounts", async () => {
describe('Accounts API', () => {
describe('GET /api/accounts', () => {
it('should return all accounts', async () => {
const response = await listAccounts(createMockAPIContext() as any);
const accounts = await response.json();
expect(response.status).toBe(200);
expect(accounts).toHaveLength(2);
expect(accounts[0]).toHaveProperty("id", "1");
expect(accounts[1]).toHaveProperty("id", "2");
expect(accounts[0]).toHaveProperty('id', '1');
expect(accounts[1]).toHaveProperty('id', '2');
});
});
describe("GET /api/accounts/:id", () => {
it("should return a specific account", async () => {
const response = await getAccount(
createMockAPIContext({ params: { id: "1" } }) as any
);
describe('GET /api/accounts/:id', () => {
it('should return a specific account', async () => {
const response = await getAccount(createMockAPIContext({ params: { id: '1' } }) as any);
const account = await response.json();
expect(response.status).toBe(200);
expect(account).toHaveProperty("id", "1");
expect(account).toHaveProperty("name", "Test Checking");
expect(account).toHaveProperty('id', '1');
expect(account).toHaveProperty('name', 'Test Checking');
});
it("should return 404 for non-existent account", async () => {
const response = await getAccount(
createMockAPIContext({ params: { id: "999" } }) as any
);
it('should return 404 for non-existent account', async () => {
const response = await getAccount(createMockAPIContext({ params: { id: '999' } }) as any);
const error = await response.json();
expect(response.status).toBe(404);
expect(error).toHaveProperty("error", "Account not found");
expect(error).toHaveProperty('error', 'Account not found');
});
});
describe("GET /api/accounts/:id/transactions", () => {
it("should return transactions for an account", async () => {
const response = await listTransactions(
createMockAPIContext({ params: { id: "1" } }) as any
);
describe('GET /api/accounts/:id/transactions', () => {
it('should return transactions for an account', async () => {
const response = await listTransactions(createMockAPIContext({ params: { id: '1' } }) as any);
const transactions = await response.json();
expect(response.status).toBe(200);
expect(transactions).toHaveLength(1);
expect(transactions[0]).toHaveProperty("accountId", "1");
expect(transactions[0]).toHaveProperty('accountId', '1');
});
it("should return empty array for account with no transactions", async () => {
it('should return empty array for account with no transactions', async () => {
const response = await listTransactions(
createMockAPIContext({ params: { id: "999" } }) as any
createMockAPIContext({ params: { id: '999' } }) as any
);
const transactions = await response.json();

View File

@@ -1,27 +1,27 @@
import { beforeEach } from "vitest";
import { accounts, transactions } from "../data/store";
import type { APIContext } from "astro";
import { beforeEach } from 'vitest';
import { accounts, transactions } from '../data/store';
import type { APIContext } from 'astro';
// Create a mock APIContext factory
export function createMockAPIContext<
T extends Record<string, string> = Record<string, string>
>({ params = {} as T } = {}): Partial<APIContext> {
export function createMockAPIContext<T extends Record<string, string> = Record<string, string>>({
params = {} as T,
} = {}): Partial<APIContext> {
return {
params,
props: {},
request: new Request("http://localhost:4321"),
site: new URL("http://localhost:4321"),
generator: "test",
url: new URL("http://localhost:4321"),
clientAddress: "127.0.0.1",
request: new Request('http://localhost:4321'),
site: new URL('http://localhost:4321'),
generator: 'test',
url: new URL('http://localhost:4321'),
clientAddress: '127.0.0.1',
cookies: new Headers() as any, // Cast Headers to cookies as we don't need cookie functionality in tests
redirect: () => new Response(),
locals: {},
preferredLocale: undefined,
preferredLocaleList: [],
currentLocale: undefined,
routePattern: "/api/[...path]",
originPathname: "/api",
routePattern: '/api/[...path]',
originPathname: '/api',
getActionResult: () => undefined,
isPrerendered: false,
};
@@ -33,15 +33,15 @@ beforeEach(() => {
accounts.length = 0;
accounts.push(
{
id: "1",
name: "Test Checking",
last4: "1234",
id: '1',
name: 'Test Checking',
last4: '1234',
balance: 1000.0,
},
{
id: "2",
name: "Test Savings",
last4: "5678",
id: '2',
name: 'Test Savings',
last4: '5678',
balance: 5000.0,
}
);
@@ -50,17 +50,17 @@ beforeEach(() => {
transactions.length = 0;
transactions.push(
{
id: "1",
accountId: "1",
date: "2025-04-24",
description: "Test Transaction 1",
id: '1',
accountId: '1',
date: '2025-04-24',
description: 'Test Transaction 1',
amount: -50.0,
},
{
id: "2",
accountId: "2",
date: "2025-04-24",
description: "Test Transaction 2",
id: '2',
accountId: '2',
date: '2025-04-24',
description: 'Test Transaction 2',
amount: 100.0,
}
);

View File

@@ -7,31 +7,31 @@
// - Add load testing for API endpoints
// - Implement test data factories
import { describe, it, expect } from "vitest";
import { POST as createTransaction } from "../pages/api/transactions/index";
import { describe, it, expect } from 'vitest';
import { POST as createTransaction } from '../pages/api/transactions/index';
import {
PUT as updateTransaction,
DELETE as deleteTransaction,
} from "../pages/api/transactions/[id]/index";
import { accounts, transactions } from "../data/store";
import type { Transaction } from "../types";
import { createMockAPIContext } from "./setup";
} from '../pages/api/transactions/[id]/index';
import { accounts, transactions } from '../data/store';
import type { Transaction } from '../types';
import { createMockAPIContext } from './setup';
describe("Transactions API", () => {
describe("POST /api/transactions", () => {
it("should create a new transaction", async () => {
describe('Transactions API', () => {
describe('POST /api/transactions', () => {
it('should create a new transaction', async () => {
const initialBalance = accounts[0].balance;
const newTransaction = {
accountId: "1",
date: "2025-04-24",
description: "Test New Transaction",
accountId: '1',
date: '2025-04-24',
description: 'Test New Transaction',
amount: -25.0,
};
const ctx = createMockAPIContext() as any;
ctx.request = new Request("http://localhost:4321/api/transactions", {
method: "POST",
headers: { "Content-Type": "application/json" },
ctx.request = new Request('http://localhost:4321/api/transactions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newTransaction),
});
@@ -39,21 +39,21 @@ describe("Transactions API", () => {
const result = await response.json();
expect(response.status).toBe(201);
expect(result).toHaveProperty("id");
expect(result).toHaveProperty('id');
expect(result.description).toBe(newTransaction.description);
expect(accounts[0].balance).toBe(initialBalance + newTransaction.amount);
});
it("should reject transaction with missing fields", async () => {
it('should reject transaction with missing fields', async () => {
const invalidTransaction = {
accountId: "1",
accountId: '1',
// Missing required fields
};
const ctx = createMockAPIContext() as any;
ctx.request = new Request("http://localhost:4321/api/transactions", {
method: "POST",
headers: { "Content-Type": "application/json" },
ctx.request = new Request('http://localhost:4321/api/transactions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(invalidTransaction),
});
@@ -61,21 +61,21 @@ describe("Transactions API", () => {
const error = await response.json();
expect(response.status).toBe(400);
expect(error).toHaveProperty("error", "Missing required fields");
expect(error).toHaveProperty('error', 'Missing required fields');
});
it("should reject transaction with invalid account", async () => {
it('should reject transaction with invalid account', async () => {
const invalidTransaction = {
accountId: "999",
date: "2025-04-24",
description: "Invalid Account Test",
accountId: '999',
date: '2025-04-24',
description: 'Invalid Account Test',
amount: 100,
};
const ctx = createMockAPIContext() as any;
ctx.request = new Request("http://localhost:4321/api/transactions", {
method: "POST",
headers: { "Content-Type": "application/json" },
ctx.request = new Request('http://localhost:4321/api/transactions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(invalidTransaction),
});
@@ -83,38 +83,38 @@ describe("Transactions API", () => {
const error = await response.json();
expect(response.status).toBe(404);
expect(error).toHaveProperty("error", "Account not found");
expect(error).toHaveProperty('error', 'Account not found');
});
it("should reject invalid request body", async () => {
it('should reject invalid request body', async () => {
const ctx = createMockAPIContext() as any;
ctx.request = new Request("http://localhost:4321/api/transactions", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: "invalid json",
ctx.request = new Request('http://localhost:4321/api/transactions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: 'invalid json',
});
const response = await createTransaction(ctx);
const error = await response.json();
expect(response.status).toBe(400);
expect(error).toHaveProperty("error", "Invalid request body");
expect(error).toHaveProperty('error', 'Invalid request body');
});
});
describe("PUT /api/transactions/:id", () => {
it("should update an existing transaction", async () => {
describe('PUT /api/transactions/:id', () => {
it('should update an existing transaction', async () => {
const initialBalance = accounts[0].balance;
const originalAmount = transactions[0].amount;
const updates = {
description: "Updated Description",
description: 'Updated Description',
amount: -75.0,
};
const ctx = createMockAPIContext({ params: { id: "1" } }) as any;
ctx.request = new Request("http://localhost:4321/api/transactions/1", {
method: "PUT",
headers: { "Content-Type": "application/json" },
const ctx = createMockAPIContext({ params: { id: '1' } }) as any;
ctx.request = new Request('http://localhost:4321/api/transactions/1', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updates),
});
@@ -124,49 +124,47 @@ describe("Transactions API", () => {
expect(response.status).toBe(200);
expect(result.description).toBe(updates.description);
expect(result.amount).toBe(updates.amount);
expect(accounts[0].balance).toBe(
initialBalance - originalAmount + updates.amount
);
expect(accounts[0].balance).toBe(initialBalance - originalAmount + updates.amount);
});
it("should reject update with invalid request body", async () => {
const ctx = createMockAPIContext({ params: { id: "1" } }) as any;
ctx.request = new Request("http://localhost:4321/api/transactions/1", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: "invalid json",
it('should reject update with invalid request body', async () => {
const ctx = createMockAPIContext({ params: { id: '1' } }) as any;
ctx.request = new Request('http://localhost:4321/api/transactions/1', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: 'invalid json',
});
const response = await updateTransaction(ctx);
const error = await response.json();
expect(response.status).toBe(400);
expect(error).toHaveProperty("error", "Invalid request body");
expect(error).toHaveProperty('error', 'Invalid request body');
});
it("should reject update for non-existent transaction", async () => {
const ctx = createMockAPIContext({ params: { id: "999" } }) as any;
ctx.request = new Request("http://localhost:4321/api/transactions/999", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ description: "Test" }),
it('should reject update for non-existent transaction', async () => {
const ctx = createMockAPIContext({ params: { id: '999' } }) as any;
ctx.request = new Request('http://localhost:4321/api/transactions/999', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ description: 'Test' }),
});
const response = await updateTransaction(ctx);
const error = await response.json();
expect(response.status).toBe(404);
expect(error).toHaveProperty("error", "Transaction not found");
expect(error).toHaveProperty('error', 'Transaction not found');
});
it("should reject update for non-existent account", async () => {
it('should reject update for non-existent account', async () => {
// First update the transaction to point to a non-existent account
transactions[0].accountId = "999";
transactions[0].accountId = '999';
const ctx = createMockAPIContext({ params: { id: "1" } }) as any;
ctx.request = new Request("http://localhost:4321/api/transactions/1", {
method: "PUT",
headers: { "Content-Type": "application/json" },
const ctx = createMockAPIContext({ params: { id: '1' } }) as any;
ctx.request = new Request('http://localhost:4321/api/transactions/1', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ amount: -100 }),
});
@@ -174,25 +172,25 @@ describe("Transactions API", () => {
const error = await response.json();
expect(response.status).toBe(404);
expect(error).toHaveProperty("error", "Account not found");
expect(error).toHaveProperty('error', 'Account not found');
// Reset account ID for other tests
transactions[0].accountId = "1";
transactions[0].accountId = '1';
});
it("should handle account balance updates correctly when switching accounts", async () => {
it('should handle account balance updates correctly when switching accounts', async () => {
// Create initial state
const oldAccount = accounts[0];
const newAccount = accounts[1];
const initialOldBalance = oldAccount.balance;
const initialNewBalance = newAccount.balance;
const oldTransaction = transactions.find((t) => t.id === "1");
if (!oldTransaction) throw new Error("Test transaction not found");
const oldTransaction = transactions.find((t) => t.id === '1');
if (!oldTransaction) throw new Error('Test transaction not found');
const ctx = createMockAPIContext({ params: { id: "1" } }) as any;
ctx.request = new Request("http://localhost:4321/api/transactions/1", {
method: "PUT",
headers: { "Content-Type": "application/json" },
const ctx = createMockAPIContext({ params: { id: '1' } }) as any;
ctx.request = new Request('http://localhost:4321/api/transactions/1', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
accountId: newAccount.id,
amount: -100,
@@ -206,41 +204,36 @@ describe("Transactions API", () => {
expect(result.accountId).toBe(newAccount.id);
// Old account should have the old amount removed
expect(oldAccount.balance).toBe(
initialOldBalance + Math.abs(oldTransaction.amount)
);
expect(oldAccount.balance).toBe(initialOldBalance + Math.abs(oldTransaction.amount));
// New account should have the new amount added
expect(newAccount.balance).toBe(initialNewBalance - 100);
});
it("should reject update without transaction ID", async () => {
it('should reject update without transaction ID', async () => {
const ctx = createMockAPIContext() as any;
ctx.request = new Request(
"http://localhost:4321/api/transactions/undefined",
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ description: "Test" }),
}
);
ctx.request = new Request('http://localhost:4321/api/transactions/undefined', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ description: 'Test' }),
});
const response = await updateTransaction(ctx);
const error = await response.json();
expect(response.status).toBe(400);
expect(error).toHaveProperty("error", "Transaction ID is required");
expect(error).toHaveProperty('error', 'Transaction ID is required');
});
it("should reject update when old account is missing", async () => {
it('should reject update when old account is missing', async () => {
// Store current accounts and clear the array
const savedAccounts = [...accounts];
accounts.length = 0;
const ctx = createMockAPIContext({ params: { id: "1" } }) as any;
ctx.request = new Request("http://localhost:4321/api/transactions/1", {
method: "PUT",
headers: { "Content-Type": "application/json" },
const ctx = createMockAPIContext({ params: { id: '1' } }) as any;
ctx.request = new Request('http://localhost:4321/api/transactions/1', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ amount: -100 }),
});
@@ -248,19 +241,19 @@ describe("Transactions API", () => {
const error = await response.json();
expect(response.status).toBe(404);
expect(error).toHaveProperty("error", "Account not found");
expect(error).toHaveProperty('error', 'Account not found');
// Restore accounts
accounts.push(...savedAccounts);
});
it("should reject update when new account doesn't exist", async () => {
const ctx = createMockAPIContext({ params: { id: "1" } }) as any;
ctx.request = new Request("http://localhost:4321/api/transactions/1", {
method: "PUT",
headers: { "Content-Type": "application/json" },
const ctx = createMockAPIContext({ params: { id: '1' } }) as any;
ctx.request = new Request('http://localhost:4321/api/transactions/1', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
accountId: "999", // Non-existent account
accountId: '999', // Non-existent account
amount: -100,
}),
});
@@ -269,18 +262,18 @@ describe("Transactions API", () => {
const error = await response.json();
expect(response.status).toBe(404);
expect(error).toHaveProperty("error", "Account not found");
expect(error).toHaveProperty('error', 'Account not found');
});
});
describe("DELETE /api/transactions/:id", () => {
it("should delete a transaction", async () => {
describe('DELETE /api/transactions/:id', () => {
it('should delete a transaction', async () => {
const initialBalance = accounts[0].balance;
const transactionAmount = transactions[0].amount;
const initialCount = transactions.length;
const response = await deleteTransaction(
createMockAPIContext({ params: { id: "1" } }) as any
createMockAPIContext({ params: { id: '1' } }) as any
);
expect(response.status).toBe(204);
@@ -288,45 +281,45 @@ describe("Transactions API", () => {
expect(accounts[0].balance).toBe(initialBalance - transactionAmount);
});
it("should reject delete without transaction ID", async () => {
it('should reject delete without transaction ID', async () => {
const response = await deleteTransaction(createMockAPIContext() as any);
const error = await response.json();
expect(response.status).toBe(400);
expect(error).toHaveProperty("error", "Transaction ID is required");
expect(error).toHaveProperty('error', 'Transaction ID is required');
});
it("should return 404 for non-existent transaction", async () => {
it('should return 404 for non-existent transaction', async () => {
const response = await deleteTransaction(
createMockAPIContext({ params: { id: "999" } }) as any
createMockAPIContext({ params: { id: '999' } }) as any
);
const error = await response.json();
expect(response.status).toBe(404);
expect(error).toHaveProperty("error", "Transaction not found");
expect(error).toHaveProperty('error', 'Transaction not found');
});
it("should handle deletion with non-existent account", async () => {
it('should handle deletion with non-existent account', async () => {
// Create a transaction then remove its account
const testTransaction: Transaction = {
id: "test-delete",
accountId: "test-account",
date: "2025-04-24",
description: "Test Delete",
id: 'test-delete',
accountId: 'test-account',
date: '2025-04-24',
description: 'Test Delete',
amount: 100,
};
transactions.push(testTransaction);
const response = await deleteTransaction(
createMockAPIContext({ params: { id: "test-delete" } }) as any
createMockAPIContext({ params: { id: 'test-delete' } }) as any
);
const error = await response.json();
expect(response.status).toBe(404);
expect(error).toHaveProperty("error", "Account not found");
expect(error).toHaveProperty('error', 'Account not found');
});
});
});

View File

@@ -1,4 +1,4 @@
import type { Transaction } from "../types";
import type { Transaction } from '../types';
export interface TransactionEventDetail {
transaction: Transaction;

View File

@@ -1,17 +1,17 @@
// Basic currency formatting (USD example)
export function formatCurrency(amount: number): string {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
}).format(amount);
}
// Basic date formatting
export function formatDate(dateString: string): string {
const date = new Date(dateString + "T00:00:00"); // Ensure correct parsing as local date
return new Intl.DateTimeFormat("en-US", {
year: "numeric",
month: "short",
day: "numeric",
const date = new Date(dateString + 'T00:00:00'); // Ensure correct parsing as local date
return new Intl.DateTimeFormat('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
}).format(date);
}

View File

@@ -1,21 +1,21 @@
/// <reference types="vitest" />
import { defineConfig } from "vitest/config";
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
// Increase timeout for slower CI environments
testTimeout: 10000,
// Use the setup file we created
setupFiles: ["./src/test/setup.ts"],
setupFiles: ['./src/test/setup.ts'],
// Ensure we're using the right environment
environment: "node",
environment: 'node',
// Only include test files
include: ["src/test/**/*.{test,spec}.{ts,js}"],
include: ['src/test/**/*.{test,spec}.{ts,js}'],
// Configure coverage collection
coverage: {
provider: "v8",
reporter: ["text", "json", "html"],
exclude: ["node_modules/", "src/test/**/*", "**/*.d.ts"],
provider: 'v8',
reporter: ['text', 'json', 'html'],
exclude: ['node_modules/', 'src/test/**/*', '**/*.d.ts'],
},
},
});