diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..32351ee --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,5 @@ +{ + "cSpell.words": [ + "Uncategorized" + ] +} \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 4541e85..e30325d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,15 +1,30 @@ -FROM node:20-alpine +FROM node:20-slim AS builder WORKDIR /app -COPY package*.json ./ - -RUN npm install +COPY package.json bun.lockb ./ +RUN npm install -g bun && bun install --frozen-lockfile COPY . . -EXPOSE 3000 +RUN bun run build + +FROM node:20-slim AS runner + +WORKDIR /app -RUN npm run build +COPY --from=builder /app/.next .next +COPY --from=builder /app/public public +COPY --from=builder /app/package.json . +COPY --from=builder /app/bun.lockb . +COPY --from=builder /app/drizzle drizzle +COPY --from=builder /app/src/schema src/schema +COPY --from=builder /app/drizzle.config.ts drizzle.config.ts + +RUN npm install -g bun && bun install --frozen-lockfile --production + +RUN bun add -g drizzle-kit + +EXPOSE 3000 -CMD ["npm", "start"] +CMD ["sh", "-c", "bunx drizzle-kit push --config=drizzle.config.ts && bun start"] diff --git a/README.md b/README.md index 779daab..9d475b2 100644 --- a/README.md +++ b/README.md @@ -1,100 +1,92 @@ # OopsBudgeter -OopsBudgeter is a personal finance management app designed to help users track their income and expenses. Built with **Next.js**, **React**, and **Tailwind CSS**, the app is **PWA** (Progressive Web App)-enabled and can be easily self-hosted with Docker. +OopsBudgeter is a personal finance management app designed to help users track their income and expenses. Built with **Next.js**, **React**, and **Tailwind CSS**, the app is **PWA** (Progressive Web App)-enabled and can be easily self-hosted with Docker. + +*(**Why made it?** Because who doesn't want a budget app that makes them realize how broke they are? πŸ’ΈπŸ˜‚)* ## Features -- **Track income and expenses**: Easily manage your transactions with details like amount, description, and date. -- **PWA Support**: Works offline, and you can install it as a native app. -- **JWT-based authentication**: Secure your app with token-based authentication. -- **Customizable Currency**: Supports over 20 currencies. -- **Passcode Protection**: Add a passcode to protect access to the app and api. -- **Responsive**: Built using Tailwind CSS for a clean and modern design. +- **Track income and expenses**: Easily manage transactions with details like amount, description, category, and date. +- **Advanced Analytics Dashboard**: Gain insights into your financial trends with detailed graphs and FakeAI-powered insights. *Yes, we graphically display your bad decisions!* +- **FakeAI-Powered Insights πŸ€–πŸ“Š**: Automated spending analysis and financial recommendations (like, "Stop buying useless stuff!"). +- **No-Spend Streaks**: Tracks how many consecutive days you've avoided spending. *Good luck breaking your record past a week!* πŸ˜‚ +- **PWA Support**: Works offline, and you can install it as a native app. *Now you can check your tragic finances even without internet!* +- **JWT-based authentication**: Secure your app with token-based authentication. *Hackers want your money? Joke’s on them, you don’t have any!* +- **Customizable Currency**: Supports all ISO 4217 currencies. *Yes, even Monopoly money... but don’t ask why.* +- **Passcode Protection**: Add a passcode to protect access to the app and API. *As if your bank account isn’t already protecting itself.* +- **Responsive UI**: Built using Tailwind CSS for a clean and modern design. - **Docker support**: Easily deploy with Docker. +- **Data Export**: Download transactions in **CSV** or **JSON** format or print them to a **PDF** format. *Because your financial misery should be well-documented!* ## Methods and Technologies Used -- **Frontend**: - - **Next.js** 15, **React** 19 - - **Tailwind CSS** for styling - - **React Hook Form** for form handling - - **Zod** for form validation - - **JWT** for user authentication and security - - **Next-PWA** for Progressive Web App features +### **Frontend:** +- **Next.js 15**, **React 19** +- **Tailwind CSS** for styling +- **React Hook Form** for form handling +- **Zod** for form validation +- **Recharts** for dynamic financial visualizations +- **Next-PWA** for Progressive Web App features -- **Backend**: - - **Quick.db** for local data storage - - **JWT-based authentication** for securing the application +### **Backend:** +- **PostgreSQL** for database storage +- **JWT-based authentication** for securing the application +- **Drizzle ORM** for database management -- **Deployment**: - - **Docker** for containerization and easy deployment - - **GitHub Container Registry (GHCR)** for hosting Docker images +### **Deployment:** +- **Docker** for containerization and easy deployment ## Installation -### Install and Run via Docker +### **Install and Run via Docker** 1. **Clone the repository**: - ```bash git clone https://github.com/OopsApps/OopsBudgeter.git - cd budgeter + cd OopsBudgeter ``` - 2. **Build the Docker image**: - ```bash - docker build -t budgeter . + docker build -t OopsBudgeter . ``` - 3. **Run the Docker container**: - ```bash - docker run -p 3000:3000 budgeter + docker run -p 3000:3000 OopsBudgeter ``` - The app should now be accessible at `http://localhost:3000`. -### Build from Source +### **Build from Source** 1. **Clone the repository**: - ```bash git clone https://github.com/OopsApps/OopsBudgeter.git - cd budgeter + cd OopsBudgeter ``` - 2. **Install dependencies**: - ```bash bun install ``` - -3. **Set environment variables** (details below) in a `.env.local` file. - +3. **Set environment variables** in a `.env.local` file (see below). 4. **Build the app**: - ```bash bun run build ``` - 5. **Start the app**: - ```bash bun start ``` - The app should now be accessible at `http://localhost:3000`. ## Environment Variables -Make sure to create a `.env.local` file in the root directory and add the following environment variables: - -- **NEXT_PUBLIC_CURRENCY**: Set the default currency for the app (e.g., `USD`, `EUR`, `INR`, etc.) -- **PASSCODE**: Set a custom passcode to protect access to the app, 6 digits. -- **JWT_SECRET**: 32-token secret key used for signing JWT tokens. +Create a `.env.local` file in the root directory and add the following: -Example `.env.local` file: +```ini +NEXT_PUBLIC_CURRENCY=USD # Set your preferred currency +PASSCODE=123456 # 6-digit passcode for app security +JWT_SECRET=your-secure-jwt-secret # Secret key for JWT authentication +DATABASE_URL=your-postgresql-url # PostgreSQL database connection URL +``` ## Contributing @@ -121,9 +113,21 @@ This project is licensed under the **Apache License 2.0**. See the [LICENSE](LIC --- -### A simple workflow of the WApp: +## **How OopsBudgeter Works** + +1. **Track Income or Expenses**: Add transactions by selecting type, amount, description, category, and date. +2. **View Financial Analytics**: Get a breakdown of spending habits, trends, and AI-powered insights. *Aka: How much money you’ve wasted.* +3. **Monitor Your No-Spend Streak**: See how long you've gone without unnecessary expenses. *Spoiler alert: It won’t be long.* +4. **Print or Download Transactions**: Export transactions in **CSV** or **JSON** format. *So you can cry over them later.* +5. **Plan Ahead with Predictions**: View projected spending based on trends. *Basically, a sneak peek into your future regrets.* + +OopsBudgeter is your all-in-one finance tracker, **making sure you face your financial mistakes head-on! πŸš€πŸ˜‚** + +--- +### Disclaimer: About FakeAI Insights + +FakeAI is not a real artificial intelligence but rather a collection of predictive algorithms and calculations based on past data. It won’t pass the Turing test, but it will still judge your spending habits mercilessly πŸ˜‰ + +--- -1. **Track Income or Expenses**: You can add a transaction by choosing whether it's an income or an expense, entering the amount, description, and date. -2. **View Balance**: The app will keep track of your balance, and you can see your current balance in the dashboard. -3. **Sort Transactions**: Transactions can be sorted by **amount** or **date** (ascending or descending). -4. **Print or Download Transactions**: You can print your transactions list for offline use with a nice and modern look. +Thanks for checking out OopsBudgeter! We appreciate your time and hope this app helps you (or at least makes you laugh while realizing where all your money went). – The OopsApps Team πŸ’€ \ No newline at end of file diff --git a/bun.lockb b/bun.lockb index 1b62af3..a0d1d33 100644 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/changelog.md b/changelog.md new file mode 100644 index 0000000..4473ff9 --- /dev/null +++ b/changelog.md @@ -0,0 +1,57 @@ +# OopsBudgeter Changelog + +## v2.0.0 - The Ultimate Overhaul πŸš€πŸ”₯ +**Released: March 15, 2025** + +**Major Upgrade from v1.0.0: A Complete Revamp!** +We threw out the old, brought in the new, and gave OopsBudgeter a complete makeover! Say goodbye to QuickDB and hello to PostgreSQL, charts, advanced analytics, and a sleek UI 😏 + +### ✨ **New Features** +- **πŸš€ Migrated from QuickDB to PostgreSQL** for a scalable and robust database solution. +- **πŸ“Š Brand New Analytics Page!** + - **Expense Heatmap πŸ”₯** - Track your spending habits visually. + - **FakeAI Insights πŸ€–** - Smart(ish) spending analysis & predictions. + - **Net Worth Over Time πŸ“ˆ** - See your financial trajectory. + - **Category Trends 🌊** - Track expenses per category monthly. + - **Top Transactions πŸ’Έ** - Your biggest income/expenses at a glance. +- **πŸ” Advanced Filtering & Sorting** + - Filter transactions by **date range, category, type, and amount**. + - Sort by **newest, oldest, highest, lowest** with a click. +- **πŸ†• Enhanced Transaction Handling** + - Add/edit transactions with a **datetime picker** (ensures correct timestamps!). + - **New Categories** for better tracking of income & expenses. + - **Passcode Protection πŸ”’** - Secure your financial data. + - **Instant Toaster Notifications** for transaction actions. +- **πŸ“‚ Data Export:** Download transactions in **CSV or JSON** for backups. + +### πŸ”„ **Improvements & Refactors** +- **🎨 Revamped UI & UX** + - New modern **dark-themed UI**. + - Improved responsiveness for **desktop & mobile**. +- **♻️ Moved Balance Logic to Context API** for cleaner state management. +- **πŸš€ Optimized Docker Image** + - Switched to **multi-stage build** for a smaller and more efficient container. + - Added automatic **PostgreSQL migrations** on startup. +- **πŸ”§ Fixed Various Bugs** + - Password prompt **no longer flickers**. + - Fixed **date picker not updating correctly**. + - Fixed **sorting and filtering edge cases**. + +### πŸ’₯ **Breaking Changes** +- **Old QuickDB-based data is not compatible.** Migration to PostgreSQL is required. +- **New environment variables are needed:** + ```ini + DATABASE_URL=your-postgres-db-url + NEXT_PUBLIC_CURRENCY=USD + PASSCODE=123456 + JWT_SECRET=your-secure-jwt-secret + ``` + +### πŸ“’ **Next Steps** +- **Real AI-powered insights** (instead of FakeAI predictions πŸ˜‰) +- **Recurring Transactions & Budget Goals!** +- **Improved mobile experience.** + +--- +**Thanks for using OopsBudgeter – The OopsApps Team πŸ’œ** + diff --git a/docker-compose.yml b/docker-compose.yml index 044469e..ff9aa4e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,17 +1,18 @@ -version: "3" services: - oopsbudgeter: - image: ghcr.io/oopsapps/oopsbudgeter:latest + oops-budgeter: + image: iconical/oopsbudgeter:latest ports: - "3333:3000" environment: - NODE_ENV=production - - NEXT_PUBLIC_CURRENCY=USD - - PASSCODE=123123 - - JWT_SECRET=Ithinkyouforgottouseyourown32tokencode + - NEXT_PUBLIC_CURRENCY=USD # Set your preferred currency + - PASSCODE=123456 # 6-digit passcode for app security + - JWT_SECRET=your-secure-jwt-secret # Secret key for JWT authentication + - DATABASE_URL=your-postgresql-url # PostgreSQL database connection URL + networks: - - oopsbudgeter-network + - oops-budgeter-network networks: - oopsbudgeter-network: + oops-budgeter-network: driver: bridge diff --git a/drizzle.config.ts b/drizzle.config.ts new file mode 100644 index 0000000..7f92dc8 --- /dev/null +++ b/drizzle.config.ts @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2025 Laith Alkhaddam aka Iconical or Sleepyico. + * All rights reserved. + + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + + * http://www.apache.org/licenses/LICENSE-2.0 + + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import "dotenv/config"; +import { defineConfig } from "drizzle-kit"; + +export default defineConfig({ + out: "./drizzle", + schema: "./src/schema/dbSchema.ts", + dialect: "postgresql", + dbCredentials: { + url: process.env.DATABASE_URL!, + }, +}); diff --git a/drizzle/0000_melodic_purifiers.sql b/drizzle/0000_melodic_purifiers.sql new file mode 100644 index 0000000..1dfbc8a --- /dev/null +++ b/drizzle/0000_melodic_purifiers.sql @@ -0,0 +1,12 @@ +CREATE TABLE "balance" ( + "id" serial PRIMARY KEY NOT NULL, + "amount" real DEFAULT 0 NOT NULL +); +--> statement-breakpoint +CREATE TABLE "transactions" ( + "id" serial PRIMARY KEY NOT NULL, + "type" text NOT NULL, + "amount" real NOT NULL, + "description" text, + "date" timestamp DEFAULT now() +); diff --git a/drizzle/0001_yellow_thena.sql b/drizzle/0001_yellow_thena.sql new file mode 100644 index 0000000..def5fa1 --- /dev/null +++ b/drizzle/0001_yellow_thena.sql @@ -0,0 +1,5 @@ +CREATE TYPE "public"."TransactionType" AS ENUM('income', 'expense');--> statement-breakpoint +ALTER TABLE "transactions" ALTER COLUMN "type" SET DATA TYPE TransactionType;--> statement-breakpoint +ALTER TABLE "transactions" ALTER COLUMN "type" DROP NOT NULL;--> statement-breakpoint +ALTER TABLE "transactions" ALTER COLUMN "date" DROP DEFAULT;--> statement-breakpoint +ALTER TABLE "transactions" ALTER COLUMN "date" SET NOT NULL; \ No newline at end of file diff --git a/drizzle/0002_icy_phil_sheldon.sql b/drizzle/0002_icy_phil_sheldon.sql new file mode 100644 index 0000000..5149e61 --- /dev/null +++ b/drizzle/0002_icy_phil_sheldon.sql @@ -0,0 +1 @@ +ALTER TABLE "balance" RENAME TO "currentBalance"; \ No newline at end of file diff --git a/drizzle/0003_lonely_bill_hollister.sql b/drizzle/0003_lonely_bill_hollister.sql new file mode 100644 index 0000000..b60bfb7 --- /dev/null +++ b/drizzle/0003_lonely_bill_hollister.sql @@ -0,0 +1,4 @@ +CREATE TYPE "public"."ExpenseCategories" AS ENUM('Food', 'Rent', 'Utilities', 'Transport', 'Entertainment', 'Shopping', 'Other');--> statement-breakpoint +CREATE TYPE "public"."IncomeCategories" AS ENUM('Salary', 'Freelance', 'Investment', 'Bonus', 'Other');--> statement-breakpoint +DROP TABLE "currentBalance" CASCADE;--> statement-breakpoint +ALTER TABLE "transactions" ADD COLUMN "category" text; \ No newline at end of file diff --git a/drizzle/meta/0000_snapshot.json b/drizzle/meta/0000_snapshot.json new file mode 100644 index 0000000..649dd59 --- /dev/null +++ b/drizzle/meta/0000_snapshot.json @@ -0,0 +1,89 @@ +{ + "id": "e63dd89c-b041-48cb-b8ca-98dd2c1ca136", + "prevId": "00000000-0000-0000-0000-000000000000", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.balance": { + "name": "balance", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "real", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.transactions": { + "name": "transactions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "date": { + "name": "date", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/0001_snapshot.json b/drizzle/meta/0001_snapshot.json new file mode 100644 index 0000000..6e9d90c --- /dev/null +++ b/drizzle/meta/0001_snapshot.json @@ -0,0 +1,98 @@ +{ + "id": "8706f857-006e-48fb-9887-e2f38e0c49ad", + "prevId": "e63dd89c-b041-48cb-b8ca-98dd2c1ca136", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.balance": { + "name": "balance", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "real", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.transactions": { + "name": "transactions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "type": { + "name": "type", + "type": "TransactionType", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "amount": { + "name": "amount", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "date": { + "name": "date", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.TransactionType": { + "name": "TransactionType", + "schema": "public", + "values": [ + "income", + "expense" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/0002_snapshot.json b/drizzle/meta/0002_snapshot.json new file mode 100644 index 0000000..acc0fc4 --- /dev/null +++ b/drizzle/meta/0002_snapshot.json @@ -0,0 +1,98 @@ +{ + "id": "107362f2-b762-4112-ac7d-57cedc4b52cd", + "prevId": "8706f857-006e-48fb-9887-e2f38e0c49ad", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.currentBalance": { + "name": "currentBalance", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "real", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.transactions": { + "name": "transactions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "type": { + "name": "type", + "type": "TransactionType", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "amount": { + "name": "amount", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "date": { + "name": "date", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.TransactionType": { + "name": "TransactionType", + "schema": "public", + "values": [ + "income", + "expense" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/0003_snapshot.json b/drizzle/meta/0003_snapshot.json new file mode 100644 index 0000000..55f6fad --- /dev/null +++ b/drizzle/meta/0003_snapshot.json @@ -0,0 +1,102 @@ +{ + "id": "cbbec3c6-2fff-49c4-af24-3da316716fbd", + "prevId": "107362f2-b762-4112-ac7d-57cedc4b52cd", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.transactions": { + "name": "transactions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "type": { + "name": "type", + "type": "TransactionType", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "amount": { + "name": "amount", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "date": { + "name": "date", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.ExpenseCategories": { + "name": "ExpenseCategories", + "schema": "public", + "values": [ + "Food", + "Rent", + "Utilities", + "Transport", + "Entertainment", + "Shopping", + "Other" + ] + }, + "public.IncomeCategories": { + "name": "IncomeCategories", + "schema": "public", + "values": [ + "Salary", + "Freelance", + "Investment", + "Bonus", + "Other" + ] + }, + "public.TransactionType": { + "name": "TransactionType", + "schema": "public", + "values": [ + "income", + "expense" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json new file mode 100644 index 0000000..a756b65 --- /dev/null +++ b/drizzle/meta/_journal.json @@ -0,0 +1,34 @@ +{ + "version": "7", + "dialect": "postgresql", + "entries": [ + { + "idx": 0, + "version": "7", + "when": 1741735509151, + "tag": "0000_melodic_purifiers", + "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1741817547280, + "tag": "0001_yellow_thena", + "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1741819338790, + "tag": "0002_icy_phil_sheldon", + "breakpoints": true + }, + { + "idx": 3, + "version": "7", + "when": 1741921373765, + "tag": "0003_lonely_bill_hollister", + "breakpoints": true + } + ] +} \ No newline at end of file diff --git a/example.env b/example.env index 4e0722a..272846c 100644 --- a/example.env +++ b/example.env @@ -1,4 +1,4 @@ -NEXT_PUBLIC_CURRENCY=USD -NEXT_PUBLIC_URL=https://wow.damn.okay PASSCODE=123456 -JWT_SECRET=ermyouneeda32tokencodeherefrfr \ No newline at end of file +NEXT_PUBLIC_CURRENCY=USD +JWT_SECRET=ermyouneeda32tokencodeherefrfr +DATABASE_URL="postgresql://maybelikeauserhere?:andapasswordheretoo?@somerandomipiguess:aportlol/OopsBudgeterDBorsomerandomname?sslmode=require" \ No newline at end of file diff --git a/package.json b/package.json index ab06917..2e6705a 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,11 @@ { "name": "oops-budgeter", - "version": "1.0.1", + "version": "2.0.2", "private": true, "scripts": { "dev": "next dev --turbopack -p 3031", "build": "next build", + "build:db": "drizzle-kit migrate --config=drizzle.config.ts", "start": "next start", "lint": "next lint" }, @@ -12,26 +13,44 @@ "@ducanh2912/next-pwa": "^10.2.9", "@hookform/resolvers": "^4.1.3", "@iconify/react": "^5.2.0", + "@radix-ui/react-alert-dialog": "^1.1.6", "@radix-ui/react-dialog": "^1.1.6", + "@radix-ui/react-label": "^2.1.2", + "@radix-ui/react-popover": "^1.1.6", + "@radix-ui/react-scroll-area": "^1.2.3", + "@radix-ui/react-select": "^2.1.6", + "@radix-ui/react-slot": "^1.1.2", + "@radix-ui/react-tooltip": "^1.1.8", "better-sqlite3": "^11.8.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "date-fns": "^4.1.0", + "dotenv": "^16.4.7", + "drizzle-kit": "^0.30.5", + "drizzle-orm": "^0.40.0", + "drizzle-zod": "^0.7.0", + "file-saver": "^2.0.5", + "framer-motion": "^12.5.0", "input-otp": "^1.4.2", "jsonwebtoken": "^9.0.2", - "lucide-react": "^0.477.0", + "lucide-react": "^0.479.0", "mongoose": "^8.12.1", "motion": "^12.4.10", "mysql2": "^3.13.0", "next": "15.2.1", - "next-themes": "^0.4.4", + "next-themes": "^0.4.6", + "pg": "^8.14.0", "quick.db": "^9.1.7", "react": "^19.0.0", + "react-day-picker": "8.10.1", "react-dom": "^19.0.0", "react-hook-form": "^7.54.2", "react-to-print": "^3.0.5", + "recharts": "^2.15.1", "sonner": "^2.0.1", "tailwind-merge": "^3.0.2", "tailwindcss-animate": "^1.0.7", + "timescape": "^0.7.1", "tsx": "^4.19.3", "vaul": "^1.1.2", "write-file-atomic": "^6.0.0", @@ -40,10 +59,14 @@ "devDependencies": { "@eslint/eslintrc": "^3", "@tailwindcss/postcss": "^4", + "@types/date-fns": "^2.6.3", + "@types/file-saver": "^2.0.7", "@types/jsonwebtoken": "^9.0.9", "@types/node": "^20", + "@types/pg": "^8.11.11", "@types/react": "^19", "@types/react-dom": "^19", + "@types/recharts": "^1.8.29", "eslint": "^9", "eslint-config-next": "15.2.1", "tailwindcss": "^4", diff --git a/public/audio/delete.wav b/public/audio/delete.wav new file mode 100644 index 0000000..05e83aa Binary files /dev/null and b/public/audio/delete.wav differ diff --git a/public/audio/new-expense.wav b/public/audio/new-expense.wav new file mode 100644 index 0000000..15bab85 Binary files /dev/null and b/public/audio/new-expense.wav differ diff --git a/public/audio/new-income.wav b/public/audio/new-income.wav new file mode 100644 index 0000000..44e1668 Binary files /dev/null and b/public/audio/new-income.wav differ diff --git a/public/audio/ssss.mp3 b/public/audio/ssss.mp3 new file mode 100644 index 0000000..769f6f0 Binary files /dev/null and b/public/audio/ssss.mp3 differ diff --git a/public/logo_dark.png b/public/logo_dark.png new file mode 100644 index 0000000..ffc0891 Binary files /dev/null and b/public/logo_dark.png differ diff --git a/public/swe-worker-5c72df51bb1f6ee0.js b/public/swe-worker-5c72df51bb1f6ee0.js new file mode 100644 index 0000000..36e6e59 --- /dev/null +++ b/public/swe-worker-5c72df51bb1f6ee0.js @@ -0,0 +1 @@ +self.onmessage=async e=>{switch(e.data.type){case"__START_URL_CACHE__":{let t=e.data.url,a=await fetch(t);if(!a.redirected)return(await caches.open("start-url")).put(t,a);return Promise.resolve()}case"__FRONTEND_NAV_CACHE__":{let t=e.data.url,a=await caches.open("pages");if(await a.match(t,{ignoreSearch:!0}))return;let s=await fetch(t);if(!s.ok)return;if(a.put(t,s.clone()),e.data.shouldCacheAggressively&&s.headers.get("Content-Type")?.includes("text/html"))try{let e=await s.text(),t=[],a=await caches.open("static-style-assets"),r=await caches.open("next-static-js-assets"),c=await caches.open("static-js-assets");for(let[s,r]of e.matchAll(//g))/rel=['"]stylesheet['"]/.test(s)&&t.push(a.match(r).then(e=>e?Promise.resolve():a.add(r)));for(let[,a]of e.matchAll(//g)){let e=/\/_next\/static.+\.js$/i.test(a)?r:c;t.push(e.match(a).then(t=>t?Promise.resolve():e.add(a)))}return await Promise.all(t)}catch{}return Promise.resolve()}default:return Promise.resolve()}}; \ No newline at end of file diff --git a/src/app/analytics/page.tsx b/src/app/analytics/page.tsx new file mode 100644 index 0000000..8914f5f --- /dev/null +++ b/src/app/analytics/page.tsx @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2025 Laith Alkhaddam aka Iconical or Sleepyico. + * All rights reserved. + + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + + * http://www.apache.org/licenses/LICENSE-2.0 + + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React from "react"; +import AnalyticsWrapper from "@/components/common/Analytics"; +import { generateMetadata } from "@/lib/head"; + +export const metadata = generateMetadata({ + title: "Analytics", +}); + +export default function Analytics() { + return ; +} diff --git a/src/app/api/auth/itsme/route.ts b/src/app/api/auth/itsme/route.ts index 72daddf..c37174d 100644 --- a/src/app/api/auth/itsme/route.ts +++ b/src/app/api/auth/itsme/route.ts @@ -21,30 +21,29 @@ import { cookies } from "next/headers"; const SECRET = process.env.JWT_SECRET as string; -export const config = { - runtime: "nodejs", -}; - export async function GET() { - const token = (await cookies()).get("authToken"); - if (!token) { - return NextResponse.json({ message: "Not authenticated" }, { status: 401 }); - } - try { - const decoded = jwt.verify(token?.value, SECRET); - return NextResponse.json({ message: "Authenticated", user: decoded }); - } catch (err) { - if (err instanceof Error) { - return NextResponse.json( - { message: "Invalid or expired token", error: err.message }, - { status: 401 } - ); - } else { + const cookieStore = await cookies(); + const token = cookieStore.get("authToken")?.value; + + if (!token) { return NextResponse.json( - { message: "Invalid or expired token", error: "Unknown error" }, + { message: "Not authenticated" }, { status: 401 } ); } + + // Verify JWT Token + const decoded = jwt.verify(token, SECRET); + + return NextResponse.json({ + message: "Authenticated", + user: decoded, + }); + } catch (err) { + return NextResponse.json( + { message: "Invalid or expired token", error: (err as Error).message }, + { status: 401 } + ); } } diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index 50870c4..1e8f9f3 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -17,32 +17,42 @@ import { NextResponse } from "next/server"; import jwt from "jsonwebtoken"; +import { cookies } from "next/headers"; const SECRET = process.env.JWT_SECRET as string; +const PASSCODE = process.env.PASSCODE as string; export async function POST(request: Request) { - const { passcode } = await request.json(); + try { + const cookieStore = await cookies(); + const { passcode } = await request.json(); - const correctPasscode = process.env.PASSCODE; + if (passcode !== PASSCODE) { + return NextResponse.json( + { message: "Incorrect passcode" }, + { status: 401 } + ); + } - if (passcode === correctPasscode) { const token = jwt.sign({ user: "authenticated" }, SECRET, { expiresIn: "7d", }); - const response = NextResponse.json({ message: "Login successful" }); - response.cookies.set("authToken", token, { + cookieStore.set({ + name: "authToken", + value: token, httpOnly: true, secure: process.env.NODE_ENV === "production", sameSite: "strict", maxAge: 7 * 24 * 60 * 60, + path: "/", }); - return response; - } else { + return NextResponse.json({ message: "Login successful", token }); + } catch (error) { return NextResponse.json( - { message: "Incorrect passcode" }, - { status: 401 } + { message: "Server error", error: (error as Error).message }, + { status: 500 } ); } } diff --git a/src/app/api/auth/token/route.ts b/src/app/api/auth/token/route.ts new file mode 100644 index 0000000..83c1254 --- /dev/null +++ b/src/app/api/auth/token/route.ts @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2025 Laith Alkhaddam aka Iconical or Sleepyico. + * All rights reserved. + + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + + * http://www.apache.org/licenses/LICENSE-2.0 + + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { cookies } from "next/headers"; +import { NextResponse } from "next/server"; + +export async function GET() { + const cookieStore = await cookies(); + const token = cookieStore.get("authToken")?.value || null; + return NextResponse.json({ token }); +} diff --git a/src/app/api/transactions/route.ts b/src/app/api/transactions/route.ts index ba7047d..73b7286 100644 --- a/src/app/api/transactions/route.ts +++ b/src/app/api/transactions/route.ts @@ -1,13 +1,13 @@ /* * Copyright (c) 2025 Laith Alkhaddam aka Iconical or Sleepyico. * All rights reserved. - + * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at - + * * http://www.apache.org/licenses/LICENSE-2.0 - + * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @@ -15,208 +15,149 @@ * limitations under the License. */ -import { Transaction } from "@/types/Transaction"; +import { db } from "@/lib/db"; +import { transactions } from "@/schema/dbSchema"; +import { eq } from "drizzle-orm"; import { cookies } from "next/headers"; -import { NextResponse } from "next/server"; -import { QuickDB } from "quick.db"; +import { NextRequest, NextResponse } from "next/server"; import jwt from "jsonwebtoken"; +import { expenseCategories, incomeCategories } from "@/lib/categories"; -const db = new QuickDB(); const SECRET = process.env.JWT_SECRET as string; -const BASE_URL = process.env.NEXT_PUBLIC_URL as string; -export async function GET() { - const response = new NextResponse(); +async function verifyToken(req: NextRequest) { + const cookieStore = await cookies(); + const tokenFromCookie = cookieStore.get("authToken")?.value; - response.headers.set("Access-Control-Allow-Origin", BASE_URL); - response.headers.set("Access-Control-Allow-Methods", "GET, POST, DELETE"); - response.headers.set( - "Access-Control-Allow-Headers", - "Content-Type, Authorization" - ); + const authHeader = req.headers.get("Authorization"); + const tokenFromHeader = + authHeader && authHeader.startsWith("Bearer ") + ? authHeader.split(" ")[1] + : null; - const cookieStore = await cookies(); - const token = cookieStore.get("authToken"); + const token = tokenFromHeader || tokenFromCookie; - if (!token) { - return NextResponse.json({ message: "Unauthorized" }, { status: 401 }); - } + if (!token) return { authorized: false, error: "Unauthorized" }; try { - const decoded = jwt.verify(token.value, SECRET); + const decoded = jwt.verify(token, SECRET); + return { authorized: true, user: decoded }; + } catch (err) { + return { + authorized: false, + error: `Invalid or expired token, ${(err as Error).message}`, + }; + } +} - const transactions = await db.get("transactions"); - const currentBalance = await db.get("balance"); +export async function GET(req: NextRequest) { + const { authorized, user, error } = await verifyToken(req); - response.headers.set("Content-Type", "application/json"); + if (!authorized) { + return NextResponse.json({ message: error }, { status: 401 }); + } - return new NextResponse( - JSON.stringify({ - user: decoded, - transactions, - currentBalance, - }), - { status: 200 } - ); + try { + const transactionsList = await db.select().from(transactions); + return NextResponse.json({ + user, + transactions: transactionsList, + }); } catch (err) { - if (err instanceof Error) { - response.headers.set("Content-Type", "application/json"); - - return NextResponse.json( - { message: "Invalid or expired token", error: err.message }, - { status: 401 } - ); - } else { - response.headers.set("Content-Type", "application/json"); - return NextResponse.json( - { message: "Invalid or expired token", error: "Unknown error" }, - { status: 401 } - ); - } + return NextResponse.json( + { + message: "Failed to fetch transactions", + error: (err as Error).message, + }, + { status: 500 } + ); } } -export async function POST(request: Request) { - const response = new NextResponse(); - response.headers.set("Access-Control-Allow-Origin", BASE_URL); - response.headers.set("Access-Control-Allow-Methods", "GET, POST, DELETE"); - response.headers.set( - "Access-Control-Allow-Headers", - "Content-Type, Authorization" - ); +export async function POST(req: NextRequest) { + const { authorized, user, error } = await verifyToken(req); - const cookieStore = await cookies(); - const token = cookieStore.get("authToken"); - - if (!token) { - return NextResponse.json({ message: "Unauthorized" }, { status: 401 }); + if (!authorized) { + return NextResponse.json({ message: error }, { status: 401 }); } try { - const { type, amount, description, date }: Transaction = - await request.json(); + const { type, amount, description, date, category } = await req.json(); - let currentBalance = (await db.get("balance")) || 0; - - if (type === "income") { - currentBalance += amount; - } else if (type === "expense") { - currentBalance -= amount; + const validCategories = + type === "income" ? incomeCategories : expenseCategories; + if (!validCategories.includes(category)) { + return NextResponse.json( + { message: `Invalid category for type '${type}'` }, + { status: 400 } + ); } - let transactionId = (await db.get("transactionId")) || 0; - transactionId++; - - const transaction: Transaction = { - tid: transactionId, - type, - amount, - description: description || "No description provided", - date, - }; - - const transactions = (await db.get("transactions")) || []; - - transactions.push(transaction); - - await db.set("transactions", transactions); - await db.set("transactionId", transactionId); - - await db.set("balance", currentBalance); - - return new NextResponse( - JSON.stringify({ + const newTransaction = await db + .insert(transactions) + .values({ + type, + amount, + description: description || "", + date, + category, + }) + .returning(); + + return NextResponse.json( + { + user, message: "Transaction added", - transaction, - currentBalance, - }), + transaction: newTransaction[0], + }, { status: 201 } ); } catch (err) { - if (err instanceof Error) { - response.headers.set("Content-Type", "application/json"); - return NextResponse.json( - { message: "Failed to add a new transaction:", error: err.message }, - { status: 401 } - ); - } else { - response.headers.set("Content-Type", "application/json"); - return NextResponse.json( - { message: "Failed to add a new transaction:", error: "Unknown error" }, - { status: 401 } - ); - } + return NextResponse.json( + { message: "Failed to add transaction", error: (err as Error).message }, + { status: 500 } + ); } } -export async function DELETE(request: Request) { - const response = new NextResponse(); - - response.headers.set("Access-Control-Allow-Origin", BASE_URL); - response.headers.set("Access-Control-Allow-Methods", "GET, POST, DELETE"); - response.headers.set( - "Access-Control-Allow-Headers", - "Content-Type, Authorization" - ); - - const cookieStore = await cookies(); - const token = cookieStore.get("authToken"); +export async function DELETE(req: NextRequest) { + const { authorized, user, error } = await verifyToken(req); - if (!token) { - return NextResponse.json({ message: "Unauthorized" }, { status: 401 }); + if (!authorized) { + return NextResponse.json({ message: error }, { status: 401 }); } try { - const { tid }: { tid: number } = await request.json(); + const { id } = await req.json(); - const transactions = (await db.get("transactions")) || []; + const transactionToDelete = await db + .select() + .from(transactions) + .where(eq(transactions.id, id)); - const transactionToDelete = transactions.find( - (transaction: Transaction) => transaction.tid === tid - ); - - if (!transactionToDelete) { - return new NextResponse( - JSON.stringify({ message: "Transaction not found" }), + if (!transactionToDelete.length) { + return NextResponse.json( + { message: "Transaction not found" }, { status: 404 } ); } - const updatedTransactions = transactions.filter( - (transaction: Transaction) => transaction.tid !== tid - ); - - let currentBalance = await db.get("balance"); - - if (transactionToDelete.type === "income") { - currentBalance -= transactionToDelete.amount; - } else if (transactionToDelete.type === "expense") { - currentBalance += transactionToDelete.amount; - } - - await db.set("transactions", updatedTransactions); - await db.set("balance", currentBalance); + await db.delete(transactions).where(eq(transactions.id, id)); - return new NextResponse( - JSON.stringify({ + return NextResponse.json( + { + user, message: "Transaction deleted successfully", - currentBalance, - }), + }, { status: 200 } ); } catch (err) { - if (err instanceof Error) { - response.headers.set("Content-Type", "application/json"); - return NextResponse.json( - { message: "Deletion was not successful", error: err.message }, - { status: 401 } - ); - } else { - response.headers.set("Content-Type", "application/json"); - return NextResponse.json( - { message: "Deletion was not successful", error: "Unknown error" }, - { status: 401 } - ); - } + return NextResponse.json( + { + message: "Failed to delete transaction", + error: (err as Error).message, + }, + { status: 500 } + ); } } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 27c40b7..7d15ffd 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -14,13 +14,17 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import type { Metadata, Viewport } from "next"; +import type { Viewport } from "next"; import { Geist, Geist_Mono } from "next/font/google"; import "./globals.css"; -import { ThemeProvider } from "@/components/providers/theme-provider"; -import { BalanceProvider } from "@/contexts/BalanceContext"; +import { ThemeProvider } from "@/components/providers/ThemeProvider"; import PasscodeWrapper from "@/components/security/PasscodeWrapper"; -import { og } from "@/lib/head"; +import GoToTop from "@/components/helpers/GoToTop"; +import { BudgetProvider } from "@/contexts/BudgetContext"; +import Toaster from "@/components/effects/Sonner"; +import { ThemeToggle } from "@/components/common/ThemeToggle"; +import Logo from "@/components/common/Logo"; +import { generateMetadata } from "@/lib/head"; const geistSans = Geist({ variable: "--font-geist-sans", @@ -32,7 +36,7 @@ const geistMono = Geist_Mono({ subsets: ["latin"], }); -export const metadata: Metadata = og; +export const metadata = generateMetadata; export const viewport: Viewport = { initialScale: 1, @@ -48,9 +52,14 @@ export default function RootLayout({ children: React.ReactNode; }>) { return ( - + - - -
- {children} + + +
+
+ + + {children} +
-
- + + + + diff --git a/src/app/page.tsx b/src/app/page.tsx index 65cee51..1fe250e 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,23 +1,27 @@ import BalanceCard from "@/components/cards/BalanceCard"; -import Expense from "@/components/extra/Expense"; -import Income from "@/components/extra/Income"; -import NewTransaction from "@/components/extra/NewTransaction"; -import { ThemeToggle } from "@/components/extra/ThemeToggle"; -import TransactionsList from "@/components/extra/TransactionsList"; +import Expense from "@/components/categories/Expense"; +import Income from "@/components/categories/Income"; +import DateRangePicker from "@/components/common/DatePicker"; +import NewTransaction from "@/components/transactions/NewTransaction"; +import TransactionsList from "@/components/transactions/TransactionsList"; +import { generateMetadata } from "@/lib/head"; + +export const metadata = generateMetadata({ + title: "Dashboard", +}); export default function Home() { return ( -
-

OopsBudgeter - For people who try to budget

- +
-
+
+
); diff --git a/src/components/cards/BalanceCard.tsx b/src/components/cards/BalanceCard.tsx index 5cee6f8..a16fea7 100644 --- a/src/components/cards/BalanceCard.tsx +++ b/src/components/cards/BalanceCard.tsx @@ -16,21 +16,30 @@ */ "use client"; -import React from "react"; +import React, { useEffect, useState } from "react"; import HoverEffect from "../effects/HoverEffect"; -import PriceDisplay from "../extra/Currency"; -import { useBalance } from "@/contexts/BalanceContext"; +import PriceDisplay from "../common/Currency"; +import { useBudget } from "@/contexts/BudgetContext"; +import { motion } from "framer-motion"; export default function BalanceCard() { - const { currentBalance } = useBalance(); + const { balance } = useBudget(); + const [animateText, setAnimateText] = useState(false); + useEffect(() => { + setAnimateText(true); + setTimeout(() => setAnimateText(false), 500); + }, [balance]); return (

Balance

- - - + + +
); diff --git a/src/components/cards/InExCard.tsx b/src/components/cards/InExCard.tsx index 6a700d0..76812a0 100644 --- a/src/components/cards/InExCard.tsx +++ b/src/components/cards/InExCard.tsx @@ -15,20 +15,37 @@ * limitations under the License. */ -import React from "react"; +import React, { useEffect, useState } from "react"; import HoverEffect from "../effects/HoverEffect"; -import PriceDisplay from "../extra/Currency"; +import PriceDisplay from "../common/Currency"; import { cn } from "@/lib/utils"; +import { motion } from "framer-motion"; +import { Icon } from "@iconify/react"; interface InExCardProps { title: "Expenses" | "Income"; amount: number; + onClick?: () => void; + className?: string; + iconClassName?: string; } -export default function InExCard({ amount, title }: Readonly) { +export default function InExCard({ + amount, + title, + onClick, + className, + iconClassName, +}: Readonly) { + const [animateText, setAnimateText] = useState(false); + useEffect(() => { + setAnimateText(true); + setTimeout(() => setAnimateText(false), 500); + }, [amount]); + return ( ) { title === "Expenses" ? "border-[#e24444]" : "border-[#166d3b]" )} > -

{title}

- +

+ {title} + +

+ -
+
); diff --git a/src/components/cards/TxCard.tsx b/src/components/cards/TxCard.tsx new file mode 100644 index 0000000..f937d10 --- /dev/null +++ b/src/components/cards/TxCard.tsx @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2025 Laith Alkhaddam aka Iconical or Sleepyico. + * All rights reserved. + + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + + * http://www.apache.org/licenses/LICENSE-2.0 + + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +"use client"; + +import { cn } from "@/lib/utils"; +import React from "react"; +import { motion } from "framer-motion"; + +interface TxCardProps { + className?: string; + children: React.ReactNode; + bgColor?: string; + onClick?: () => void; + role?: string; +} + +export default function TxCard({ + className, + children, + bgColor = "linear-gradient(135deg, #1c4dac, #1b8adf, #25a6ce, #30ba7c)", + onClick, + role, +}: Readonly) { + return ( + + {children} + + ); +} diff --git a/src/components/categories/Expense.tsx b/src/components/categories/Expense.tsx new file mode 100644 index 0000000..bbeea34 --- /dev/null +++ b/src/components/categories/Expense.tsx @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2025 Laith Alkhaddam aka Iconical or Sleepyico. + * All rights reserved. + + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + + * http://www.apache.org/licenses/LICENSE-2.0 + + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +"use client"; + +import InExCard from "../cards/InExCard"; +import { useBudget } from "@/contexts/BudgetContext"; + +export default function Expense() { + const { totalExpense, transactionTypeFilter, filterByType } = useBudget(); + + return ( + + filterByType(transactionTypeFilter === "expense" ? "all" : "expense") + } + className={`${transactionTypeFilter === "expense" && "bg-red-500"}`} + iconClassName={`${ + transactionTypeFilter === "expense" && "text-[#e24444]" + }`} + /> + ); +} diff --git a/src/components/categories/Income.tsx b/src/components/categories/Income.tsx new file mode 100644 index 0000000..f22b9ca --- /dev/null +++ b/src/components/categories/Income.tsx @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2025 Laith Alkhaddam aka Iconical or Sleepyico. + * All rights reserved. + + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + + * http://www.apache.org/licenses/LICENSE-2.0 + + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +"use client"; + +import InExCard from "../cards/InExCard"; +import { useBudget } from "@/contexts/BudgetContext"; + +export default function Income() { + const { totalIncome, transactionTypeFilter, filterByType } = useBudget(); + + return ( + + filterByType(transactionTypeFilter === "income" ? "all" : "income") + } + className={`${transactionTypeFilter === "income" && "bg-green-500"}`} + iconClassName={`${ + transactionTypeFilter === "income" && "text-[#35C774]" + }`} + /> + ); +} diff --git a/src/components/common/Analytics.tsx b/src/components/common/Analytics.tsx new file mode 100644 index 0000000..3aff194 --- /dev/null +++ b/src/components/common/Analytics.tsx @@ -0,0 +1,478 @@ +/* + * Copyright (c) 2025 Laith Alkhaddam aka Iconical or Sleepyico. + * All rights reserved. + + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + + * http://www.apache.org/licenses/LICENSE-2.0 + + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +"use client"; + +import { useBudget } from "@/contexts/BudgetContext"; +import { getMonthlyTrends } from "@/lib/monthlyTrends"; +import { Icon } from "@iconify/react"; +import { format } from "date-fns"; +import React from "react"; +import * as Recharts from "recharts"; +import PriceDisplay from "./Currency"; +import { + getNoSpendStreak, + getSpendingInsights, + predictNextMonthSpending, +} from "@/lib/my1DollarAI"; + +export default function AnalyticsWrapper() { + const { transactions } = useBudget(); + const monthlyTrend = getMonthlyTrends(transactions); + + const COLORS = [ + "#2DAC64", + "#E24444", + "#FFB02E", + "#0088FE", + "#FF6347", + "#A155B9", + "#45C4B0", + "#E4D374", + ]; + + const incomeData = transactions.filter((trx) => trx.type === "income"); + const expenseData = transactions.filter((trx) => trx.type === "expense"); + + const totalIncome = incomeData.reduce((acc, trx) => acc + trx.amount, 0); + const totalExpenses = expenseData.reduce((acc, trx) => acc + trx.amount, 0); + + const categoryTotals = transactions + .filter((trx) => trx.type === "expense") + .reduce((acc: Record, trx) => { + const category = trx.category ?? "Uncategorized"; + + if (!acc[category]) acc[category] = 0; + acc[category] += trx.amount; + + return acc; + }, {}); + + const pieChartData = Object.entries(categoryTotals).map( + ([category, amount], index) => ({ + name: category, + value: amount, + color: COLORS[index % COLORS.length], + }) + ); + + const netWorthTrend = transactions.reduce( + (acc: Record, trx) => { + const month = format(new Date(trx.date), "MMM yyy"); + if (!acc[month]) acc[month] = { name: month, balance: 0 }; + acc[month].balance += trx.type === "income" ? trx.amount : -trx.amount; + return acc; + }, + {} + ); + const netWorthData = Object.values(netWorthTrend).sort( + (a, b) => new Date(a.name).getTime() - new Date(b.name).getTime() + ); + + const savingsRate = + totalIncome > 0 ? ((totalIncome - totalExpenses) / totalIncome) * 100 : 0; + + const monthsTracked = new Set( + transactions + .filter((trx) => trx.date) + .map((trx) => format(new Date(trx.date), "MMM yyy")) + ).size; + const avgSpending = monthsTracked > 0 ? totalExpenses / monthsTracked : 0; + + const categoryTrends = transactions + .filter((trx) => trx.type === "expense") + .reduce((acc: Record>, trx) => { + const month = format(new Date(trx.date), "MMM yyyy"); + + if (!acc[month]) acc[month] = {}; // βœ… No more `{ name: string }` in numeric data + + acc[month][trx.category ?? "Uncategorized"] = + (acc[month][trx.category ?? "Uncategorized"] || 0) + trx.amount; + + return acc; + }, {}); + + const monthlyCategoryTrends = Object.entries(categoryTrends).map( + ([month, values]) => ({ + name: month, + ...values, + }) + ); + + const topTransactions = [...transactions] + .sort((a, b) => b.amount - a.amount) + .slice(0, 5); + + return ( +
+
+

+ + OopsStats + fakeAI +

+ + In this page we break down your finances so you can pretend to have + control over them. See exactly how much you spent on β€˜necessary’ + impulse buys and why your bank account is crying. No refunds, just + regrets! + +
+ +
+

πŸ’° Savings Rate

+

+ {savingsRate.toFixed(2)}% +

+ {savingsRate < 20 && ( +

Bruh, stop spending πŸ˜±πŸ’Έ

+ )} +
+ +
+
+

πŸ“‰ Average Monthly Spending

+

+ +

+
+ +
+

πŸ”₯ No-Spend Streak

+

+ {getNoSpendStreak(transactions)} +

+
+ +
+

+ πŸ’Έ Biggest Spending Category +

+

+ {getSpendingInsights(transactions)} +

+
+ +
+

+ πŸ“Š Next Months Predicted Spending +

+

+ {predictNextMonthSpending(transactions)} +

+
+
+ +
+

πŸ’° Income vs. Expenses

+ + + + + { + if (active && payload && payload.length) { + return ( +
+

+ {payload[0]?.payload?.name ?? "Unknown"} +

+ {payload.map((entry, index) => { + const key = String(entry.dataKey); + const amount = + typeof entry.value === "number" + ? entry.value.toFixed(2) + : "0.00"; + return ( +

+ + {key.charAt(0).toUpperCase() + key.slice(1)}: + + +

+ ); + })} +
+ ); + } + return null; + }} + /> + + + +
+
+
+ +
+

πŸ’Έ Spending Breakdown

+ + + + {pieChartData.map((entry, index) => ( + + ))} + + { + if (active && payload && payload.length) { + const data = payload[0].payload; + return ( +
+

+ {data.name ?? "Uncategorized"} +

+

+ πŸ’° Total Spent:{" "} + +

+
+ ); + } + return null; + }} + /> +
+
+
+ +
+

