├── .gitignore
├── frontend
├── src
│ ├── index.css
│ ├── utils
│ │ ├── cn.js
│ │ └── formatDate.js
│ ├── components
│ │ ├── ui
│ │ │ ├── GridBackgroun.jsx
│ │ │ └── Header.jsx
│ │ ├── InputField.jsx
│ │ ├── skeletons
│ │ │ └── TransactionFormSkeleton.jsx
│ │ ├── Cards.jsx
│ │ ├── RadioButton.jsx
│ │ ├── Card.jsx
│ │ └── TransactionForm.jsx
│ ├── graphql
│ │ ├── mutations
│ │ │ ├── user.mutation.js
│ │ │ └── transcation.mutation.js
│ │ └── queries
│ │ │ ├── user.query.js
│ │ │ └── transaction.query.js
│ ├── pages
│ │ ├── NotFoundPage.jsx
│ │ ├── LoginPage.jsx
│ │ ├── SignUpPage.jsx
│ │ ├── HomePage.jsx
│ │ └── TransactionPage.jsx
│ ├── main.jsx
│ └── App.jsx
├── postcss.config.js
├── vite.config.js
├── .gitignore
├── index.html
├── README.md
├── .eslintrc.cjs
├── package.json
├── public
│ ├── vite.svg
│ └── 404.svg
└── tailwind.config.js
├── backend
├── resolvers
│ ├── index.js
│ ├── user.resolver.js
│ └── transaction.resolver.js
├── db
│ └── connectDB.js
├── models
│ ├── user.model.js
│ └── transaction.model.js
├── typeDefs
│ ├── user.typeDef.js
│ ├── index.js
│ └── transaction.typeDef.js
├── cron.js
├── passport
│ └── passport.config.js
├── dummyData
│ └── data.js
└── index.js
├── package.json
├── README.md
├── NOTES.md
└── COPY-PASTE.md
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | .env
--------------------------------------------------------------------------------
/frontend/src/index.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
--------------------------------------------------------------------------------
/frontend/postcss.config.js:
--------------------------------------------------------------------------------
1 | export default {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | }
7 |
--------------------------------------------------------------------------------
/frontend/src/utils/cn.js:
--------------------------------------------------------------------------------
1 | import { clsx } from "clsx";
2 | import { twMerge } from "tailwind-merge";
3 |
4 | export function cn(...inputs) {
5 | return twMerge(clsx(inputs));
6 | }
7 |
--------------------------------------------------------------------------------
/frontend/vite.config.js:
--------------------------------------------------------------------------------
1 | import { defineConfig } from "vite";
2 | import react from "@vitejs/plugin-react";
3 |
4 | // https://vitejs.dev/config/
5 | export default defineConfig({
6 | plugins: [react()],
7 | server: {
8 | port: 3000,
9 | },
10 | });
11 |
--------------------------------------------------------------------------------
/backend/resolvers/index.js:
--------------------------------------------------------------------------------
1 | import { mergeResolvers } from "@graphql-tools/merge";
2 |
3 | import userResolver from "./user.resolver.js";
4 | import transactionResolver from "./transaction.resolver.js";
5 |
6 | const mergedResolvers = mergeResolvers([userResolver, transactionResolver]);
7 |
8 | export default mergedResolvers;
9 |
--------------------------------------------------------------------------------
/backend/db/connectDB.js:
--------------------------------------------------------------------------------
1 | import mongoose from "mongoose";
2 |
3 | export const connectDB = async () => {
4 | try {
5 | const conn = await mongoose.connect(process.env.MONGO_URI);
6 | console.log(`MongoDB Connected: ${conn.connection.host}`);
7 | } catch (err) {
8 | console.error(`Error: ${err.message}`);
9 | process.exit(1);
10 | }
11 | };
12 |
--------------------------------------------------------------------------------
/frontend/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | node_modules
11 | dist
12 | dist-ssr
13 | *.local
14 |
15 | # Editor directories and files
16 | .vscode/*
17 | !.vscode/extensions.json
18 | .idea
19 | .DS_Store
20 | *.suo
21 | *.ntvs*
22 | *.njsproj
23 | *.sln
24 | *.sw?
25 |
--------------------------------------------------------------------------------
/frontend/src/components/ui/GridBackgroun.jsx:
--------------------------------------------------------------------------------
1 | const GridBackground = ({ children }) => {
2 | return (
3 |
7 | );
8 | };
9 | export default GridBackground;
10 |
--------------------------------------------------------------------------------
/frontend/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Vite + React
8 |
9 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/frontend/README.md:
--------------------------------------------------------------------------------
1 | # React + Vite
2 |
3 | This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
4 |
5 | Currently, two official plugins are available:
6 |
7 | - [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
8 | - [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
9 |
--------------------------------------------------------------------------------
/frontend/src/utils/formatDate.js:
--------------------------------------------------------------------------------
1 | export function formatDate(timestamp) {
2 | const date = new Date(parseInt(timestamp)); // Parse the timestamp to ensure it's an integer representing milliseconds
3 | const options = { day: "2-digit", month: "short", year: "numeric" };
4 | return date.toLocaleDateString("en-US", options);
5 | }
6 |
7 | // Example usage:
8 | const timestamp = 1704067200000;
9 | const formattedDate = formatDate(timestamp);
10 | console.log(formattedDate); // Output: "12 Dec 2023"
11 |
--------------------------------------------------------------------------------
/frontend/src/graphql/mutations/user.mutation.js:
--------------------------------------------------------------------------------
1 | import { gql } from "@apollo/client";
2 |
3 | export const SIGN_UP = gql`
4 | mutation SignUp($input: SignUpInput!) {
5 | signUp(input: $input) {
6 | _id
7 | name
8 | username
9 | }
10 | }
11 | `;
12 |
13 | export const LOGIN = gql`
14 | mutation Login($input: LoginInput!) {
15 | login(input: $input) {
16 | _id
17 | name
18 | username
19 | }
20 | }
21 | `;
22 |
23 | export const LOGOUT = gql`
24 | mutation Logout {
25 | logout {
26 | message
27 | }
28 | }
29 | `;
30 |
--------------------------------------------------------------------------------
/frontend/src/components/InputField.jsx:
--------------------------------------------------------------------------------
1 | const InputField = ({ label, id, name, type = "text", onChange, value }) => {
2 | return (
3 |
4 |
7 |
15 |
16 | );
17 | };
18 |
19 | export default InputField;
20 |
--------------------------------------------------------------------------------
/backend/models/user.model.js:
--------------------------------------------------------------------------------
1 | import mongoose from "mongoose";
2 |
3 | const userSchema = new mongoose.Schema(
4 | {
5 | username: {
6 | type: String,
7 | required: true,
8 | unique: true,
9 | },
10 | name: {
11 | type: String,
12 | required: true,
13 | },
14 | password: {
15 | type: String,
16 | required: true,
17 | },
18 | profilePicture: {
19 | type: String,
20 | default: "",
21 | },
22 | gender: {
23 | type: String,
24 | enum: ["male", "female"],
25 | },
26 | },
27 | { timestamps: true }
28 | );
29 |
30 | const User = mongoose.model("User", userSchema);
31 |
32 | export default User;
33 |
--------------------------------------------------------------------------------
/frontend/.eslintrc.cjs:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | root: true,
3 | env: { browser: true, es2020: true },
4 | extends: [
5 | "eslint:recommended",
6 | "plugin:react/recommended",
7 | "plugin:react/jsx-runtime",
8 | "plugin:react-hooks/recommended",
9 | ],
10 | ignorePatterns: ["dist", ".eslintrc.cjs"],
11 | parserOptions: { ecmaVersion: "latest", sourceType: "module" },
12 | settings: { react: { version: "18.2" } },
13 | plugins: ["react-refresh"],
14 | rules: {
15 | "react/jsx-no-target-blank": "off",
16 | "react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
17 | "react/prop-types": "off",
18 | },
19 | };
20 |
--------------------------------------------------------------------------------
/frontend/src/graphql/queries/user.query.js:
--------------------------------------------------------------------------------
1 | import { gql } from "@apollo/client";
2 |
3 | export const GET_AUTHENTICATED_USER = gql`
4 | query GetAuthenticatedUser {
5 | authUser {
6 | _id
7 | username
8 | name
9 | profilePicture
10 | }
11 | }
12 | `;
13 |
14 | export const GET_USER_AND_TRANSACTIONS = gql`
15 | query GetUserAndTransactions($userId: ID!) {
16 | user(userId: $userId) {
17 | _id
18 | name
19 | username
20 | profilePicture
21 | # relationships
22 | transactions {
23 | _id
24 | description
25 | paymentType
26 | category
27 | amount
28 | location
29 | date
30 | }
31 | }
32 | }
33 | `;
34 |
--------------------------------------------------------------------------------
/frontend/src/pages/NotFoundPage.jsx:
--------------------------------------------------------------------------------
1 | const NotFound = () => {
2 | return (
3 |
23 | );
24 | };
25 | export default NotFound;
26 |
--------------------------------------------------------------------------------
/frontend/src/graphql/queries/transaction.query.js:
--------------------------------------------------------------------------------
1 | import { gql } from "@apollo/client";
2 |
3 | export const GET_TRANSACTIONS = gql`
4 | query GetTransactions {
5 | transactions {
6 | _id
7 | description
8 | paymentType
9 | category
10 | amount
11 | location
12 | date
13 | }
14 | }
15 | `;
16 |
17 | export const GET_TRANSACTION = gql`
18 | query GetTransaction($id: ID!) {
19 | transaction(transactionId: $id) {
20 | _id
21 | description
22 | paymentType
23 | category
24 | amount
25 | location
26 | date
27 | user {
28 | name
29 | username
30 | profilePicture
31 | }
32 | }
33 | }
34 | `;
35 |
36 | export const GET_TRANSACTION_STATISTICS = gql`
37 | query GetTransactionStatistics {
38 | categoryStatistics {
39 | category
40 | totalAmount
41 | }
42 | }
43 | `;
44 |
--------------------------------------------------------------------------------
/backend/models/transaction.model.js:
--------------------------------------------------------------------------------
1 | import mongoose from "mongoose";
2 |
3 | const transactionSchema = new mongoose.Schema({
4 | userId: {
5 | type: mongoose.Schema.Types.ObjectId,
6 | ref: "User",
7 | required: true,
8 | },
9 | description: {
10 | type: String,
11 | required: true,
12 | },
13 | paymentType: {
14 | type: String,
15 | enum: ["cash", "card"],
16 | required: true,
17 | },
18 | category: {
19 | type: String,
20 | enum: ["saving", "expense", "investment"],
21 | required: true,
22 | },
23 | amount: {
24 | type: Number,
25 | required: true,
26 | },
27 | location: {
28 | type: String,
29 | default: "Unknown",
30 | },
31 | date: {
32 | type: Date,
33 | required: true,
34 | },
35 | });
36 |
37 | const Transaction = mongoose.model("Transaction", transactionSchema);
38 |
39 | export default Transaction;
40 |
--------------------------------------------------------------------------------
/backend/typeDefs/user.typeDef.js:
--------------------------------------------------------------------------------
1 | const userTypeDef = `#graphql
2 | type User {
3 | _id: ID!
4 | username: String!
5 | name: String!
6 | password: String!
7 | profilePicture: String
8 | gender: String!
9 | transactions: [Transaction!]
10 | }
11 |
12 | type Query {
13 | authUser: User
14 | user(userId:ID!): User
15 | }
16 |
17 | type Mutation {
18 | signUp(input: SignUpInput!): User
19 | login(input: LoginInput!): User
20 | logout: LogoutResponse
21 | }
22 |
23 | input SignUpInput {
24 | username: String!
25 | name: String!
26 | password: String!
27 | gender: String!
28 | }
29 |
30 | input LoginInput {
31 | username: String!
32 | password: String!
33 | }
34 |
35 | type LogoutResponse {
36 | message: String!
37 | }
38 | `;
39 |
40 | export default userTypeDef;
41 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "expense-tracker-gql",
3 | "version": "1.0.0",
4 | "description": "",
5 | "main": "index.js",
6 | "scripts": {
7 | "start": "NODE_ENV=production node backend/index.js",
8 | "dev": "NODE_ENV=development nodemon backend/index.js",
9 | "build": "npm install && npm install --prefix frontend && npm run build --prefix frontend"
10 | },
11 | "type": "module",
12 | "keywords": [],
13 | "author": "",
14 | "license": "ISC",
15 | "dependencies": {
16 | "@apollo/server": "^4.10.0",
17 | "@graphql-tools/merge": "^9.0.3",
18 | "bcryptjs": "^2.4.3",
19 | "connect-mongodb-session": "^5.0.0",
20 | "cron": "^3.1.6",
21 | "dotenv": "^16.4.5",
22 | "express": "^4.18.2",
23 | "express-session": "^1.18.0",
24 | "graphql": "^16.8.1",
25 | "graphql-passport": "^0.6.8",
26 | "mongoose": "^8.2.0",
27 | "passport": "^0.7.0"
28 | },
29 | "devDependencies": {
30 | "nodemon": "^3.1.0"
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/frontend/src/graphql/mutations/transcation.mutation.js:
--------------------------------------------------------------------------------
1 | import { gql } from "@apollo/client";
2 |
3 | export const CREATE_TRANSACTION = gql`
4 | mutation CreateTransaction($input: CreateTransactionInput!) {
5 | createTransaction(input: $input) {
6 | _id
7 | description
8 | paymentType
9 | category
10 | amount
11 | location
12 | date
13 | }
14 | }
15 | `;
16 |
17 | export const UPDATE_TRANSACTION = gql`
18 | mutation UpdateTransaction($input: UpdateTransactionInput!) {
19 | updateTransaction(input: $input) {
20 | _id
21 | description
22 | paymentType
23 | category
24 | amount
25 | location
26 | date
27 | }
28 | }
29 | `;
30 | export const DELETE_TRANSACTION = gql`
31 | mutation DeleteTransaction($transactionId: ID!) {
32 | deleteTransaction(transactionId: $transactionId) {
33 | _id
34 | description
35 | paymentType
36 | category
37 | amount
38 | location
39 | date
40 | }
41 | }
42 | `;
43 |
--------------------------------------------------------------------------------
/backend/typeDefs/index.js:
--------------------------------------------------------------------------------
1 | import { mergeTypeDefs } from "@graphql-tools/merge";
2 |
3 | // typeDefs
4 | import userTypeDef from "./user.typeDef.js";
5 | import transactionTypeDef from "./transaction.typeDef.js";
6 |
7 | const mergedTypeDefs = mergeTypeDefs([userTypeDef, transactionTypeDef]);
8 |
9 | export default mergedTypeDefs;
10 |
11 | // Why Merge Type Definitions? 🤔
12 |
13 | // Modularity: Merging type definitions allows you to keep related schema components in separate files, promoting modularity and organization.
14 |
15 | // Easier Collaboration: If multiple developers are working on different parts of the schema, merging separate type definitions can make it easier to collaborate without conflicts.
16 |
17 | // Reuse: You can reuse type definitions across different parts of the schema, potentially reducing duplication.
18 |
19 | // Clear Separation of Concerns: Each file can focus on a specific domain or type of data, making it easier to understand and maintain.
20 |
--------------------------------------------------------------------------------
/frontend/src/components/ui/Header.jsx:
--------------------------------------------------------------------------------
1 | import { Link } from "react-router-dom";
2 |
3 | const Header = () => {
4 | return (
5 |
6 |
7 | Expense GQL
8 |
9 |
10 | {/* Gradients */}
11 |
12 |
13 |
14 |
15 |
16 |
17 | );
18 | };
19 | export default Header;
20 |
--------------------------------------------------------------------------------
/frontend/src/components/skeletons/TransactionFormSkeleton.jsx:
--------------------------------------------------------------------------------
1 | const TransactionFormSkeleton = () => {
2 | return (
3 |
4 |
5 |
6 |
11 |
15 |
18 |
19 | );
20 | };
21 | export default TransactionFormSkeleton;
22 |
--------------------------------------------------------------------------------
/frontend/src/main.jsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import ReactDOM from "react-dom/client";
3 | import App from "./App.jsx";
4 | import "./index.css";
5 | import { BrowserRouter } from "react-router-dom";
6 | import GridBackground from "./components/ui/GridBackgroun.jsx";
7 | import { ApolloClient, InMemoryCache, ApolloProvider } from "@apollo/client";
8 |
9 | const client = new ApolloClient({
10 | // TODO => Update the uri on production
11 | uri: import.meta.env.VITE_NODE_ENV === "development" ? "http://localhost:4000/graphql" : "/graphql", // the URL of our GraphQL server.
12 | cache: new InMemoryCache(), // Apollo Client uses to cache query results after fetching them.
13 | credentials: "include", // This tells Apollo Client to send cookies along with every request to the server.
14 | });
15 |
16 | ReactDOM.createRoot(document.getElementById("root")).render(
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 | );
27 |
--------------------------------------------------------------------------------
/backend/cron.js:
--------------------------------------------------------------------------------
1 | import cron from "cron";
2 | import https from "https";
3 |
4 | const URL = "https://graphql-crash-course.onrender.com";
5 |
6 | const job = new cron.CronJob("*/14 * * * *", function () {
7 | https
8 | .get(URL, (res) => {
9 | if (res.statusCode === 200) {
10 | console.log("GET request sent successfully");
11 | } else {
12 | console.log("GET request failed", res.statusCode);
13 | }
14 | })
15 | .on("error", (e) => {
16 | console.error("Error while sending request", e);
17 | });
18 | });
19 |
20 | export default job;
21 |
22 | // CRON JOB EXPLANATION:
23 | // Cron jobs are scheduled tasks that run periodically at fixed intervals or specific times
24 | // send 1 GET request for every 14 minutes
25 |
26 | // Schedule:
27 | // You define a schedule using a cron expression, which consists of five fields representing:
28 |
29 | //! MINUTE, HOUR, DAY OF THE MONTH, MONTH, DAY OF THE WEEK
30 |
31 | //? EXAMPLES && EXPLANATION:
32 | //* 14 * * * * - Every 14 minutes
33 | //* 0 0 * * 0 - At midnight on every Sunday
34 | //* 30 3 15 * * - At 3:30 AM, on the 15th of every month
35 | //* 0 0 1 1 * - At midnight, on January 1st
36 | //* 0 * * * * - Every hour
37 |
--------------------------------------------------------------------------------
/backend/passport/passport.config.js:
--------------------------------------------------------------------------------
1 | import passport from "passport";
2 | import bcrypt from "bcryptjs";
3 |
4 | import User from "../models/user.model.js";
5 | import { GraphQLLocalStrategy } from "graphql-passport";
6 |
7 | export const configurePassport = async () => {
8 | passport.serializeUser((user, done) => {
9 | console.log("Serializing user");
10 | done(null, user.id);
11 | });
12 |
13 | passport.deserializeUser(async (id, done) => {
14 | console.log("Deserializing user");
15 | try {
16 | const user = await User.findById(id);
17 | done(null, user);
18 | } catch (err) {
19 | done(err);
20 | }
21 | });
22 |
23 | passport.use(
24 | new GraphQLLocalStrategy(async (username, password, done) => {
25 | try {
26 | const user = await User.findOne({ username });
27 | if (!user) {
28 | throw new Error("Invalid username or password");
29 | }
30 | const validPassword = await bcrypt.compare(password, user.password);
31 |
32 | if (!validPassword) {
33 | throw new Error("Invalid username or password");
34 | }
35 |
36 | return done(null, user);
37 | } catch (err) {
38 | return done(err);
39 | }
40 | })
41 | );
42 | };
43 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # GraphQL Crash Course - Build an Expense Tracker App
2 |
3 | 
4 |
5 | [Video Tutorial on Youtube](https://youtu.be/Vr-QHtbmd38)
6 |
7 | Welcome to the MERN GraphQL Expense Tracker App! This project is designed to help you manage your expenses efficiently using a modern tech stack and GraphQL.
8 |
9 | ## Features:
10 |
11 | - 🌟 Tech stack: MERN (MongoDB, Express.js, React.js, Node.js) + Apollo GraphQL
12 | - 📝 Learn type definitions and resolvers for defining GraphQL schema and data fetching logic
13 | - 🔄 Mutations for modifying data in the GraphQL API and establishing graph relations
14 | - 🎃 Authentication with Passport.js and MongoDB session store
15 | - 🚀 Global state management with Apollo Client
16 | - 🐞 Error handling both on the server and on the client
17 | - ⭐ Deployment made easy with a platform called Render
18 | - 👾 Cron jobs for scheduled tasks and automation
19 | - ⏳ And much more!
20 |
21 | ### Setup .env file
22 |
23 | ```js
24 | MONGO_URI=...
25 | SESSION_SECRET=...
26 | ```
27 |
28 | ### Build the app
29 |
30 | ```shell
31 | npm run build
32 | ```
33 |
34 | ### Start the app
35 |
36 | ```shell
37 | npm start
38 | ```
39 |
--------------------------------------------------------------------------------
/backend/typeDefs/transaction.typeDef.js:
--------------------------------------------------------------------------------
1 | const transactionTypeDef = `#graphql
2 | type Transaction {
3 | _id: ID!
4 | userId: ID!
5 | description: String!
6 | paymentType: String!
7 | category: String!
8 | amount: Float!
9 | location: String
10 | date: String!
11 | user: User!
12 | }
13 |
14 | type Query {
15 | transactions: [Transaction!]
16 | transaction(transactionId:ID!): Transaction
17 | categoryStatistics: [CategoryStatistics!]
18 | }
19 |
20 | type Mutation {
21 | createTransaction(input: CreateTransactionInput!): Transaction!
22 | updateTransaction(input: UpdateTransactionInput!): Transaction!
23 | deleteTransaction(transactionId:ID!): Transaction!
24 | }
25 |
26 | type CategoryStatistics {
27 | category: String!
28 | totalAmount: Float!
29 | }
30 |
31 | input CreateTransactionInput {
32 | description: String!
33 | paymentType: String!
34 | category: String!
35 | amount: Float!
36 | date: String!
37 | location: String
38 | }
39 |
40 | input UpdateTransactionInput {
41 | transactionId: ID!
42 | description: String
43 | paymentType: String
44 | category: String
45 | amount: Float
46 | location: String
47 | date: String
48 | }
49 | `;
50 |
51 | export default transactionTypeDef;
52 |
--------------------------------------------------------------------------------
/frontend/src/App.jsx:
--------------------------------------------------------------------------------
1 | import { Navigate, Route, Routes } from "react-router-dom";
2 | import HomePage from "./pages/HomePage";
3 | import LoginPage from "./pages/LoginPage";
4 | import SignUpPage from "./pages/SignUpPage";
5 | import TransactionPage from "./pages/TransactionPage";
6 | import NotFoundPage from "./pages/NotFoundPage";
7 | import Header from "./components/ui/Header";
8 | import { useQuery } from "@apollo/client";
9 | import { GET_AUTHENTICATED_USER } from "./graphql/queries/user.query";
10 | import { Toaster } from "react-hot-toast";
11 |
12 | function App() {
13 | const { loading, data } = useQuery(GET_AUTHENTICATED_USER);
14 |
15 | if (loading) return null;
16 |
17 | return (
18 | <>
19 | {data?.authUser && }
20 |
21 | : } />
22 | : } />
23 | : } />
24 | : }
27 | />
28 | } />
29 |
30 |
31 | >
32 | );
33 | }
34 |
35 | export default App;
36 |
--------------------------------------------------------------------------------
/frontend/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "frontend",
3 | "private": true,
4 | "version": "0.0.0",
5 | "type": "module",
6 | "scripts": {
7 | "dev": "vite",
8 | "build": "vite build",
9 | "lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0",
10 | "preview": "vite preview"
11 | },
12 | "dependencies": {
13 | "@apollo/client": "^3.9.5",
14 | "@tailwindcss/aspect-ratio": "^0.4.2",
15 | "chart.js": "^4.4.1",
16 | "clsx": "^2.1.0",
17 | "expense-tracker-gql": "file:..",
18 | "framer-motion": "^11.0.6",
19 | "graphql": "^16.8.1",
20 | "mini-svg-data-uri": "^1.4.4",
21 | "react": "^18.2.0",
22 | "react-chartjs-2": "^5.2.0",
23 | "react-dom": "^18.2.0",
24 | "react-hot-toast": "^2.4.1",
25 | "react-icons": "^5.0.1",
26 | "react-router-dom": "^6.22.1",
27 | "tailwind-merge": "^2.2.1"
28 | },
29 | "devDependencies": {
30 | "@types/react": "^18.2.56",
31 | "@types/react-dom": "^18.2.19",
32 | "@vitejs/plugin-react": "^4.2.1",
33 | "autoprefixer": "^10.4.17",
34 | "eslint": "^8.56.0",
35 | "eslint-plugin-react": "^7.33.2",
36 | "eslint-plugin-react-hooks": "^4.6.0",
37 | "eslint-plugin-react-refresh": "^0.4.5",
38 | "postcss": "^8.4.35",
39 | "tailwindcss": "^3.4.1",
40 | "vite": "^5.1.4"
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/frontend/src/components/Cards.jsx:
--------------------------------------------------------------------------------
1 | import { useQuery } from "@apollo/client";
2 | import Card from "./Card";
3 | import { GET_TRANSACTIONS } from "../graphql/queries/transaction.query";
4 | import { GET_AUTHENTICATED_USER, GET_USER_AND_TRANSACTIONS } from "../graphql/queries/user.query";
5 |
6 | const Cards = () => {
7 | const { data, loading } = useQuery(GET_TRANSACTIONS);
8 | const { data: authUser } = useQuery(GET_AUTHENTICATED_USER);
9 |
10 | const { data: userAndTransactions } = useQuery(GET_USER_AND_TRANSACTIONS, {
11 | variables: {
12 | userId: authUser?.authUser?._id,
13 | },
14 | });
15 |
16 | console.log("userAndTransactions:", userAndTransactions);
17 |
18 | console.log("cards:", data);
19 |
20 | // TODO => ADD RELATIONSHIPS
21 | return (
22 |
23 |
History
24 |
25 | {!loading &&
26 | data.transactions.map((transaction) => (
27 |
28 | ))}
29 |
30 | {!loading && data?.transactions?.length === 0 && (
31 |
No transaction history found.
32 | )}
33 |
34 | );
35 | };
36 | export default Cards;
37 |
--------------------------------------------------------------------------------
/frontend/public/vite.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/frontend/src/components/RadioButton.jsx:
--------------------------------------------------------------------------------
1 | const RadioButton = ({ id, label, onChange, value, checked }) => {
2 | return (
3 |
4 |
25 |
28 |
29 | );
30 | };
31 |
32 | export default RadioButton;
33 |
--------------------------------------------------------------------------------
/frontend/tailwind.config.js:
--------------------------------------------------------------------------------
1 | import svgToDataUri from "mini-svg-data-uri";
2 | import { default as flattenColorPalette } from "tailwindcss/lib/util/flattenColorPalette";
3 |
4 | /** @type {import('tailwindcss').Config} */
5 | export default {
6 | content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
7 | darkMode: "class",
8 | theme: {
9 | // rest of the code
10 | },
11 | plugins: [
12 | addVariablesForColors,
13 | function ({ matchUtilities, theme }) {
14 | matchUtilities(
15 | {
16 | "bg-grid": (value) => ({
17 | backgroundImage: `url("${svgToDataUri(
18 | ``
19 | )}")`,
20 | }),
21 | "bg-grid-small": (value) => ({
22 | backgroundImage: `url("${svgToDataUri(
23 | ``
24 | )}")`,
25 | }),
26 | "bg-dot": (value) => ({
27 | backgroundImage: `url("${svgToDataUri(
28 | ``
29 | )}")`,
30 | }),
31 | },
32 | { values: flattenColorPalette(theme("backgroundColor")), type: "color" }
33 | );
34 | },
35 | ],
36 | };
37 |
38 | function addVariablesForColors({ addBase, theme }) {
39 | let allColors = flattenColorPalette(theme("colors"));
40 | let newVars = Object.fromEntries(Object.entries(allColors).map(([key, val]) => [`--${key}`, val]));
41 |
42 | addBase({
43 | ":root": newVars,
44 | });
45 | }
46 |
--------------------------------------------------------------------------------
/backend/dummyData/data.js:
--------------------------------------------------------------------------------
1 | // Hardcoded array of 5 users
2 | const users = [
3 | {
4 | _id: "1",
5 | username: "user1",
6 | name: "User One",
7 | password: "password1",
8 | profilePicture: "profile1.jpg",
9 | gender: "male",
10 | },
11 | {
12 | _id: "2",
13 | username: "user2",
14 | name: "User Two",
15 | password: "password2",
16 | profilePicture: "profile2.jpg",
17 | gender: "female",
18 | },
19 | {
20 | _id: "3",
21 | username: "user3",
22 | name: "User Three",
23 | password: "password3",
24 | profilePicture: "profile3.jpg",
25 | gender: "male",
26 | },
27 | {
28 | _id: "4",
29 | username: "user4",
30 | name: "User Four",
31 | password: "password4",
32 | profilePicture: "profile4.jpg",
33 | gender: "female",
34 | },
35 | {
36 | _id: "5",
37 | username: "user5",
38 | name: "User Five",
39 | password: "password5",
40 | profilePicture: "profile5.jpg",
41 | gender: "male",
42 | },
43 | ];
44 |
45 | // Hardcoded array of 5 transactions
46 | const transactions = [
47 | {
48 | _id: "1",
49 | userId: "1",
50 | description: "Transaction One",
51 | paymentType: "CASH",
52 | category: "Category One",
53 | amount: 100.0,
54 | location: "Location One",
55 | date: "2024-01-01",
56 | },
57 | {
58 | _id: "2",
59 | userId: "2",
60 | description: "Transaction Two",
61 | paymentType: "CARD",
62 | category: "Category Two",
63 | amount: 200.0,
64 | location: "Location Two",
65 | date: "2024-01-02",
66 | },
67 | {
68 | _id: "3",
69 | userId: "3",
70 | description: "Transaction Three",
71 | paymentType: "CASH",
72 | category: "Category Three",
73 | amount: 300.0,
74 | location: "Location Three",
75 | date: "2024-01-03",
76 | },
77 | {
78 | _id: "4",
79 | userId: "4",
80 | description: "Transaction Four",
81 | paymentType: "CARD",
82 | category: "Category Four",
83 | amount: 400.0,
84 | location: "Location Four",
85 | date: "2024-01-04",
86 | },
87 | {
88 | _id: "5",
89 | userId: "5",
90 | description: "Transaction Five",
91 | paymentType: "CASH",
92 | category: "Category Five",
93 | amount: 500.0,
94 | location: "Location Five",
95 | date: "2024-01-05",
96 | },
97 | ];
98 |
99 | // Export the arrays
100 | export { users, transactions };
101 |
--------------------------------------------------------------------------------
/frontend/src/pages/LoginPage.jsx:
--------------------------------------------------------------------------------
1 | import { Link } from "react-router-dom";
2 | import { useState } from "react";
3 | import InputField from "../components/InputField";
4 | import { useMutation } from "@apollo/client";
5 | import { LOGIN } from "../graphql/mutations/user.mutation";
6 | import toast from "react-hot-toast";
7 |
8 | const LoginPage = () => {
9 | const [loginData, setLoginData] = useState({
10 | username: "",
11 | password: "",
12 | });
13 |
14 | const [login, { loading }] = useMutation(LOGIN, {
15 | refetchQueries: ["GetAuthenticatedUser"],
16 | });
17 |
18 | const handleChange = (e) => {
19 | const { name, value } = e.target;
20 | setLoginData((prevData) => ({
21 | ...prevData,
22 | [name]: value,
23 | }));
24 | };
25 |
26 | const handleSubmit = async (e) => {
27 | e.preventDefault();
28 | if (!loginData.username || !loginData.password) return toast.error("Please fill in all fields");
29 | try {
30 | await login({ variables: { input: loginData } });
31 | } catch (error) {
32 | console.error("Error logging in:", error);
33 | toast.error(error.message);
34 | }
35 | };
36 |
37 | return (
38 |
39 |
40 |
41 |
42 |
Login
43 |
44 | Welcome back! Log in to your account
45 |
46 |
74 |
75 |
76 | {"Don't"} have an account?{" "}
77 |
78 | Sign Up
79 |
80 |
81 |
82 |
83 |
84 |
85 |
86 | );
87 | };
88 | export default LoginPage;
89 |
--------------------------------------------------------------------------------
/backend/index.js:
--------------------------------------------------------------------------------
1 | import express from "express";
2 | import http from "http";
3 | import cors from "cors";
4 | import dotenv from "dotenv";
5 | import path from "path";
6 | import passport from "passport";
7 | import session from "express-session";
8 | import connectMongo from "connect-mongodb-session";
9 |
10 | import { ApolloServer } from "@apollo/server";
11 | import { expressMiddleware } from "@apollo/server/express4";
12 | import { ApolloServerPluginDrainHttpServer } from "@apollo/server/plugin/drainHttpServer";
13 |
14 | import { buildContext } from "graphql-passport";
15 |
16 | import mergedResolvers from "./resolvers/index.js";
17 | import mergedTypeDefs from "./typeDefs/index.js";
18 |
19 | import { connectDB } from "./db/connectDB.js";
20 | import { configurePassport } from "./passport/passport.config.js";
21 |
22 | import job from "./cron.js";
23 |
24 | dotenv.config();
25 | configurePassport();
26 |
27 | job.start();
28 |
29 | const __dirname = path.resolve();
30 | const app = express();
31 |
32 | const httpServer = http.createServer(app);
33 |
34 | const MongoDBStore = connectMongo(session);
35 |
36 | const store = new MongoDBStore({
37 | uri: process.env.MONGO_URI,
38 | collection: "sessions",
39 | });
40 |
41 | store.on("error", (err) => console.log(err));
42 |
43 | app.use(
44 | session({
45 | secret: process.env.SESSION_SECRET,
46 | resave: false, // this option specifies whether to save the session to the store on every request
47 | saveUninitialized: false, // option specifies whether to save uninitialized sessions
48 | cookie: {
49 | maxAge: 1000 * 60 * 60 * 24 * 7,
50 | httpOnly: true, // this option prevents the Cross-Site Scripting (XSS) attacks
51 | },
52 | store: store,
53 | })
54 | );
55 |
56 | app.use(passport.initialize());
57 | app.use(passport.session());
58 |
59 | const server = new ApolloServer({
60 | typeDefs: mergedTypeDefs,
61 | resolvers: mergedResolvers,
62 | plugins: [ApolloServerPluginDrainHttpServer({ httpServer })],
63 | });
64 |
65 | // Ensure we wait for our server to start
66 | await server.start();
67 |
68 | // Set up our Express middleware to handle CORS, body parsing,
69 | // and our expressMiddleware function.
70 | app.use(
71 | "/graphql",
72 | cors({
73 | origin: "http://localhost:3000",
74 | credentials: true,
75 | }),
76 | express.json(),
77 | // expressMiddleware accepts the same arguments:
78 | // an Apollo Server instance and optional configuration options
79 | expressMiddleware(server, {
80 | context: async ({ req, res }) => buildContext({ req, res }),
81 | })
82 | );
83 |
84 | // npm run build will build your frontend app, and it will the optimized version of your app
85 | app.use(express.static(path.join(__dirname, "frontend/dist")));
86 |
87 | app.get("*", (req, res) => {
88 | res.sendFile(path.join(__dirname, "frontend/dist", "index.html"));
89 | });
90 |
91 | // Modified server startup
92 | await new Promise((resolve) => httpServer.listen({ port: 4000 }, resolve));
93 | await connectDB();
94 |
95 | console.log(`🚀 Server ready at http://localhost:4000/graphql`);
96 |
--------------------------------------------------------------------------------
/frontend/src/components/Card.jsx:
--------------------------------------------------------------------------------
1 | import { FaLocationDot } from "react-icons/fa6";
2 | import { BsCardText } from "react-icons/bs";
3 | import { MdOutlinePayments } from "react-icons/md";
4 | import { FaSackDollar } from "react-icons/fa6";
5 | import { FaTrash } from "react-icons/fa";
6 | import { HiPencilAlt } from "react-icons/hi";
7 | import { Link } from "react-router-dom";
8 | import { formatDate } from "../utils/formatDate";
9 | import toast from "react-hot-toast";
10 | import { useMutation } from "@apollo/client";
11 | import { DELETE_TRANSACTION } from "../graphql/mutations/transcation.mutation";
12 |
13 | const categoryColorMap = {
14 | saving: "from-green-700 to-green-400",
15 | expense: "from-pink-800 to-pink-600",
16 | investment: "from-blue-700 to-blue-400",
17 | // Add more categories and corresponding color classes as needed
18 | };
19 |
20 | const Card = ({ transaction, authUser }) => {
21 | let { category, amount, location, date, paymentType, description } = transaction;
22 | const cardClass = categoryColorMap[category];
23 | const [deleteTransaction, { loading }] = useMutation(DELETE_TRANSACTION, {
24 | refetchQueries: ["GetTransactions", "GetTransactionStatistics"],
25 | });
26 |
27 | // Capitalize the first letter of the description
28 | description = description[0]?.toUpperCase() + description.slice(1);
29 | category = category[0]?.toUpperCase() + category.slice(1);
30 | paymentType = paymentType[0]?.toUpperCase() + paymentType.slice(1);
31 |
32 | const formattedDate = formatDate(date);
33 |
34 | const handleDelete = async () => {
35 | try {
36 | await deleteTransaction({ variables: { transactionId: transaction._id } });
37 | toast.success("Transaction deleted successfully");
38 | } catch (error) {
39 | console.error("Error deleting transaction:", error);
40 | toast.error(error.message);
41 | }
42 | };
43 |
44 | return (
45 |
46 |
47 |
48 |
{category}
49 |
50 | {!loading &&
}
51 | {loading &&
}
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 | Description: {description}
60 |
61 |
62 |
63 | Payment Type: {paymentType}
64 |
65 |
66 |
67 | Amount: ${amount}
68 |
69 |
70 |
71 | Location: {location || "N/A"}
72 |
73 |
74 |
{formattedDate}
75 |

76 |
77 |
78 |
79 | );
80 | };
81 | export default Card;
82 |
--------------------------------------------------------------------------------
/backend/resolvers/user.resolver.js:
--------------------------------------------------------------------------------
1 | import Transaction from "../models/transaction.model.js";
2 | import User from "../models/user.model.js";
3 | import bcrypt from "bcryptjs";
4 |
5 | const userResolver = {
6 | Mutation: {
7 | signUp: async (_, { input }, context) => {
8 | try {
9 | const { username, name, password, gender } = input;
10 |
11 | if (!username || !name || !password || !gender) {
12 | throw new Error("All fields are required");
13 | }
14 | const existingUser = await User.findOne({ username });
15 | if (existingUser) {
16 | throw new Error("User already exists");
17 | }
18 |
19 | const salt = await bcrypt.genSalt(10);
20 | const hashedPassword = await bcrypt.hash(password, salt);
21 |
22 | // https://avatar-placeholder.iran.liara.run/
23 | const boyProfilePic = `https://avatar.iran.liara.run/public/boy?username=${username}`;
24 | const girlProfilePic = `https://avatar.iran.liara.run/public/girl?username=${username}`;
25 |
26 | const newUser = new User({
27 | username,
28 | name,
29 | password: hashedPassword,
30 | gender,
31 | profilePicture: gender === "male" ? boyProfilePic : girlProfilePic,
32 | });
33 |
34 | await newUser.save();
35 | await context.login(newUser);
36 | return newUser;
37 | } catch (err) {
38 | console.error("Error in signUp: ", err);
39 | throw new Error(err.message || "Internal server error");
40 | }
41 | },
42 |
43 | login: async (_, { input }, context) => {
44 | try {
45 | const { username, password } = input;
46 | if (!username || !password) throw new Error("All fields are required");
47 | const { user } = await context.authenticate("graphql-local", { username, password });
48 |
49 | await context.login(user);
50 | return user;
51 | } catch (err) {
52 | console.error("Error in login:", err);
53 | throw new Error(err.message || "Internal server error");
54 | }
55 | },
56 | logout: async (_, __, context) => {
57 | try {
58 | await context.logout();
59 | context.req.session.destroy((err) => {
60 | if (err) throw err;
61 | });
62 | context.res.clearCookie("connect.sid");
63 |
64 | return { message: "Logged out successfully" };
65 | } catch (err) {
66 | console.error("Error in logout:", err);
67 | throw new Error(err.message || "Internal server error");
68 | }
69 | },
70 | },
71 | Query: {
72 | authUser: async (_, __, context) => {
73 | try {
74 | const user = await context.getUser();
75 | return user;
76 | } catch (err) {
77 | console.error("Error in authUser: ", err);
78 | throw new Error("Internal server error");
79 | }
80 | },
81 | user: async (_, { userId }) => {
82 | try {
83 | const user = await User.findById(userId);
84 | return user;
85 | } catch (err) {
86 | console.error("Error in user query:", err);
87 | throw new Error(err.message || "Error getting user");
88 | }
89 | },
90 | },
91 | User: {
92 | transactions: async (parent) => {
93 | try {
94 | const transactions = await Transaction.find({ userId: parent._id });
95 | return transactions;
96 | } catch (err) {
97 | console.log("Error in user.transactions resolver: ", err);
98 | throw new Error(err.message || "Internal server error");
99 | }
100 | },
101 | },
102 | };
103 |
104 | export default userResolver;
105 |
--------------------------------------------------------------------------------
/backend/resolvers/transaction.resolver.js:
--------------------------------------------------------------------------------
1 | import Transaction from "../models/transaction.model.js";
2 | import User from "../models/user.model.js";
3 |
4 | const transactionResolver = {
5 | Query: {
6 | transactions: async (_, __, context) => {
7 | try {
8 | if (!context.getUser()) throw new Error("Unauthorized");
9 | const userId = await context.getUser()._id;
10 |
11 | const transactions = await Transaction.find({ userId });
12 | return transactions;
13 | } catch (err) {
14 | console.error("Error getting transactions:", err);
15 | throw new Error("Error getting transactions");
16 | }
17 | },
18 | transaction: async (_, { transactionId }) => {
19 | try {
20 | const transaction = await Transaction.findById(transactionId);
21 | return transaction;
22 | } catch (err) {
23 | console.error("Error getting transaction:", err);
24 | throw new Error("Error getting transaction");
25 | }
26 | },
27 | categoryStatistics: async (_, __, context) => {
28 | if (!context.getUser()) throw new Error("Unauthorized");
29 |
30 | const userId = context.getUser()._id;
31 | const transactions = await Transaction.find({ userId });
32 | const categoryMap = {};
33 |
34 | // const transactions = [
35 | // { category: "expense", amount: 50 },
36 | // { category: "expense", amount: 75 },
37 | // { category: "investment", amount: 100 },
38 | // { category: "saving", amount: 30 },
39 | // { category: "saving", amount: 20 }
40 | // ];
41 |
42 | transactions.forEach((transaction) => {
43 | if (!categoryMap[transaction.category]) {
44 | categoryMap[transaction.category] = 0;
45 | }
46 | categoryMap[transaction.category] += transaction.amount;
47 | });
48 |
49 | // categoryMap = { expense: 125, investment: 100, saving: 50 }
50 |
51 | return Object.entries(categoryMap).map(([category, totalAmount]) => ({ category, totalAmount }));
52 | // return [ { category: "expense", totalAmount: 125 }, { category: "investment", totalAmount: 100 }, { category: "saving", totalAmount: 50 } ]
53 | },
54 | },
55 | Mutation: {
56 | createTransaction: async (_, { input }, context) => {
57 | try {
58 | const newTransaction = new Transaction({
59 | ...input,
60 | userId: context.getUser()._id,
61 | });
62 | await newTransaction.save();
63 | return newTransaction;
64 | } catch (err) {
65 | console.error("Error creating transaction:", err);
66 | throw new Error("Error creating transaction");
67 | }
68 | },
69 | updateTransaction: async (_, { input }) => {
70 | try {
71 | const updatedTransaction = await Transaction.findByIdAndUpdate(input.transactionId, input, {
72 | new: true,
73 | });
74 | return updatedTransaction;
75 | } catch (err) {
76 | console.error("Error updating transaction:", err);
77 | throw new Error("Error updating transaction");
78 | }
79 | },
80 | deleteTransaction: async (_, { transactionId }) => {
81 | try {
82 | const deletedTransaction = await Transaction.findByIdAndDelete(transactionId);
83 | return deletedTransaction;
84 | } catch (err) {
85 | console.error("Error deleting transaction:", err);
86 | throw new Error("Error deleting transaction");
87 | }
88 | },
89 | },
90 | Transaction: {
91 | user: async (parent) => {
92 | const userId = parent.userId;
93 | try {
94 | const user = await User.findById(userId);
95 | return user;
96 | } catch (err) {
97 | console.error("Error getting user:", err);
98 | throw new Error("Error getting user");
99 | }
100 | },
101 | },
102 | };
103 |
104 | export default transactionResolver;
105 |
--------------------------------------------------------------------------------
/NOTES.md:
--------------------------------------------------------------------------------
1 | # graphql package =>
2 |
3 | - It is the core GraphQL implementation in **JavaScript**.
4 | - It provides the functionality to define GraphQL schemas, parse and validate GraphQL queries, execute queries against a schema, and format responses.
5 | - graphql is not tied to any specific server or client framework; it's a standalone library that can be used in various JavaScript environments.
6 |
7 | # @apollo/server =>
8 |
9 | - This package is part of the Apollo ecosystem and is used for building GraphQL servers in Node.js.
10 | - It provides tools and utilities to create and manage GraphQL schemas, handle incoming GraphQL requests, execute queries, and send responses.
11 | - @apollo/server is built on top of the popular express framework, making it easy to integrate GraphQL into existing Node.js web applications.
12 | - Overall, @apollo/server simplifies the process of creating and maintaining GraphQL servers in Node.js environments.
13 |
14 | # What is GraphQL Schema?
15 |
16 | - A GraphQL schema is a fundamental concept in GraphQL.
17 | - It defines the structure of the data that clients can query and the operations they can perform. A schema in GraphQL typically consists of two main parts: typeDefs and resolvers.
18 |
19 | # What are TypeDefs? (or Type Definitions)
20 |
21 | - Type definitions define the shape of the data available in the GraphQL API. They specify the types of objects that can be queried and the relationships between them.
22 |
23 | # What are Resolvers?
24 |
25 | - Resolvers are functions that determine how to fetch the data associated with each field in the schema.
26 |
27 | ## Apollo Client
28 |
29 | - Apollo Client is a comprehensive state management library for JavaScript that enables you to manage both local and remote data with GraphQL. Use it to fetch, cache, and modify application data, all while automatically updating your UI.
30 |
31 | # Features
32 |
33 | - Declarative data fetching: Write a query and receive data without manually tracking loading states.
34 | - Excellent developer experience: Enjoy helpful tooling for TypeScript, Chrome / Firefox devtools, and VS Code.
35 | - Designed for modern React: Take advantage of the latest React features, such as hooks.
36 | - Incrementally adoptable: Drop Apollo into any JavaScript app and incorporate it feature by feature.
37 | - Universally compatible: Use any build setup and any GraphQL API.
38 | - Community driven: Share knowledge with thousands of developers in the GraphQL community.
39 |
40 | ### Declarative Data Fetching
41 |
42 | - Apollo Client handles the request cycle from start to finish, including tracking loading and error states. There's no middleware or boilerplate code to set up before making your first request, and you don't need to worry about transforming or caching responses. All you have to do is describe the data your component needs and let Apollo Client do the heavy lifting.
43 |
44 | ```jsx
45 | function ShowDogs() {
46 | // The useQuery hook supports advanced features like an optimistic UI, refetching, and pagination.
47 | const { loading, error, data } = useQuery(GET_DOGS);
48 | if (error) return ;
49 | if (loading) return ;
50 |
51 | return ;
52 | }
53 | ```
54 |
55 | ### Caching a graph is not an easy task, but they have spent years solving this problem.
56 |
57 | ```jsx
58 | import { ApolloClient, InMemoryCache } from "@apollo/client";
59 |
60 | const client = new ApolloClient({
61 | cache: new InMemoryCache(),
62 | });
63 | ```
64 |
65 | # Installation
66 |
67 | ```bash
68 | npm install @apollo/client graphql
69 | ```
70 |
71 | - **@apollo/client:** This single package contains virtually everything you need to set up Apollo Client. It includes the in-memory cache, local state management, error handling, and a React-based view layer.
72 |
73 | - **graphql:** This package provides logic for parsing GraphQL queries.
74 |
--------------------------------------------------------------------------------
/frontend/src/pages/SignUpPage.jsx:
--------------------------------------------------------------------------------
1 | import { useState } from "react";
2 | import { Link } from "react-router-dom";
3 | import RadioButton from "../components/RadioButton";
4 | import InputField from "../components/InputField";
5 | import { useMutation } from "@apollo/client";
6 | import { SIGN_UP } from "../graphql/mutations/user.mutation";
7 | import toast from "react-hot-toast";
8 |
9 | const SignUpPage = () => {
10 | const [signUpData, setSignUpData] = useState({
11 | name: "",
12 | username: "",
13 | password: "",
14 | gender: "",
15 | });
16 |
17 | const [signup, { loading }] = useMutation(SIGN_UP, {
18 | refetchQueries: ["GetAuthenticatedUser"],
19 | });
20 |
21 | const handleSubmit = async (e) => {
22 | e.preventDefault();
23 | try {
24 | await signup({
25 | variables: {
26 | input: signUpData,
27 | },
28 | });
29 | } catch (error) {
30 | console.error("Error:", error);
31 | toast.error(error.message);
32 | }
33 | };
34 |
35 | const handleChange = (e) => {
36 | const { name, value, type } = e.target;
37 |
38 | if (type === "radio") {
39 | setSignUpData((prevData) => ({
40 | ...prevData,
41 | gender: value,
42 | }));
43 | } else {
44 | setSignUpData((prevData) => ({
45 | ...prevData,
46 | [name]: value,
47 | }));
48 | }
49 | };
50 |
51 | return (
52 |
53 |
54 |
55 |
56 |
Sign Up
57 |
58 | Join to keep track of your expenses
59 |
60 |
113 |
114 |
115 | Already have an account?{" "}
116 |
117 | Login here
118 |
119 |
120 |
121 |
122 |
123 |
124 |
125 | );
126 | };
127 |
128 | export default SignUpPage;
129 |
--------------------------------------------------------------------------------
/frontend/src/pages/HomePage.jsx:
--------------------------------------------------------------------------------
1 | import { Doughnut } from "react-chartjs-2";
2 | import { Chart as ChartJS, ArcElement, Tooltip, Legend } from "chart.js";
3 |
4 | import Cards from "../components/Cards";
5 | import TransactionForm from "../components/TransactionForm";
6 |
7 | import { MdLogout } from "react-icons/md";
8 | import toast from "react-hot-toast";
9 | import { useMutation, useQuery } from "@apollo/client";
10 | import { LOGOUT } from "../graphql/mutations/user.mutation";
11 | import { GET_TRANSACTION_STATISTICS } from "../graphql/queries/transaction.query";
12 | import { GET_AUTHENTICATED_USER } from "../graphql/queries/user.query";
13 | import { useEffect, useState } from "react";
14 |
15 | // const chartData = {
16 | // labels: ["Saving", "Expense", "Investment"],
17 | // datasets: [
18 | // {
19 | // label: "%",
20 | // data: [13, 8, 3],
21 | // backgroundColor: ["rgba(75, 192, 192)", "rgba(255, 99, 132)", "rgba(54, 162, 235)"],
22 | // borderColor: ["rgba(75, 192, 192)", "rgba(255, 99, 132)", "rgba(54, 162, 235, 1)"],
23 | // borderWidth: 1,
24 | // borderRadius: 30,
25 | // spacing: 10,
26 | // cutout: 130,
27 | // },
28 | // ],
29 | // };
30 |
31 | ChartJS.register(ArcElement, Tooltip, Legend);
32 |
33 | const HomePage = () => {
34 | const { data } = useQuery(GET_TRANSACTION_STATISTICS);
35 | const { data: authUserData } = useQuery(GET_AUTHENTICATED_USER);
36 |
37 | const [logout, { loading, client }] = useMutation(LOGOUT, {
38 | refetchQueries: ["GetAuthenticatedUser"],
39 | });
40 |
41 | const [chartData, setChartData] = useState({
42 | labels: [],
43 | datasets: [
44 | {
45 | label: "$",
46 | data: [],
47 | backgroundColor: [],
48 | borderColor: [],
49 | borderWidth: 1,
50 | borderRadius: 30,
51 | spacing: 10,
52 | cutout: 130,
53 | },
54 | ],
55 | });
56 |
57 | useEffect(() => {
58 | if (data?.categoryStatistics) {
59 | const categories = data.categoryStatistics.map((stat) => stat.category);
60 | const totalAmounts = data.categoryStatistics.map((stat) => stat.totalAmount);
61 |
62 | const backgroundColors = [];
63 | const borderColors = [];
64 |
65 | categories.forEach((category) => {
66 | if (category === "saving") {
67 | backgroundColors.push("rgba(75, 192, 192)");
68 | borderColors.push("rgba(75, 192, 192)");
69 | } else if (category === "expense") {
70 | backgroundColors.push("rgba(255, 99, 132)");
71 | borderColors.push("rgba(255, 99, 132)");
72 | } else if (category === "investment") {
73 | backgroundColors.push("rgba(54, 162, 235)");
74 | borderColors.push("rgba(54, 162, 235)");
75 | }
76 | });
77 |
78 | setChartData((prev) => ({
79 | labels: categories,
80 | datasets: [
81 | {
82 | ...prev.datasets[0],
83 | data: totalAmounts,
84 | backgroundColor: backgroundColors,
85 | borderColor: borderColors,
86 | },
87 | ],
88 | }));
89 | }
90 | }, [data]);
91 |
92 | const handleLogout = async () => {
93 | try {
94 | await logout();
95 | // Clear the Apollo Client cache FROM THE DOCS
96 | // https://www.apollographql.com/docs/react/caching/advanced-topics/#:~:text=Resetting%20the%20cache,any%20of%20your%20active%20queries
97 | client.resetStore();
98 | } catch (error) {
99 | console.error("Error logging out:", error);
100 | toast.error(error.message);
101 | }
102 | };
103 |
104 | return (
105 | <>
106 |
107 |
108 |
109 | Spend wisely, track wisely
110 |
111 |

116 | {!loading &&
}
117 | {/* loading spinner */}
118 | {loading &&
}
119 |
120 |
121 | {data?.categoryStatistics.length > 0 && (
122 |
123 |
124 |
125 | )}
126 |
127 |
128 |
129 |
130 |
131 | >
132 | );
133 | };
134 | export default HomePage;
135 |
--------------------------------------------------------------------------------
/frontend/src/components/TransactionForm.jsx:
--------------------------------------------------------------------------------
1 | import { useMutation } from "@apollo/client";
2 | import { CREATE_TRANSACTION } from "../graphql/mutations/transcation.mutation";
3 | import toast from "react-hot-toast";
4 |
5 | const TransactionForm = () => {
6 | // TODO => WHEN RELATIONSHIPS ARE ADDED, CHANGE THE REFETCH QUERY A BIT
7 | const [createTransaction, { loading }] = useMutation(CREATE_TRANSACTION, {
8 | refetchQueries: ["GetTransactions", "GetTransactionStatistics"],
9 | });
10 |
11 | const handleSubmit = async (e) => {
12 | e.preventDefault();
13 |
14 | const form = e.target;
15 | const formData = new FormData(form);
16 | const transactionData = {
17 | description: formData.get("description"),
18 | paymentType: formData.get("paymentType"),
19 | category: formData.get("category"),
20 | amount: parseFloat(formData.get("amount")),
21 | location: formData.get("location"),
22 | date: formData.get("date"),
23 | };
24 |
25 | try {
26 | await createTransaction({ variables: { input: transactionData } });
27 |
28 | form.reset();
29 | toast.success("Transaction created successfully");
30 | } catch (error) {
31 | toast.error(error.message);
32 | }
33 | };
34 |
35 | return (
36 |
175 | );
176 | };
177 |
178 | export default TransactionForm;
179 |
--------------------------------------------------------------------------------
/frontend/src/pages/TransactionPage.jsx:
--------------------------------------------------------------------------------
1 | import { useMutation, useQuery } from "@apollo/client";
2 | import { useEffect, useState } from "react";
3 | import { useParams } from "react-router-dom";
4 | import { GET_TRANSACTION, GET_TRANSACTION_STATISTICS } from "../graphql/queries/transaction.query";
5 | import { UPDATE_TRANSACTION } from "../graphql/mutations/transcation.mutation";
6 | import toast from "react-hot-toast";
7 | import TransactionFormSkeleton from "../components/skeletons/TransactionFormSkeleton";
8 |
9 | const TransactionPage = () => {
10 | const { id } = useParams();
11 | const { loading, data } = useQuery(GET_TRANSACTION, {
12 | variables: { id: id },
13 | });
14 |
15 | console.log("Transaction", data);
16 |
17 | const [updateTransaction, { loading: loadingUpdate }] = useMutation(UPDATE_TRANSACTION, {
18 | // https://github.com/apollographql/apollo-client/issues/5419 => refetchQueries is not working, and here is how we fixed it
19 | refetchQueries: [{ query: GET_TRANSACTION_STATISTICS }],
20 | });
21 |
22 | const [formData, setFormData] = useState({
23 | description: data?.transaction?.description || "",
24 | paymentType: data?.transaction?.paymentType || "",
25 | category: data?.transaction?.category || "",
26 | amount: data?.transaction?.amount || "",
27 | location: data?.transaction?.location || "",
28 | date: data?.transaction?.date || "",
29 | });
30 |
31 | const handleSubmit = async (e) => {
32 | e.preventDefault();
33 | const amount = parseFloat(formData.amount); // convert amount to number bc by default it is string
34 | // and the reason it's coming from an input field
35 | try {
36 | await updateTransaction({
37 | variables: {
38 | input: {
39 | ...formData,
40 | amount,
41 | transactionId: id,
42 | },
43 | },
44 | });
45 | toast.success("Transaction updated successfully");
46 | } catch (error) {
47 | toast.error(error.message);
48 | }
49 | };
50 |
51 | const handleInputChange = (e) => {
52 | const { name, value } = e.target;
53 | setFormData((prevFormData) => ({
54 | ...prevFormData,
55 | [name]: value,
56 | }));
57 | };
58 |
59 | useEffect(() => {
60 | if (data) {
61 | setFormData({
62 | description: data?.transaction?.description,
63 | paymentType: data?.transaction?.paymentType,
64 | category: data?.transaction?.category,
65 | amount: data?.transaction?.amount,
66 | location: data?.transaction?.location,
67 | date: new Date(+data.transaction.date).toISOString().substr(0, 10),
68 | });
69 | }
70 | }, [data]);
71 |
72 | if (loading) return ;
73 |
74 | return (
75 |
76 |
77 | Update this transaction
78 |
79 |
231 |
232 | );
233 | };
234 | export default TransactionPage;
235 |
--------------------------------------------------------------------------------
/COPY-PASTE.md:
--------------------------------------------------------------------------------
1 | # COPY && PASTE LIST IF YOU FOLLOW ALONG THE TUTORIAL
2 |
3 | # Backend Dependencies
4 |
5 | ```bash
6 | npm install express express-session graphql @apollo/server @graphql-tools/merge bcryptjs connect-mongodb-session dotenv graphql-passport passport mongoose
7 | ```
8 |
9 | # Frontend Dependencies
10 |
11 | ```bash
12 | npm install graphql @apollo/client react-router-dom react-icons react-hot-toast tailwind-merge @tailwindcss/aspect-ratio clsx chart.js react-chartjs-2 mini-svg-data-uri framer-motion
13 | ```
14 |
15 | # Main.jsx
16 |
17 | ```jsx
18 | ReactDOM.createRoot(document.getElementById("root")).render(
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 | );
27 | ```
28 |
29 | # GridBackground.jsx
30 |
31 | ```jsx
32 | const GridBackground = ({ children }) => {
33 | return (
34 |
35 |
36 | {children}
37 |
38 | );
39 | };
40 | export default GridBackground;
41 | ```
42 |
43 | # App.jsx
44 |
45 | ```jsx
46 | function App() {
47 | const authUser = true;
48 | return (
49 | <>
50 | {authUser && }
51 |
52 | } />
53 | } />
54 | } />
55 | } />
56 | } />
57 |
58 | >
59 | );
60 | }
61 | export default App;
62 | ```
63 |
64 | # Header.jsx
65 |
66 | ```jsx
67 | import { Link } from "react-router-dom";
68 |
69 | const Header = () => {
70 | return (
71 |
72 |
73 | Expense GQL
74 |
75 |
76 | {/* Gradients */}
77 |
78 |
79 |
80 |
81 |
82 |
83 | );
84 | };
85 | export default Header;
86 | ```
87 |
88 | # SignUpPage.jsx
89 |
90 | ```jsx
91 | import { useState } from "react";
92 | import { Link } from "react-router-dom";
93 | import RadioButton from "../components/RadioButton";
94 | import InputField from "../components/InputField";
95 |
96 | const SignUpPage = () => {
97 | const [signUpData, setSignUpData] = useState({
98 | name: "",
99 | username: "",
100 | password: "",
101 | gender: "",
102 | });
103 |
104 | const handleChange = (e) => {
105 | const { name, value, type } = e.target;
106 |
107 | if (type === "radio") {
108 | setSignUpData((prevData) => ({
109 | ...prevData,
110 | gender: value,
111 | }));
112 | } else {
113 | setSignUpData((prevData) => ({
114 | ...prevData,
115 | [name]: value,
116 | }));
117 | }
118 | };
119 |
120 | const handleSubmit = async (e) => {
121 | e.preventDefault();
122 | console.log(signUpData);
123 | };
124 |
125 | return (
126 |
127 |
128 |
129 |
130 |
Sign Up
131 |
132 | Join to keep track of your expenses
133 |
134 |
186 |
187 |
188 | Already have an account?{" "}
189 |
190 | Login here
191 |
192 |
193 |
194 |
195 |
196 |
197 |
198 | );
199 | };
200 |
201 | export default SignUpPage;
202 | ```
203 |
204 | # InputField.jsx
205 |
206 | ```jsx
207 | const InputField = ({ label, id, name, type = "text", onChange, value }) => {
208 | return (
209 |
210 |
213 |
221 |
222 | );
223 | };
224 |
225 | export default InputField;
226 | ```
227 |
228 | # RadioButton.jsx
229 |
230 | ```jsx
231 | const RadioButton = ({ id, label, onChange, value, checked }) => {
232 | return (
233 |
234 |
255 |
258 |
259 | );
260 | };
261 |
262 | export default RadioButton;
263 | ```
264 |
265 | # LoginPage.jsx
266 |
267 | ```jsx
268 | import { Link } from "react-router-dom";
269 | import { useState } from "react";
270 | import InputField from "../components/InputField";
271 |
272 | const LoginPage = () => {
273 | const [loginData, setLoginData] = useState({
274 | username: "",
275 | password: "",
276 | });
277 |
278 | const handleChange = (e) => {
279 | const { name, value } = e.target;
280 | setLoginData((prevData) => ({
281 | ...prevData,
282 | [name]: value,
283 | }));
284 | };
285 |
286 | const handleSubmit = (e) => {
287 | e.preventDefault();
288 | console.log(loginData);
289 | };
290 |
291 | return (
292 |
293 |
294 |
295 |
296 |
Login
297 |
298 | Welcome back! Log in to your account
299 |
300 |
328 |
329 |
330 | {"Don't"} have an account?{" "}
331 |
332 | Sign Up
333 |
334 |
335 |
336 |
337 |
338 |
339 |
340 | );
341 | };
342 | export default LoginPage;
343 | ```
344 |
345 | # TransactionPage.jsx
346 |
347 | ```jsx
348 | import { useState } from "react";
349 |
350 | const TransactionPage = () => {
351 | const [formData, setFormData] = useState({
352 | description: "",
353 | paymentType: "",
354 | category: "",
355 | amount: "",
356 | location: "",
357 | date: "",
358 | });
359 |
360 | const handleSubmit = async (e) => {
361 | e.preventDefault();
362 | console.log("formData", formData);
363 | };
364 | const handleInputChange = (e) => {
365 | const { name, value } = e.target;
366 | setFormData((prevFormData) => ({
367 | ...prevFormData,
368 | [name]: value,
369 | }));
370 | };
371 |
372 | // if (loading) return ;
373 |
374 | return (
375 |
376 |
377 | Update this transaction
378 |
379 |
530 |
531 | );
532 | };
533 | export default TransactionPage;
534 | ```
535 |
536 | # TransactionFormSkeleton.jsx
537 |
538 | ```jsx
539 | const TransactionFormSkeleton = () => {
540 | return (
541 |
542 |
543 |
544 |
549 |
553 |
556 |
557 | );
558 | };
559 | export default TransactionFormSkeleton;
560 | ```
561 |
562 | # NotFound.jsx
563 |
564 | ```jsx
565 | const NotFound = () => {
566 | return (
567 |
587 | );
588 | };
589 | export default NotFound;
590 | ```
591 |
592 | # HomePage.jsx
593 |
594 | ```jsx
595 | import { Doughnut } from "react-chartjs-2";
596 | import { Chart as ChartJS, ArcElement, Tooltip, Legend } from "chart.js";
597 |
598 | import Cards from "../components/Cards";
599 | import TransactionForm from "../components/TransactionForm";
600 |
601 | import { MdLogout } from "react-icons/md";
602 |
603 | ChartJS.register(ArcElement, Tooltip, Legend);
604 |
605 | const HomePage = () => {
606 | const chartData = {
607 | labels: ["Saving", "Expense", "Investment"],
608 | datasets: [
609 | {
610 | label: "%",
611 | data: [13, 8, 3],
612 | backgroundColor: ["rgba(75, 192, 192)", "rgba(255, 99, 132)", "rgba(54, 162, 235)"],
613 | borderColor: ["rgba(75, 192, 192)", "rgba(255, 99, 132)", "rgba(54, 162, 235, 1)"],
614 | borderWidth: 1,
615 | borderRadius: 30,
616 | spacing: 10,
617 | cutout: 130,
618 | },
619 | ],
620 | };
621 |
622 | const handleLogout = () => {
623 | console.log("Logging out...");
624 | };
625 |
626 | const loading = false;
627 |
628 | return (
629 | <>
630 |
631 |
632 |
633 | Spend wisely, track wisely
634 |
635 |

640 | {!loading &&
}
641 | {/* loading spinner */}
642 | {loading &&
}
643 |
644 |
645 |
646 |
647 |
648 |
649 |
650 |
651 |
652 |
653 | >
654 | );
655 | };
656 | export default HomePage;
657 | ```
658 |
659 | # TransactionForm.jsx
660 |
661 | ```jsx
662 | const TransactionForm = () => {
663 | const handleSubmit = async (e) => {
664 | e.preventDefault();
665 |
666 | const form = e.target;
667 | const formData = new FormData(form);
668 | const transactionData = {
669 | description: formData.get("description"),
670 | paymentType: formData.get("paymentType"),
671 | category: formData.get("category"),
672 | amount: parseFloat(formData.get("amount")),
673 | location: formData.get("location"),
674 | date: formData.get("date"),
675 | };
676 | console.log("transactionData", transactionData);
677 | };
678 |
679 | return (
680 |
818 | );
819 | };
820 |
821 | export default TransactionForm;
822 | ```
823 |
824 | # Cards.jsx
825 |
826 | ```jsx
827 | import Card from "./Card";
828 |
829 | const Cards = () => {
830 | return (
831 |
832 |
History
833 |
834 |
835 |
836 |
837 |
838 |
839 |
840 |
841 |
842 | );
843 | };
844 | export default Cards;
845 | ```
846 |
847 | # Card.jsx
848 |
849 | ```jsx
850 | import { FaLocationDot } from "react-icons/fa6";
851 | import { BsCardText } from "react-icons/bs";
852 | import { MdOutlinePayments } from "react-icons/md";
853 | import { FaSackDollar } from "react-icons/fa6";
854 | import { FaTrash } from "react-icons/fa";
855 | import { HiPencilAlt } from "react-icons/hi";
856 | import { Link } from "react-router-dom";
857 |
858 | const categoryColorMap = {
859 | saving: "from-green-700 to-green-400",
860 | expense: "from-pink-800 to-pink-600",
861 | investment: "from-blue-700 to-blue-400",
862 | // Add more categories and corresponding color classes as needed
863 | };
864 |
865 | const Card = ({ cardType }) => {
866 | const cardClass = categoryColorMap[cardType];
867 |
868 | return (
869 |
870 |
871 |
872 |
Saving
873 |
874 |
875 |
876 |
877 |
878 |
879 |
880 |
881 |
882 | Description: Salary
883 |
884 |
885 |
886 | Payment Type: Cash
887 |
888 |
889 |
890 | Amount: $150
891 |
892 |
893 |
894 | Location: New York
895 |
896 |
897 |
21 Sep, 2001
898 |

903 |
904 |
905 |
906 | );
907 | };
908 | export default Card;
909 | ```
910 |
--------------------------------------------------------------------------------
/frontend/public/404.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------