πŸ“ˆ Monthly Trends

+ + + + + { + if (active && payload && payload.length) { + return ( +
+

+ {payload[0]?.payload?.name ?? "Unknown Month"} +

+ {payload.map((entry, index) => { + const key = String(entry.dataKey); + const amount = + typeof entry.value === "number" + ? entry.value.toFixed(2) + : "0.00"; + return ( +

+ + {key.charAt(0).toUpperCase() + key.slice(1)}: + + +

+ ); + })} +
+ ); + } + return null; + }} + /> + + + +
+
+
+ +
+

+ 🌊 Expense Trends by Category +

+ + + + + { + if (active && payload && payload.length) { + return ( +
+

+ {payload[0]?.payload?.name ?? "Unknown Month"} +

+ {payload.map((entry, index) => { + const key = String(entry.dataKey); + const amount = + typeof entry.value === "number" + ? entry.value.toFixed(2) + : "0.00"; + const color = entry.color || "#FFFFFF"; // βœ… Get color from Recharts or default white + + return ( +

+ + + + {key.charAt(0).toUpperCase() + key.slice(1)}: + + + +

+ ); + })} +
+ ); + } + return null; + }} + /> + + {Object.keys( + categoryTrends[Object.keys(categoryTrends)[0]] ?? {} + ).map((cat, idx) => ( + + ))} +
+
+
+ +
+

+ πŸ’Έ Top 5 Biggest Transactions +

+ + + + + { + if (active && payload && payload.length) { + const data = payload[0].payload; + return ( +
+

+ πŸ’° Amount:{" "} + +

+

πŸ“‚ Category: {data.category ?? "Uncategorized"}

+

{data.description}

+
+ ); + } + return null; + }} + /> + + +
+
+
+ +
+

πŸ“ˆ Net Worth Over Time

+ + + + + { + if (active && payload && payload.length) { + const data = payload[0].payload; + return ( +
+

{data.name}

+

+ πŸ“Š Balance:{" "} + +

+
+ ); + } + return null; + }} + />{" "} + + +
+
+
+
+ ); +} diff --git a/src/components/extra/Currency.tsx b/src/components/common/Currency.tsx similarity index 77% rename from src/components/extra/Currency.tsx rename to src/components/common/Currency.tsx index 1f5f57d..c9a738a 100644 --- a/src/components/extra/Currency.tsx +++ b/src/components/common/Currency.tsx @@ -16,15 +16,21 @@ */ const currencyCode = process.env.NEXT_PUBLIC_CURRENCY || "USD"; -const formatCurrency = (amount: number, currency: string) => { +export const formatCurrency = (amount: number, currency: string) => { return new Intl.NumberFormat("en-US", { style: "currency", currency: currency, }).format(amount); }; -export default function PriceDisplay({ amount }: { amount: number }) { +export default function PriceDisplay({ + amount, + className, +}: { + amount: number; + className?: string; +}) { const currency = currencyCode; - return
{formatCurrency(amount, currency)}
; + return {formatCurrency(amount, currency)}; } diff --git a/src/components/common/DatePicker.tsx b/src/components/common/DatePicker.tsx new file mode 100644 index 0000000..a186fd8 --- /dev/null +++ b/src/components/common/DatePicker.tsx @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2025 Laith Alkhaddam aka Iconical or Sleepyico. + * All rights reserved. + + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + + * http://www.apache.org/licenses/LICENSE-2.0 + + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +"use client"; + +import { useBudget } from "@/contexts/BudgetContext"; +import { format } from "date-fns"; +import HoverEffect from "../effects/HoverEffect"; + +export default function DateRangePicker() { + const { startDate, endDate, setDateRange } = useBudget(); + + return ( + + setDateRange(new Date(e.target.value), endDate)} + className="border p-2 rounded-md text-base" + /> + + {format(startDate, "MMM d")} +

-

+ {format(endDate, "MMM d")} +
+

-

+ setDateRange(startDate, new Date(e.target.value))} + className="border p-2 rounded-md text-base" + /> +
+ ); +} diff --git a/src/components/common/Logo.tsx b/src/components/common/Logo.tsx new file mode 100644 index 0000000..ea3b0d0 --- /dev/null +++ b/src/components/common/Logo.tsx @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2025 Laith Alkhaddam aka Iconical or Sleepyico. + * All rights reserved. + + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + + * http://www.apache.org/licenses/LICENSE-2.0 + + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +"use client"; + +import React from "react"; +import { useTheme } from "next-themes"; +import Image from "next/image"; +import { useRouter } from "next/navigation"; +export default function Logo() { + const { theme } = useTheme(); + const router = useRouter(); + + return ( + router.push("/")} + > + OopsBudgeter Logo +

OopsBudgeter

+
+ ); +} diff --git a/src/components/extra/ThemeToggle.tsx b/src/components/common/ThemeToggle.tsx similarity index 96% rename from src/components/extra/ThemeToggle.tsx rename to src/components/common/ThemeToggle.tsx index e80646d..0690ea4 100644 --- a/src/components/extra/ThemeToggle.tsx +++ b/src/components/common/ThemeToggle.tsx @@ -21,7 +21,7 @@ import * as React from "react"; import { useTheme } from "next-themes"; import HoverEffect from "../effects/HoverEffect"; -import { Icon } from "@iconify/react/dist/iconify.js"; +import { Icon } from "@iconify/react"; export function ThemeToggle() { const { theme, setTheme } = useTheme(); diff --git a/src/components/effects/HoverEffect.tsx b/src/components/effects/HoverEffect.tsx index 68b17df..f2c3c55 100644 --- a/src/components/effects/HoverEffect.tsx +++ b/src/components/effects/HoverEffect.tsx @@ -27,6 +27,7 @@ interface HoverEffectProps { bgColor?: string; onClick?: () => void; role?: string; + isRounded?: boolean; } export default function HoverEffect({ @@ -36,13 +37,14 @@ export default function HoverEffect({ bgColor = "linear-gradient(135deg, #1c4dac, #1b8adf, #25a6ce, #30ba7c)", onClick, role, + isRounded = false, }: Readonly) { const [mouse, parentRef] = useMouse(); return (
-
+
{children}
); diff --git a/src/components/effects/Sonner.tsx b/src/components/effects/Sonner.tsx new file mode 100644 index 0000000..662cbae --- /dev/null +++ b/src/components/effects/Sonner.tsx @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2025 Laith Alkhaddam aka Iconical or Sleepyico. + * All rights reserved. + + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + + * http://www.apache.org/licenses/LICENSE-2.0 + + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from "react"; +import { Icon } from "@iconify/react"; +import { Toaster as Sonner } from "../ui/sonner"; + +export default function Toaster() { + return ( +
+ + ), + info: ( + + ), + warning: ( + + ), + error: ( + + ), + loading: , + }} + /> +
+ ); +} diff --git a/src/components/extra/Expense.tsx b/src/components/extra/Expense.tsx deleted file mode 100644 index 699c72b..0000000 --- a/src/components/extra/Expense.tsx +++ /dev/null @@ -1,53 +0,0 @@ -/* - * Copyright (c) 2025 Laith Alkhaddam aka Iconical or Sleepyico. - * All rights reserved. - - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - - * http://www.apache.org/licenses/LICENSE-2.0 - - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -"use client"; - -import { useEffect, useState } from "react"; -import InExCard from "../cards/InExCard"; -import { useBalance } from "@/contexts/BalanceContext"; - -export default function Expense() { - const [totalExpense, setTotalExpense] = useState(0); - const { currentBalance } = useBalance(); - - useEffect(() => { - const fetchTotalExpenses = async () => { - try { - const response = await fetch("/api/transactions"); - const data = await response.json(); - - const expenses = data.transactions.filter( - (transaction: { type: string; amount: number }) => - transaction.type === "expense" - ); - - const total = expenses.reduce( - (acc: number, curr: { amount: number }) => acc + curr.amount, - 0 - ); - - setTotalExpense(total); - } catch (error) { - console.error("Error fetching transactions:", error); - } - }; - - fetchTotalExpenses(); - }, [currentBalance]); - - return ; -} diff --git a/src/components/extra/Income.tsx b/src/components/extra/Income.tsx deleted file mode 100644 index a0a4802..0000000 --- a/src/components/extra/Income.tsx +++ /dev/null @@ -1,53 +0,0 @@ -/* - * Copyright (c) 2025 Laith Alkhaddam aka Iconical or Sleepyico. - * All rights reserved. - - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - - * http://www.apache.org/licenses/LICENSE-2.0 - - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -"use client"; - -import { useEffect, useState } from "react"; -import InExCard from "../cards/InExCard"; -import { useBalance } from "@/contexts/BalanceContext"; - -export default function Income() { - const [totalIncome, setTotalIncome] = useState(0); - const { currentBalance } = useBalance(); - - useEffect(() => { - const fetchTotalExpenses = async () => { - try { - const response = await fetch("/api/transactions"); - const data = await response.json(); - - const expenses = data.transactions.filter( - (transaction: { type: string; amount: number }) => - transaction.type === "income" - ); - - const total = expenses.reduce( - (acc: number, curr: { amount: number }) => acc + curr.amount, - 0 - ); - - setTotalIncome(total); - } catch (error) { - console.error("Error fetching transactions:", error); - } - }; - - fetchTotalExpenses(); - }, [currentBalance]); - - return ; -} diff --git a/src/components/extra/NewTransaction.tsx b/src/components/extra/NewTransaction.tsx deleted file mode 100644 index 89f42c5..0000000 --- a/src/components/extra/NewTransaction.tsx +++ /dev/null @@ -1,195 +0,0 @@ -/* - * Copyright (c) 2025 Laith Alkhaddam aka Iconical or Sleepyico. - * All rights reserved. - - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - - * http://www.apache.org/licenses/LICENSE-2.0 - - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -"use client"; - -import React, { useState } from "react"; -import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer"; -import HoverEffect from "../effects/HoverEffect"; -import { Icon } from "@iconify/react"; -import { transactionSchema } from "@/schema/transactionForm"; -import { useForm } from "react-hook-form"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { useBalance } from "@/contexts/BalanceContext"; -import { Input } from "../ui/input"; -import { Textarea } from "../ui/textarea"; - -interface TransactionFormData { - type: "income" | "expense"; - amount: number; - description?: string; - date?: string; -} - -export default function NewTransaction() { - const [successMessage, setSuccessMessage] = useState(""); - const { setCurrentBalance } = useBalance(); - const [type, setType] = useState<"income" | "expense">("income"); - const [popupVisible, setPopupVisible] = useState(false); - - const handleToggle = (selectedType: "income" | "expense") => { - console.log("Selected type:", selectedType); - setType(selectedType); - setValue("type", selectedType); - }; - - const showPopup = () => { - setPopupVisible(true); - setTimeout(() => { - setPopupVisible(false); - }, 3000); - }; - - const { - register, - handleSubmit, - formState: { errors }, - setValue, - reset, - } = useForm({ - resolver: zodResolver(transactionSchema), - defaultValues: { - date: new Date().toISOString().slice(0, 16), - }, - }); - - const onSubmit = async (data: TransactionFormData) => { - const formData = { ...data }; - - try { - const response = await fetch("/api/transactions", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(formData), - }); - - const result = await response.json(); - if (response.ok) { - setSuccessMessage("Transaction added successfully!"); - setCurrentBalance(result.currentBalance); - reset(); - showPopup(); - } else { - setSuccessMessage(`Error: ${result.message}`); - showPopup(); - } - } catch (err) { - setSuccessMessage(`Something went wrong: ${err}`); - showPopup(); - } - }; - - return ( - - - - - New Transaction - - - -
-
-
- - -
- - - -
-
- - - value === "" ? 0 : parseFloat(value), - })} - /> -
-
- -