feat: Implement push notifications in Budget Manager PWA

- Added functionality for subscribing to push notifications in Settings.vue.
- Integrated service worker registration and notification permission requests.
- Enhanced Vite configuration for PWA with manifest and caching strategies.
- Created a script for generating PDF reports.
- Added project report markdown file detailing the PWA implementation and features.
This commit is contained in:
2025-05-23 23:54:46 +02:00
parent 26e3b76b11
commit 8681aa88af
37 changed files with 7034 additions and 85 deletions
+3
View File
@@ -134,3 +134,6 @@ dist
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
#custom
/report
+69 -6
View File
@@ -1,7 +1,70 @@
# Aplikacja do zarządzania finansami z powiadomieniami push
# Budget Manager PWA
- Zaprojektuj aplikację PWA umożliwiającą śledzenie wydatków i przychodów.
- Wykorzystaj Local Storage do przechowywania danych użytkownika.
- Zaimplementuj funkcje budżetowania, raportowania i analizy finansowej.
- Dodaj powiadomienia push, aby informować użytkowników o ważnych wydarzeniach finansowych.
- Wykorzystaj MongoDB do przechowywania danych.
A Progressive Web App for managing personal finances with push notifications.
## Features
- Track expenses and income
- Manage budget categories with custom colors
- View financial reports and analytics
- Receive push notifications for new transactions
- Works offline with local data storage
- Full PWA support for mobile installation
## Setup Instructions
### Backend
1. Navigate to the backend directory:
```bash
cd backend
```
2. Install dependencies:
```bash
bun install
```
3. Generate VAPID keys for push notifications:
```bash
cd scripts
bun run generate-vapid-keys.js
```
4. Copy the `.env.example` to `.env` and update with your VAPID keys:
```bash
cp .env.example .env
```
5. Start the backend server:
```bash
bun start
```
### Frontend
1. Navigate to the frontend directory:
```bash
cd frontend
```
2. Install dependencies:
```bash
bun install
```
3. Start the development server:
```bash
bun dev
```
## Push Notifications
The app supports push notifications for the following events:
- New transaction added
- Budget threshold exceeded
To enable push notifications:
1. Go to the Settings page
2. Toggle "Push Notifications" to On
3. Grant notification permissions when prompted
+14
View File
@@ -0,0 +1,14 @@
# JWT Secret for authentication
JWT_SECRET=your_jwt_secret_here
# MongoDB connection
MONGODB_URI=mongodb://localhost:27017/budget_manager
# VAPID Keys for Web Push Notifications
# Generate your own keys using the script in /scripts/generate-vapid-keys.js
VAPID_PUBLIC_KEY=BEHsROsRzfqdMv4g50BEKUbFEL0NNdig0yxdmd-d9j5ZNLOOWbwsTZY3q_W_Y2E-h-hCCJmC5r3uATdFKQTN4z0
VAPID_PRIVATE_KEY=dhty4Wi3rKZlKRs_7zSPmcW6qEK8sCMZ23Qw9iMGuBM
# App settings
PORT=3000
NODE_ENV=development
+39
View File
@@ -0,0 +1,39 @@
# use the official Bun image
# see all versions at https://hub.docker.com/r/oven/bun/tags
FROM oven/bun:1 AS base
WORKDIR /usr/src/app
# install dependencies into temp directory
# this will cache them and speed up future builds
FROM base AS install
RUN mkdir -p /temp/dev
COPY package.json bun.lockb /temp/dev/
RUN cd /temp/dev && bun install --frozen-lockfile
# install with --production (exclude devDependencies)
RUN mkdir -p /temp/prod
COPY package.json bun.lockb /temp/prod/
RUN cd /temp/prod && bun install --frozen-lockfile --production
# copy node_modules from temp directory
# then copy all (non-ignored) project files into the image
FROM base AS prerelease
COPY --from=install /temp/dev/node_modules node_modules
COPY . .
# [optional] tests & build
ENV NODE_ENV=production
RUN bun run generate-vapid
# copy production dependencies and source code into final image
FROM base AS release
COPY --from=install /temp/prod/node_modules node_modules
COPY --from=prerelease /usr/src/app/index.ts .
COPY --from=prerelease /usr/src/app/src ./src
COPY --from=prerelease /usr/src/app/package.json .
COPY --from=prerelease /usr/src/app/.env .
# run the app
USER bun
EXPOSE 3000/tcp
ENTRYPOINT [ "bun", "run", "index.ts" ]
+21
View File
@@ -12,6 +12,7 @@
"koa-jwt": "^4.0.4",
"koa-router": "^13.0.1",
"mongodb": "^6.16.0",
"web-push": "^3.6.7",
},
"devDependencies": {
"@types/bcrypt": "^5.0.2",
@@ -90,10 +91,16 @@
"accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw=="],
"agent-base": ["agent-base@7.1.3", "", {}, "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw=="],
"aggregate-error": ["aggregate-error@3.1.0", "", { "dependencies": { "clean-stack": "^2.0.0", "indent-string": "^4.0.0" } }, "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA=="],
"asn1.js": ["asn1.js@5.4.1", "", { "dependencies": { "bn.js": "^4.0.0", "inherits": "^2.0.1", "minimalistic-assert": "^1.0.0", "safer-buffer": "^2.1.0" } }, "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA=="],
"bcrypt": ["bcrypt@6.0.0", "", { "dependencies": { "node-addon-api": "^8.3.0", "node-gyp-build": "^4.8.4" } }, "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg=="],
"bn.js": ["bn.js@4.12.2", "", {}, "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw=="],
"bson": ["bson@6.10.3", "", {}, "sha512-MTxGsqgYTwfshYWTRdmZRC+M7FnG1b4y7RO7p2k3X24Wq0yv1m77Wsj0BzlPzd/IowgESfsruQCUToa7vbOpPQ=="],
"buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="],
@@ -164,6 +171,10 @@
"http-errors": ["http-errors@2.0.0", "", { "dependencies": { "depd": "2.0.0", "inherits": "2.0.4", "setprototypeof": "1.2.0", "statuses": "2.0.1", "toidentifier": "1.0.1" } }, "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ=="],
"http_ece": ["http_ece@1.2.0", "", {}, "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA=="],
"https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
"iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="],
"indent-string": ["indent-string@4.0.0", "", {}, "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg=="],
@@ -216,6 +227,10 @@
"mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
"minimalistic-assert": ["minimalistic-assert@1.0.1", "", {}, "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A=="],
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
"mongodb": ["mongodb@6.16.0", "", { "dependencies": { "@mongodb-js/saslprep": "^1.1.9", "bson": "^6.10.3", "mongodb-connection-string-url": "^3.0.0" }, "peerDependencies": { "@aws-sdk/credential-providers": "^3.188.0", "@mongodb-js/zstd": "^1.1.0 || ^2.0.0", "gcp-metadata": "^5.2.0", "kerberos": "^2.0.1", "mongodb-client-encryption": ">=6.0.0 <7", "snappy": "^7.2.2", "socks": "^2.7.1" }, "optionalPeers": ["@aws-sdk/credential-providers", "@mongodb-js/zstd", "gcp-metadata", "kerberos", "mongodb-client-encryption", "snappy", "socks"] }, "sha512-D1PNcdT0y4Grhou5Zi/qgipZOYeWrhLEpk33n3nm6LGtz61jvO88WlrWCK/bigMjpnOdAUKKQwsGIl0NtWMyYw=="],
"mongodb-connection-string-url": ["mongodb-connection-string-url@3.0.2", "", { "dependencies": { "@types/whatwg-url": "^11.0.2", "whatwg-url": "^14.1.0 || ^13.0.0" } }, "sha512-rMO7CGo/9BFwyZABcKAWL8UJwH/Kc2x0g72uhDWzG48URRax5TCIcJ7Rc3RZqffZzO/Gwff/jyKwCU9TN8gehA=="],
@@ -286,6 +301,8 @@
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
"web-push": ["web-push@3.6.7", "", { "dependencies": { "asn1.js": "^5.3.0", "http_ece": "1.2.0", "https-proxy-agent": "^7.0.0", "jws": "^4.0.0", "minimist": "^1.2.5" }, "bin": { "web-push": "src/cli.js" } }, "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A=="],
"webidl-conversions": ["webidl-conversions@7.0.0", "", {}, "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g=="],
"whatwg-url": ["whatwg-url@14.2.0", "", { "dependencies": { "tr46": "^5.1.0", "webidl-conversions": "^7.0.0" } }, "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw=="],
@@ -300,6 +317,8 @@
"type-is/mime-types": ["mime-types@3.0.1", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA=="],
"web-push/jws": ["jws@4.0.0", "", { "dependencies": { "jwa": "^2.0.0", "safe-buffer": "^5.0.1" } }, "sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg=="],
"co-body/type-is/media-typer": ["media-typer@0.3.0", "", {}, "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ=="],
"http-assert/http-errors/depd": ["depd@1.1.2", "", {}, "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ=="],
@@ -309,5 +328,7 @@
"koa-bodyparser/type-is/media-typer": ["media-typer@0.3.0", "", {}, "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ=="],
"type-is/mime-types/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],
"web-push/jws/jwa": ["jwa@2.0.1", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg=="],
}
}
+7 -1
View File
@@ -3,6 +3,11 @@
"module": "index.ts",
"type": "module",
"private": true,
"scripts": {
"start": "bun run index.ts",
"dev": "bun --watch run index.ts",
"generate-vapid": "bun run scripts/generate-vapid-keys.js"
},
"devDependencies": {
"@types/bcrypt": "^5.0.2",
"@types/bun": "latest",
@@ -23,6 +28,7 @@
"koa-bodyparser": "^4.4.1",
"koa-jwt": "^4.0.4",
"koa-router": "^13.0.1",
"mongodb": "^6.16.0"
"mongodb": "^6.16.0",
"web-push": "^3.6.7"
}
}
+21
View File
@@ -0,0 +1,21 @@
/**
* Script to generate VAPID keys for Web Push Notifications
* Run with: bun run generate-vapid-keys.js
*/
const webpush = require('web-push');
// Generate VAPID keys (public/private key pair)
const vapidKeys = webpush.generateVAPIDKeys();
console.log('VAPID Keys generated successfully:');
console.log('===============================');
console.log('Public Key:');
console.log(vapidKeys.publicKey);
console.log('===============================');
console.log('Private Key:');
console.log(vapidKeys.privateKey);
console.log('===============================');
console.log('Add these to your .env file as:');
console.log('VAPID_PUBLIC_KEY="' + vapidKeys.publicKey + '"');
console.log('VAPID_PRIVATE_KEY="' + vapidKeys.privateKey + '"');
console.log('===============================');
+12
View File
@@ -3,6 +3,7 @@ import { ObjectId } from "mongodb";
import dbConfig from "../config/database";
import type { Transaction } from "../models";
import DatabaseService from "../services/database";
import pushController from "./push";
const db = DatabaseService.getInstance();
@@ -118,6 +119,17 @@ export default {
console.log("Inserting transaction:", newTransaction);
const result = await transactionsCollection.insertOne(newTransaction);
// Send push notification for the new transaction
try {
await pushController.sendTransactionNotification(
userId,
{ ...newTransaction, _id: result.insertedId }
);
} catch (notificationError) {
console.error("Failed to send notification:", notificationError);
// Continue even if notification fails
}
ctx.status = 201;
ctx.body = {
success: true,
+5 -2
View File
@@ -6,8 +6,9 @@ import webpush from "web-push";
// Configure web-push with VAPID keys
const vapidKeys = {
publicKey: process.env.VAPID_PUBLIC_KEY || 'your-public-key',
privateKey: process.env.VAPID_PRIVATE_KEY || 'your-private-key'
// These should be replaced with generated keys from the script
publicKey: process.env.VAPID_PUBLIC_KEY || 'BEHsROsRzfqdMv4g50BEKUbFEL0NNdig0yxdmd-d9j5ZNLOOWbwsTZY3q_W_Y2E-h-hCCJmC5r3uATdFKQTN4z0',
privateKey: process.env.VAPID_PRIVATE_KEY || 'dhty4Wi3rKZlKRs_7zSPmcW6qEK8sCMZ23Qw9iMGuBM'
};
webpush.setVapidDetails(
@@ -21,7 +22,9 @@ const db = DatabaseService.getInstance();
export default {
// Get VAPID public key
async getVapidPublicKey(ctx: Context) {
ctx.set('Content-Type', 'text/plain');
ctx.body = vapidKeys.publicKey;
console.log("Returning VAPID public key:", vapidKeys.publicKey);
},
// Register a new push subscription
+6 -1
View File
@@ -7,7 +7,12 @@ export const authenticate: Middleware = jwt({
secret: jwtConfig.secret,
}).unless({
// Paths that don't require authentication
path: ["/api/auth/login", "/api/auth/register", "/api/auth/register-admin"],
path: [
"/api/auth/login",
"/api/auth/register",
"/api/auth/register-admin",
"/api/push/vapid-public-key"
],
});
// Error handling middleware for JWT authentication
+4
View File
@@ -53,4 +53,8 @@ router.delete(
categoriesController.deleteCategory,
);
// Push notification routes
router.get("/push/vapid-public-key", pushController.getVapidPublicKey);
router.post("/push/register", pushController.registerPushSubscription);
export default router;
+8
View File
@@ -8,6 +8,7 @@
"@tailwindcss/vite": "^4.1.7",
"@vee-validate/zod": "^4.15.0",
"@vueuse/core": "^13.2.0",
"chart.js": "^4.4.9",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-vue-next": "^0.511.0",
@@ -20,6 +21,7 @@
"tw-animate-css": "^1.3.0",
"vee-validate": "^4.15.0",
"vue": "^3.5.14",
"vue-chartjs": "^5.3.2",
"vue-router": "^4.5.1",
"zod": "^3.25.7",
},
@@ -308,6 +310,8 @@
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.25", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ=="],
"@kurkle/color": ["@kurkle/color@0.3.4", "", {}, "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w=="],
"@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
"@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="],
@@ -562,6 +566,8 @@
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
"chart.js": ["chart.js@4.4.9", "", { "dependencies": { "@kurkle/color": "^0.3.0" } }, "sha512-EyZ9wWKgpAU0fLJ43YAEIF8sr5F2W3LqbS40ZJyHIner2lY14ufqv2VMp69MAiZ2rpwxEUxEhIH/0U3xyRynxg=="],
"chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
"chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="],
@@ -1148,6 +1154,8 @@
"vue": ["vue@3.5.14", "", { "dependencies": { "@vue/compiler-dom": "3.5.14", "@vue/compiler-sfc": "3.5.14", "@vue/runtime-dom": "3.5.14", "@vue/server-renderer": "3.5.14", "@vue/shared": "3.5.14" }, "peerDependencies": { "typescript": "*" }, "optionalPeers": ["typescript"] }, "sha512-LbOm50/vZFG6Mhy6KscQYXZMQ0LMCC/y40HDJPPvGFQ+i/lUH+PJHR6C3assgOQiXdl6tAfsXHbXYVBZZu65ew=="],
"vue-chartjs": ["vue-chartjs@5.3.2", "", { "peerDependencies": { "chart.js": "^4.1.1", "vue": "^3.0.0-0 || ^2.7.0" } }, "sha512-NrkbRRoYshbXbWqJkTN6InoDVwVb90C0R7eAVgMWcB9dPikbruaOoTFjFYHE/+tNPdIe6qdLCDjfjPHQ0fw4jw=="],
"vue-demi": ["vue-demi@0.14.10", "", { "peerDependencies": { "@vue/composition-api": "^1.0.0-rc.1", "vue": "^3.0.0-0 || ^2.6.0" }, "optionalPeers": ["@vue/composition-api"], "bin": { "vue-demi-fix": "bin/vue-demi-fix.js", "vue-demi-switch": "bin/vue-demi-switch.js" } }, "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg=="],
"vue-router": ["vue-router@4.5.1", "", { "dependencies": { "@vue/devtools-api": "^6.6.4" }, "peerDependencies": { "vue": "^3.2.0" } }, "sha512-ogAF3P97NPm8fJsE4by9dwSYtDwXIY1nFY9T6DyQnGHd1E2Da94w9JIolpe42LJGIl0DwOHBi8TcRPlPGwbTtw=="],
+27 -2
View File
@@ -67,7 +67,7 @@ if (!self.define) {
});
};
}
define(['./workbox-54d0af47'], (function (workbox) { 'use strict';
define(['./workbox-47da91e0'], (function (workbox) { 'use strict';
self.skipWaiting();
workbox.clientsClaim();
@@ -82,11 +82,36 @@ define(['./workbox-54d0af47'], (function (workbox) { 'use strict';
"revision": "3ca0b8505b4bec776b69afdba2768812"
}, {
"url": "index.html",
"revision": "0.i9edo79oi9g"
"revision": "0.luk243ceho"
}], {});
workbox.cleanupOutdatedCaches();
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {
allowlist: [/^\/$/]
}));
workbox.registerRoute(/^https:\/\/fonts\.googleapis\.com\/.*/i, new workbox.CacheFirst({
"cacheName": "google-fonts-cache",
plugins: [new workbox.ExpirationPlugin({
maxEntries: 10,
maxAgeSeconds: 31536000
}), new workbox.CacheableResponsePlugin({
statuses: [0, 200]
})]
}), 'GET');
workbox.registerRoute(/^https:\/\/fonts\.gstatic\.com\/.*/i, new workbox.CacheFirst({
"cacheName": "gstatic-fonts-cache",
plugins: [new workbox.ExpirationPlugin({
maxEntries: 10,
maxAgeSeconds: 31536000
}), new workbox.CacheableResponsePlugin({
statuses: [0, 200]
})]
}), 'GET');
workbox.registerRoute(/\/api\/(?!.*auth).*/i, new workbox.NetworkFirst({
"cacheName": "api-cache",
plugins: [new workbox.ExpirationPlugin({
maxEntries: 50,
maxAgeSeconds: 3600
})]
}), 'GET');
}));
File diff suppressed because it is too large Load Diff
+2
View File
@@ -13,6 +13,7 @@
"@tailwindcss/vite": "^4.1.7",
"@vee-validate/zod": "^4.15.0",
"@vueuse/core": "^13.2.0",
"chart.js": "^4.4.9",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-vue-next": "^0.511.0",
@@ -25,6 +26,7 @@
"tw-animate-css": "^1.3.0",
"vee-validate": "^4.15.0",
"vue": "^3.5.14",
"vue-chartjs": "^5.3.2",
"vue-router": "^4.5.1",
"zod": "^3.25.7"
},
+103 -7
View File
@@ -110,24 +110,120 @@ self.addEventListener("sync", (event) => {
// Push notification handler
self.addEventListener("push", (event) => {
const data = event.data.json();
let data;
try {
data = event.data.json();
} catch (error) {
// If the data is not JSON, try to get it as text
data = {
message: event.data ? event.data.text() : "New notification",
additionalData: {}
};
}
// Format transaction type for display
let messageText = data.message || "New transaction";
// Default notification options
const options = {
body: data.body,
body: messageText,
icon: "/icons/icon-192px.jpg",
badge: "/icons/badge-96x96.png",
data: data.data,
actions: data.actions || [],
badge: "/icons/icon-192px.jpg",
vibrate: [100, 50, 100],
data: data.additionalData || {},
actions: [
{
action: 'view',
title: 'View Details'
}
],
// Tag transactions by ID to prevent duplicate notifications
tag: data.additionalData?.transactionId || 'transaction'
};
event.waitUntil(self.registration.showNotification(data.title, options));
// Show different icons based on transaction type
if (data.additionalData?.type === 'income') {
options.icon = "/icons/income-icon.jpg";
} else if (data.additionalData?.type === 'expense') {
options.icon = "/icons/expense-icon.jpg";
}
console.log('Showing notification:', messageText);
// Forward to any open clients
forwardNotificationToClients(data);
// Show system notification
event.waitUntil(self.registration.showNotification("Budget Manager", options));
});
// Notification click handler
self.addEventListener("notificationclick", (event) => {
event.notification.close();
event.waitUntil(clients.openWindow("/"));
// Get transaction ID from notification data
const transactionId = event.notification.data?.transactionId;
// Handle different notification actions
if (event.action === 'view' && transactionId) {
// Open transaction details page
event.waitUntil(
clients.matchAll({type: 'window'})
.then(clientList => {
// Check if there is already a window open
for (const client of clientList) {
if (client.url.includes('/') && 'focus' in client) {
client.navigate(`/edit/${transactionId}`);
return client.focus();
}
}
// If no window is open, open a new one
return clients.openWindow(`/edit/${transactionId}`);
})
);
} else {
// Default: open the app
event.waitUntil(
clients.matchAll({type: 'window'})
.then(clientList => {
for (const client of clientList) {
if (client.url.includes('/') && 'focus' in client) {
return client.focus();
}
}
return clients.openWindow('/');
})
);
}
});
// Forward push notifications to all clients
function forwardNotificationToClients(data) {
self.clients.matchAll().then(clients => {
clients.forEach(client => {
// Send message to client
client.postMessage({
type: 'PUSH_NOTIFICATION',
title: 'Budget Manager',
body: data.message,
transactionId: data.additionalData?.transactionId,
type: data.additionalData?.type
});
});
});
}
// Listen for messages from clients
self.addEventListener('message', (event) => {
if (event.data && event.data.type === 'GET_AUTH_TOKEN') {
// Token request from service worker
self.clients.matchAll().then(clients => {
if (clients.length > 0) {
const token = localStorage.getItem('token');
event.ports[0].postMessage({ token });
}
});
}
});
// Sync transactions with the server
+4
View File
@@ -13,6 +13,7 @@
*/
import { ref, onMounted } from "vue";
import NotificationsProvider from "@/components/NotificationsProvider.vue";
// 'ref' is like React's useState - creates a reactive variable
// In React: const [isPageLoaded, setIsPageLoaded] = useState(false)
@@ -60,6 +61,9 @@
-->
<component :is="Component" :key="$route.fullPath" />
</router-view>
<!-- Notifications provider for in-app push notifications -->
<NotificationsProvider />
</div>
</template>
+2 -3
View File
@@ -48,6 +48,8 @@
-->
<div class="py-2">
<ul class="space-y-1">
<!-- Auth-required links -->
<template v-if="authStore.isAuthenticated">
<li>
<router-link
to="/"
@@ -57,9 +59,6 @@
Dashboard
</router-link>
</li>
<!-- Auth-required links -->
<template v-if="authStore.isAuthenticated">
<li>
<router-link
to="/transactions"
@@ -0,0 +1,117 @@
<script setup lang="ts">
import { ref, onMounted, watch } from "vue";
import {
Toast,
ToastClose,
ToastDescription,
ToastProvider,
ToastTitle,
ToastViewport,
} from "@/components/ui/toast";
import { Bell } from "lucide-vue-next";
import { useAuthStore } from "@/stores/auth";
import "@/../styles/tailwind.css";
// Track notifications
const notifications = ref<
Array<{
id: string;
title: string;
description: string;
type?: "default" | "success" | "info" | "warning" | "error";
duration?: number;
}>
>([]);
// Auth store
const authStore = useAuthStore();
// Handle incoming push messages
function handlePushMessage(event: MessageEvent) {
if (event.data && event.data.type === "PUSH_NOTIFICATION") {
const { title, body, transactionId, type } = event.data;
// Add to notifications
notifications.value.push({
id: transactionId || Date.now().toString(),
title: title || "New Notification",
description: body || "You have a new notification",
type:
type === "income"
? "success"
: type === "expense"
? "warning"
: "info",
duration: 5000,
});
}
}
// Remove a notification
function removeNotification(id: string) {
const index = notifications.value.findIndex((n) => n.id === id);
if (index !== -1) {
notifications.value.splice(index, 1);
}
}
// Set up message listener when authenticated
watch(
() => authStore.isAuthenticated,
(authenticated) => {
if (authenticated && navigator.serviceWorker) {
navigator.serviceWorker.addEventListener("message", handlePushMessage);
}
},
);
onMounted(() => {
// Set up service worker message listener
if (authStore.isAuthenticated && navigator.serviceWorker) {
navigator.serviceWorker.addEventListener("message", handlePushMessage);
}
// Clean up on unmount
return () => {
if (navigator.serviceWorker) {
navigator.serviceWorker.removeEventListener(
"message",
handlePushMessage,
);
}
};
});
</script>
<template>
<ToastProvider>
<Toast
v-for="notification in notifications"
:key="notification.id"
:class="notification.type ? `type-${notification.type}` : ''"
@close="removeNotification(notification.id)">
<div class="flex items-center gap-2">
<Bell class="h-5 w-5" />
<ToastTitle>{{ notification.title }}</ToastTitle>
</div>
<ToastDescription>{{ notification.description }}</ToastDescription>
<ToastClose />
</Toast>
<ToastViewport />
</ToastProvider>
</template>
<!--
<style scoped>
.type-success {
@apply border-green-500 bg-green-50;
}
.type-warning {
@apply border-amber-500 bg-amber-50;
}
.type-error {
@apply border-red-500 bg-red-50;
}
.type-info {
@apply border-blue-500 bg-blue-50;
}
</style> -->
+118
View File
@@ -0,0 +1,118 @@
<script setup lang="ts">
import { Bar } from 'vue-chartjs'
import {
Chart as ChartJS,
Title,
Tooltip,
Legend,
BarElement,
CategoryScale,
LinearScale
} from 'chart.js'
import { computed } from 'vue';
ChartJS.register(
Title,
Tooltip,
Legend,
BarElement,
CategoryScale,
LinearScale
)
const props = defineProps<{
chartData: {
labels: string[],
datasets: Array<{
label: string,
data: number[],
backgroundColor?: string,
borderColor?: string,
borderWidth?: number
}>
},
chartOptions?: any
}>()
const defaultOptions = {
responsive: true,
maintainAspectRatio: false,
interaction: {
intersect: false,
mode: 'index'
},
scales: {
x: {
grid: {
display: false
}
},
y: {
beginAtZero: true,
grid: {
display: true,
color: 'rgba(200, 200, 200, 0.2)'
},
ticks: {
callback: function(value) {
return value.toLocaleString('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: 0,
maximumFractionDigits: 0
});
}
}
}
},
plugins: {
legend: {
position: 'bottom',
labels: {
usePointStyle: true,
boxWidth: 10
}
},
tooltip: {
callbacks: {
label: function(context) {
let label = context.dataset.label || '';
if (label) {
label += ': ';
}
if (context.parsed.y !== null) {
label += new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD'
}).format(context.parsed.y);
}
return label;
}
}
}
}
}
const options = computed(() => {
return {
...defaultOptions,
...props.chartOptions
}
})
</script>
<template>
<div class="bar-chart">
<Bar
:data="chartData"
:options="options"
/>
</div>
</template>
<style scoped>
.bar-chart {
min-height: 300px;
position: relative;
}
</style>
@@ -0,0 +1,74 @@
<script setup lang="ts">
import { Doughnut } from 'vue-chartjs'
import {
Chart as ChartJS,
Title,
Tooltip,
Legend,
ArcElement,
CategoryScale
} from 'chart.js'
import { computed } from 'vue';
ChartJS.register(
Title,
Tooltip,
Legend,
ArcElement,
CategoryScale
)
const props = defineProps<{
chartData: {
labels: string[],
datasets: Array<{
data: number[],
backgroundColor?: string[],
borderColor?: string[],
circumference?: number,
rotation?: number,
cutout?: string,
borderWidth?: number
}>
},
chartOptions?: any
}>()
const defaultOptions = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'bottom',
labels: {
usePointStyle: true,
boxWidth: 10
}
}
},
cutout: '75%'
}
const options = computed(() => {
return {
...defaultOptions,
...props.chartOptions
}
})
</script>
<template>
<div class="doughnut-chart">
<Doughnut
:data="chartData"
:options="options"
/>
</div>
</template>
<style scoped>
.doughnut-chart {
min-height: 300px;
position: relative;
}
</style>
@@ -0,0 +1,71 @@
<script setup lang="ts">
import { Pie } from 'vue-chartjs'
import {
Chart as ChartJS,
Title,
Tooltip,
Legend,
ArcElement,
CategoryScale
} from 'chart.js'
import { computed } from 'vue';
ChartJS.register(
Title,
Tooltip,
Legend,
ArcElement,
CategoryScale
)
const props = defineProps<{
chartData: {
labels: string[],
datasets: Array<{
data: number[],
backgroundColor?: string[],
borderColor?: string[],
hoverOffset?: number,
borderWidth?: number
}>
},
chartOptions?: any
}>()
const defaultOptions = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'bottom',
labels: {
usePointStyle: true,
boxWidth: 10
}
}
},
}
const options = computed(() => {
return {
...defaultOptions,
...props.chartOptions
}
})
</script>
<template>
<div class="pie-chart">
<Pie
:data="chartData"
:options="options"
/>
</div>
</template>
<style scoped>
.pie-chart {
min-height: 300px;
position: relative;
}
</style>
@@ -0,0 +1,36 @@
<!-- src/components/ui/toast/Toast.vue -->
<script lang="ts">
export const positions = {
"top-left": "top-0 left-0",
"top-center": "top-0 left-1/2 -translate-x-1/2",
"top-right": "top-0 right-0",
"bottom-left": "bottom-0 left-0",
"bottom-center": "bottom-0 left-1/2 -translate-x-1/2",
"bottom-right": "bottom-0 right-0",
}
export type ToastProps = {
position?: keyof typeof positions
class?: string
}
</script>
<script setup lang="ts">
import { computed, toRefs } from "vue"
const props = defineProps<ToastProps>()
const { position = "bottom-right", class: className } = toRefs(props)
const computedPosition = computed(() => positions[position.value])
</script>
<template>
<li
:class="[
'pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border border-slate-200 p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full bg-white dark:border-slate-800 dark:bg-slate-950',
className,
]"
>
<slot />
</li>
</template>
@@ -0,0 +1,18 @@
<!-- src/components/ui/toast/ToastClose.vue -->
<script setup lang="ts">
import { X } from "lucide-vue-next"
const emit = defineEmits<{
(e: "click", event: MouseEvent): void
}>()
</script>
<template>
<button
class="absolute right-1 top-1 rounded-md p-1 text-slate-950/50 opacity-0 transition-opacity hover:text-slate-950 focus:opacity-100 focus:outline-none focus:ring-1 group-hover:opacity-100 dark:text-slate-50/50 dark:hover:text-slate-50"
@click="(event) => emit('click', event)"
>
<X class="h-4 w-4" />
<span class="sr-only">Close</span>
</button>
</template>
@@ -0,0 +1,6 @@
<!-- src/components/ui/toast/ToastDescription.vue -->
<template>
<div class="text-sm opacity-90">
<slot />
</div>
</template>
@@ -0,0 +1,6 @@
<!-- src/components/ui/toast/ToastProvider.vue -->
<template>
<div class="relative">
<slot />
</div>
</template>
@@ -0,0 +1,6 @@
<!-- src/components/ui/toast/ToastTitle.vue -->
<template>
<div class="text-sm font-medium">
<slot />
</div>
</template>
@@ -0,0 +1,25 @@
<!-- src/components/ui/toast/ToastViewport.vue -->
<script lang="ts">
import { positions, type ToastProps } from "./Toast.vue"
</script>
<script setup lang="ts">
import { toRefs } from "vue"
const props = withDefaults(defineProps<ToastProps>(), {
position: "bottom-right",
})
const { position, class: className } = toRefs(props)
</script>
<template>
<ol
:class="[
'fixed z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:flex-col md:max-w-[420px] gap-2',
positions[position],
className,
]"
>
<slot />
</ol>
</template>
@@ -0,0 +1,6 @@
export { default as Toast } from "./Toast.vue"
export { default as ToastClose } from "./ToastClose.vue"
export { default as ToastDescription } from "./ToastDescription.vue"
export { default as ToastProvider } from "./ToastProvider.vue"
export { default as ToastTitle } from "./ToastTitle.vue"
export { default as ToastViewport } from "./ToastViewport.vue"
+132
View File
@@ -19,6 +19,9 @@ export async function registerServiceWorker() {
console.log("Service worker active");
}
// Ensure the service worker is ready before trying to subscribe
await navigator.serviceWorker.ready;
return registration;
} catch (error) {
console.error("Registration failed with error:", error);
@@ -98,3 +101,132 @@ export function showBudgetAlert(percentage, category) {
export function isOnline() {
return navigator.onLine;
}
/**
* Get the VAPID public key from the server
* @returns {Promise<string>} The VAPID public key
*/
export async function getVapidPublicKey(): Promise<string | null> {
try {
console.log('Fetching VAPID public key...');
const response = await fetch('/api/push/vapid-public-key', {
method: 'GET',
headers: {
'Accept': 'text/plain'
}
});
if (!response.ok) {
console.error('Failed to fetch VAPID public key, status:', response.status);
throw new Error('Failed to fetch VAPID public key');
}
const key = await response.text();
console.log('Received VAPID public key:', key.substring(0, 10) + '...');
return key;
} catch (error) {
console.error('Error getting VAPID public key:', error);
return null;
}
}
/**
* Convert a base64 string to a Uint8Array for push subscription
* @param {string} base64String The base64 string to convert
* @returns {Uint8Array} The converted array
*/
function urlBase64ToUint8Array(base64String: string): ArrayBuffer {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding)
.replace(/-/g, '+')
.replace(/_/g, '/');
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray.buffer;
}
/**
* Subscribe to push notifications
* @returns {Promise<PushSubscription|null>} The push subscription
*/
export async function subscribeToPushNotifications(): Promise<PushSubscription | null> {
try {
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
console.log('Push notifications not supported');
return null;
}
// Get service worker registration
const registration = await navigator.serviceWorker.ready;
console.log('Service worker is ready for push subscription');
// Check for existing subscription
const existingSubscription = await registration.pushManager.getSubscription();
if (existingSubscription) {
console.log('Already subscribed to push notifications');
return existingSubscription;
}
// Get VAPID key
const vapidPublicKey = await getVapidPublicKey();
if (!vapidPublicKey) {
throw new Error('Failed to get VAPID public key');
}
console.log('Converting VAPID key to Uint8Array');
// Convert key to array
const applicationServerKey = urlBase64ToUint8Array(vapidPublicKey);
console.log('Subscribing to push notifications...');
// Subscribe
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey
});
console.log('Push subscription successful, registering with server');
// Register with backend
await registerSubscriptionWithServer(subscription);
return subscription;
} catch (error) {
console.error('Error subscribing to push notifications:', error);
return null;
}
}
/**
* Register push subscription with the backend
* @param {PushSubscription} subscription The push subscription to register
*/
async function registerSubscriptionWithServer(subscription: PushSubscription): Promise<boolean> {
try {
const token = localStorage.getItem('token');
if (!token) {
throw new Error('Not authenticated');
}
const response = await fetch('/api/push/register', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({ subscription })
});
if (!response.ok) {
throw new Error('Failed to register subscription with server');
}
return true;
} catch (error) {
console.error('Error registering subscription with server:', error);
return false;
}
}
+21 -4
View File
@@ -4,12 +4,28 @@ import "../styles/transitions.css";
import "../styles/style.css";
import App from "./App.vue";
import { createRouter, createWebHistory } from "vue-router";
import { registerServiceWorker } from "./lib/pwa-utils";
import { registerServiceWorker, subscribeToPushNotifications } from "./lib/pwa-utils";
// Register service worker for PWA functionality
if (import.meta.env.PROD) {
registerServiceWorker();
// Initialize PWA functionality
async function initPWA() {
// Register service worker
const registration = await registerServiceWorker();
// Check if push notifications are enabled
if (registration &&
localStorage.getItem("notificationsEnabled") === "true" &&
Notification.permission === "granted") {
try {
await subscribeToPushNotifications();
console.log("Push notifications subscription successful");
} catch (error) {
console.error("Failed to subscribe to push notifications:", error);
}
}
}
// Register service worker and initialize PWA
initPWA();
const router = createRouter({
// createWebHistory is like BrowserRouter in React Router
@@ -20,6 +36,7 @@ const router = createRouter({
// Dynamic imports for code splitting - similar to React.lazy
// In React: const Dashboard = React.lazy(() => import('./pages/Dashboard'))
component: () => import("./pages/Dashboard.vue"),
meta: { requiresAuth: true },
},
{
path: "/about",
-26
View File
@@ -1,13 +1,4 @@
<script setup lang="ts">
/**
* Login Page Component
* -------------------
* This is a page component that handles user authentication.
* In React terms, this would be a container/page component with form handling logic.
*/
// Import UI components - similar to importing from a UI library in React
// These are shadcn components (Vue's equivalent of shadcn/ui for React)
import { Card, CardHeader, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -30,9 +21,6 @@
const isRegistering = ref(false);
const email = ref("");
/**
* Handle login form submission
*/
const login = async () => {
if (!username.value || !password.value) {
errorMessage.value = "Please enter both username and password";
@@ -89,26 +77,12 @@
}
};
/**
* Toggle between login and register forms
*/
const toggleRegistration = () => {
isRegistering.value = !isRegistering.value;
errorMessage.value = "";
};
</script>
<template>
<!--
Main Template
------------
In React, this would all be JSX returned by the component function.
Key differences:
- Vue uses <template> section instead of return statement with JSX
- Vue uses @ for events (e.g., @submit) instead of React's camelCase (e.g., onSubmit)
- Vue uses : for props (e.g., :disabled) instead of React's {} (e.g., disabled={value})
- Vue uses v-for, v-if instead of React's .map() and && or ternary operators
-->
<PageLayout header="Login">
<!-- Layout structure (similar to React) -->
<div class="flex justify-center items-center h-full">
+145 -20
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, computed, onMounted } from "vue";
import { ref, computed, onMounted, reactive } from "vue";
import PageLayout from "@/components/PageLayout.vue";
import {
Card,
@@ -11,7 +11,10 @@
import { useTransactionsStore } from "@/stores/transactions";
import { useCategoriesStore } from "@/stores/categories";
import { formatCurrency } from "@/lib/utils";
import { PieChart, BarChart, Calendar } from "lucide-vue-next";
import { PieChart as PieChartIcon, BarChart as BarChartIcon, Calendar } from "lucide-vue-next";
import PieChart from "@/components/charts/PieChart.vue";
import BarChart from "@/components/charts/BarChart.vue";
import DoughnutChart from "@/components/charts/DoughnutChart.vue";
// Initialize stores
const transactionsStore = useTransactionsStore();
@@ -166,6 +169,78 @@
return result;
});
// Chart data for monthly trend
const monthlyTrendChartData = computed(() => {
return {
labels: monthlyTrend.value.map(item => item.month),
datasets: [
{
label: 'Income',
data: monthlyTrend.value.map(item => item.income),
backgroundColor: 'rgba(75, 192, 75, 0.6)',
borderColor: 'rgba(75, 192, 75, 1)',
borderWidth: 1
},
{
label: 'Expenses',
data: monthlyTrend.value.map(item => item.expenses),
backgroundColor: 'rgba(255, 99, 132, 0.6)',
borderColor: 'rgba(255, 99, 132, 1)',
borderWidth: 1
},
{
label: 'Savings',
data: monthlyTrend.value.map(item => item.savings),
backgroundColor: 'rgba(54, 162, 235, 0.6)',
borderColor: 'rgba(54, 162, 235, 1)',
borderWidth: 1
}
]
};
});
// Chart data for category breakdown
const categoryChartData = computed(() => {
return {
labels: categoryBreakdown.value.map(item => item.name),
datasets: [{
data: categoryBreakdown.value.map(item => item.amount),
backgroundColor: categoryBreakdown.value.map(item => item.color),
borderColor: categoryBreakdown.value.map(item => item.color),
hoverOffset: 4,
borderWidth: 1
}]
};
});
// Budget usage doughnut chart data
const budgetChartData = computed(() => {
// For this example, we'll use a mock budget value of $2000
// In a real app, you'd get this from user settings or another store
const targetBudget = 2000;
const spent = monthlySummary.value.expenses;
const remaining = Math.max(0, targetBudget - spent);
const percentage = Math.min(100, Math.round((spent / targetBudget) * 100));
let statusColor = '#22c55e'; // green for < 70%
if (percentage >= 90) {
statusColor = '#ef4444'; // red for >= 90%
} else if (percentage >= 70) {
statusColor = '#f59e0b'; // amber for >= 70%
}
return {
labels: ['Spent', 'Remaining'],
datasets: [{
data: [spent, remaining],
backgroundColor: [statusColor, '#e5e7eb'],
borderColor: ['transparent', 'transparent'],
borderWidth: 0,
cutout: '80%'
}]
};
});
// Function to switch between report types
const setReportType = (type) => {
activeReport.value = type;
@@ -186,13 +261,13 @@
<Button
:variant="activeReport === 'overview' ? 'default' : 'outline'"
@click="setReportType('overview')">
<PieChart class="mr-2 h-4 w-4" />
<PieChartIcon class="mr-2 h-4 w-4" />
Monthly Overview
</Button>
<Button
:variant="activeReport === 'byCategory' ? 'default' : 'outline'"
@click="setReportType('byCategory')">
<BarChart class="mr-2 h-4 w-4" />
<BarChartIcon class="mr-2 h-4 w-4" />
Spending by Category
</Button>
<Button
@@ -282,17 +357,53 @@
</CardContent>
</Card>
<!-- Placeholder for budget comparison chart -->
<!-- Budget comparison chart -->
<Card>
<CardHeader>
<CardTitle>Budget Comparison</CardTitle>
</CardHeader>
<CardContent>
<div class="h-40 flex items-center justify-center">
<p class="text-gray-500 text-center">
Budget comparison visualization will appear here.
<br /><small>Set your budget targets in Settings</small>
</p>
<div class="relative h-60">
<DoughnutChart
:chartData="budgetChartData"
:chartOptions="{
plugins: {
tooltip: {
callbacks: {
label: (context) => {
const value = context.parsed;
const label = context.label;
return `${label}: ${formatCurrency(value)}`;
}
}
},
legend: {
display: false
}
}
}"
/>
<!-- Center text with percentage -->
<div class="absolute inset-0 flex flex-col items-center justify-center">
<span class="text-4xl font-bold">
{{ Math.min(100, Math.round((monthlySummary.expenses / 2000) * 100)) }}%
</span>
<span class="text-sm text-gray-500">of monthly budget</span>
</div>
</div>
<div class="mt-4 flex justify-between text-sm">
<div>
<div class="text-gray-500">Monthly Budget</div>
<div class="font-medium">{{ formatCurrency(2000) }}</div>
</div>
<div>
<div class="text-gray-500">Spent</div>
<div class="font-medium">{{ formatCurrency(monthlySummary.expenses) }}</div>
</div>
<div>
<div class="text-gray-500">Remaining</div>
<div class="font-medium">{{ formatCurrency(Math.max(0, 2000 - monthlySummary.expenses)) }}</div>
</div>
</div>
</CardContent>
</Card>
@@ -312,11 +423,25 @@
</div>
<div v-else class="space-y-6">
<!-- Placeholder for pie chart -->
<div class="h-40 flex items-center justify-center">
<p class="text-gray-500 text-center">
Category distribution chart will appear here.
</p>
<!-- Pie chart for category spending -->
<div class="h-72">
<PieChart
:chartData="categoryChartData"
:chartOptions="{
plugins: {
tooltip: {
callbacks: {
label: (context) => {
const value = context.parsed;
const label = context.label;
const percentage = categoryBreakdown.value[context.dataIndex]?.percentage;
return `${label}: ${formatCurrency(value)} (${percentage}%)`;
}
}
}
}
}"
/>
</div>
<!-- Category breakdown table -->
@@ -349,11 +474,11 @@
<CardTitle>Monthly Income & Expenses ({{ selectedYear }})</CardTitle>
</CardHeader>
<CardContent>
<!-- Placeholder for bar chart -->
<div class="h-60 flex items-center justify-center mb-6">
<p class="text-gray-500 text-center">
Monthly trend chart will appear here.
</p>
<!-- Monthly trend bar chart -->
<div class="h-80">
<BarChart
:chartData="monthlyTrendChartData"
/>
</div>
<!-- Monthly data table -->
+90 -2
View File
@@ -19,8 +19,14 @@
Moon,
Sun,
Smartphone,
AlertCircle,
} from "lucide-vue-next";
import { useAuthStore } from "@/stores/auth";
import {
requestNotificationPermission,
registerServiceWorker,
subscribeToPushNotifications
} from "@/lib/pwa-utils";
// Initialize store
const authStore = useAuthStore();
@@ -40,6 +46,9 @@
const isOnline = ref(navigator.onLine);
const serviceWorkerRegistered = ref(false);
const pushPermission = ref("default");
const pushSubscribed = ref(false);
const isSubscribing = ref(false);
const subscriptionError = ref("");
// Check if PWA is installed
onMounted(async () => {
@@ -67,6 +76,13 @@
try {
const registrations = await navigator.serviceWorker.getRegistrations();
serviceWorkerRegistered.value = registrations.length > 0;
// Check if already subscribed to push
if (serviceWorkerRegistered.value) {
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.getSubscription();
pushSubscribed.value = !!subscription;
}
} catch (error) {
console.error("Error checking service worker:", error);
}
@@ -81,6 +97,9 @@
if (permission === "granted") {
notificationsEnabled.value = true;
pushPermission.value = "granted";
// Subscribe to push notifications if permission granted
subscribeToPushService();
} else {
notificationsEnabled.value = false;
pushPermission.value = permission;
@@ -88,6 +107,10 @@
}
} else {
notificationsEnabled.value = !notificationsEnabled.value;
if (notificationsEnabled.value && !pushSubscribed.value) {
subscribeToPushService();
}
}
localStorage.setItem(
@@ -97,6 +120,49 @@
}
};
// Subscribe to push notification service
const subscribeToPushService = async () => {
if (!authStore.isAuthenticated) {
subscriptionError.value = "You must be signed in to receive notifications";
return;
}
isSubscribing.value = true;
subscriptionError.value = "";
try {
// Ensure service worker is registered
if (!serviceWorkerRegistered.value) {
await registerServiceWorker();
serviceWorkerRegistered.value = true;
}
// Request permission if not already granted
if (Notification.permission !== "granted") {
const permission = await requestNotificationPermission();
if (permission !== "granted") {
pushPermission.value = permission;
throw new Error("Notification permission denied");
}
}
// Subscribe to push notifications
const subscription = await subscribeToPushNotifications();
if (subscription) {
pushSubscribed.value = true;
notificationsEnabled.value = true;
localStorage.setItem("notificationsEnabled", "true");
} else {
throw new Error("Failed to subscribe to push notifications");
}
} catch (error) {
console.error("Error subscribing to push notifications:", error);
subscriptionError.value = (error as Error).message || "Failed to subscribe to push notifications";
} finally {
isSubscribing.value = false;
}
};
// Toggle dark mode
const toggleDarkMode = () => {
darkMode.value = !darkMode.value;
@@ -172,7 +238,7 @@
<div class="space-y-0.5">
<Label>Push Notifications</Label>
<div class="text-sm text-gray-500 dark:text-gray-400">
Enable budget alerts and reminders
Enable transaction alerts and reminders
</div>
<div
v-if="pushPermission === 'denied'"
@@ -180,13 +246,25 @@
Permission denied. Please enable notifications in your browser
settings.
</div>
<div
v-if="subscriptionError"
class="text-xs text-red-500 mt-1 flex items-center">
<AlertCircle class="h-3 w-3 mr-1" />
{{ subscriptionError }}
</div>
<div
v-if="pushSubscribed"
class="text-xs text-green-600 mt-1">
Subscribed to transaction notifications
</div>
</div>
<Button
@click="toggleNotifications"
variant="outline"
size="sm"
class="ml-auto"
:disabled="pushPermission === 'denied'">
:disabled="pushPermission === 'denied' || isSubscribing"
:loading="isSubscribing">
<Bell class="h-4 w-4 mr-2" />
{{ notificationsEnabled ? "Disable" : "Enable" }}
</Button>
@@ -261,6 +339,16 @@
</span>
</div>
<div class="flex items-center justify-between">
<p class="font-medium">Push Notifications</p>
<span
:class="
pushSubscribed ? 'text-green-600' : 'text-gray-600'
">
{{ pushSubscribed ? "Subscribed" : "Not Subscribed" }}
</span>
</div>
<div v-if="!isPWAInstalled" class="mt-4">
<p class="text-sm text-gray-600 mb-2">
Install this app on your device for better performance and
+66 -2
View File
@@ -14,7 +14,72 @@ export default defineConfig({
vue(),
vueDevTools(),
tailwindcss(),
VitePWA({ registerType: "autoUpdate", devOptions: { enabled: true } }),
VitePWA({
registerType: "autoUpdate",
devOptions: { enabled: true },
manifest: {
name: "Budget Manager",
short_name: "BudgetMgr",
description: "Track and manage your finances",
theme_color: "#ffffff",
icons: [
{
src: "/icons/icon-192px.jpg",
sizes: "192x192",
type: "image/png"
},
{
src: "/icons/icon-512px.jpg",
sizes: "512x512",
type: "image/png"
}
]
},
workbox: {
globPatterns: ["**/*.{js,css,html,ico,png,svg,jpg}"],
runtimeCaching: [
{
urlPattern: /^https:\/\/fonts\.googleapis\.com\/.*/i,
handler: "CacheFirst",
options: {
cacheName: "google-fonts-cache",
expiration: {
maxEntries: 10,
maxAgeSeconds: 60 * 60 * 24 * 365 // <== 365 days
},
cacheableResponse: {
statuses: [0, 200]
}
}
},
{
urlPattern: /^https:\/\/fonts\.gstatic\.com\/.*/i,
handler: "CacheFirst",
options: {
cacheName: "gstatic-fonts-cache",
expiration: {
maxEntries: 10,
maxAgeSeconds: 60 * 60 * 24 * 365 // <== 365 days
},
cacheableResponse: {
statuses: [0, 200]
},
}
},
{
urlPattern: /\/api\/(?!.*auth).*/i, // All API routes except auth
handler: "NetworkFirst",
options: {
cacheName: "api-cache",
expiration: {
maxEntries: 50,
maxAgeSeconds: 60 * 60 // 1 hour
}
}
}
]
}
}),
],
resolve: {
alias: {
@@ -26,7 +91,6 @@ export default defineConfig({
"/api": {
target: "http://localhost:3000", // Replace with your API server URL
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, ""),
},
},
port: 3001, // Replace with your desired port
+18
View File
@@ -0,0 +1,18 @@
#!/bin/bash
if [ -z "$1" ]; then
echo -e "\e[32;7;1mREAD THE CODE:\e[0m"
echo
if command -v bat &>/dev/null; then
bat -Pp "$0"
else
cat "$0"
fi
echo
echo -e "\e[32;7;1mEND OF CODE\e[0m"
exit 1
fi
IN="$1" # input file, if empty prints scripts itself
OUT="${2:-$(basename "$IN" .md).pdf}" # if second argument is empty, use first argument without extension
REST="${*:3}" # pass rest of arguments to pandoc
COMMAND="pandoc \"$IN\" -o \"$OUT\" --pdf-engine=lualatex --pdf-engine-opt=\"--shell-escape\" $REST"
eval "$COMMAND"
+939
View File
@@ -0,0 +1,939 @@
---
title: "PWA z JavaScript"
date: "2025-05-23"
author: "Igor Barcik 131780"
subtitle: "Projekt"
papersize: a4
geometry: margin=2cm
header-includes:
- \usepackage{float}
- \usepackage{fontspec}
- \usepackage{unicode-math}
- \usepackage{xcolor}
- \usepackage{minted}
- \usepackage{mdframed}
- \floatplacement{figure}{H}
- \setmonofont{FiraCode Nerd Font Mono} # Set the font for code blocks
- \definecolor{light-gray}{gray}{0.95} # Define a custom color
- \BeforeBeginEnvironment{Highlighting}{\begin{mdframed}[backgroundcolor=light-gray,linewidth=0pt]} # Wrap Highlighting environments in a mdframed environment 1/2
- \AfterEndEnvironment{Highlighting}{\end{mdframed}} # Wrap Highlighting environments in a mdframed environment 2/2
- \DefineVerbatimEnvironment{Highlighting}{Verbatim}{breaklines, commandchars=\\\{\}, breakanywhere=true, fontsize=\small, breakafter=d, breakaftersymbolpre=, breakaftersymbolpost=} # Define the Verbatim environment for code blocks
---
\newpage
# Budget Manager PWA - Raport projektu
## 1. Wprowadzenie
Aplikacja "Budget Manager PWA" *([link do git](https://gitea.bigoscloud.com/biggy/budget-manager))* to progresywna aplikacja internetowa (PWA) służąca do zarządzania budżetem osobistym. Umożliwia śledzenie wydatków i przychodów, kategoryzowanie transakcji, generowanie raportów oraz ustawianie powiadomień związanych z budżetem. Aplikacja działa zarówno online, jak i offline, dzięki wykorzystaniu nowoczesnych technologii webowych.
### 1.1 Cel projektu
Celem projektu było stworzenie aplikacji PWA, która:
- Umożliwia śledzenie wydatków i przychodów użytkownika
- Działa zarówno online, jak i offline
- Przechowuje dane w Local Storage oraz MongoDB
- Oferuje funkcje budżetowania, raportowania i analizy finansowej
### 1.2 Wykorzystane technologie
Frontend:
- Vue 3 z Composition API
- TypeScript
- Tailwind CSS
- Vite (bundler)
- Pinia (zarządzanie stanem)
- Vue Router
Backend:
- Koa.js
- TypeScript
- MongoDB
PWA:
- Service Worker
- Web App Manifest
- IndexedDB (poprzez abstrakcję)
## 2. Architektura aplikacji
### 2.1 Struktura projektu
Projekt jest podzielony na dwie główne części:
#### Frontend
```bash
frontend/
├── public/ # Statyczne pliki i manifest PWA
│ ├── manifest.json # Konfiguracja PWA
│ ├── sw.js # Service Worker
│ └── icons/ # Ikony dla PWA
├── src/
│ ├── components/ # Komponenty Vue
│ ├── pages/ # Widoki aplikacji
│ ├── stores/ # Pinia stores
│ ├── lib/ # Funkcje pomocnicze
│ │ └── pwa-utils.ts # Narzędzia związane z PWA
│ └── main.ts # Główny plik aplikacji
└── styles/ # Style CSS
```
#### Backend
```bash
backend/
├── src/
│ ├── app.ts # Główny plik aplikacji
│ ├── controllers/ # Kontrolery REST API
│ ├── middleware/ # Middleware (np. autoryzacja)
│ └── services/ # Serwisy (np. baza danych)
└── scripts/
└── generate-vapid-keys.js # Skrypt do generowania kluczy VAPID
```
### 2.2 Przepływ danych
1. Dane użytkownika są przechowywane lokalnie w Local Storage dla dostępu offline
2. Przy połączeniu z internetem dane są synchronizowane z bazą MongoDB na serwerze
3. Service Worker zapisuje dane do cache, umożliwiając działanie aplikacji offline
4. Background Sync API jest używane do synchronizacji danych, gdy połączenie internetowe zostanie przywrócone
## 3. Implementacja PWA
### 3.1 Manifest aplikacji
Manifest definiuje jak aplikacja ma się zachowywać po zainstalowaniu na urządzeniu użytkownika:
```json
{
"name": "Budget Manager PWA",
"short_name": "Budget",
"description": "Budget Manager application for tracking expenses and income",
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#3b82f6",
"icons": [
{
"src": "/icons/icon-192px.jpg",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/icons/icon-512px.jpg",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
],
"shortcuts": [
{
"name": "Add Transaction",
"short_name": "Add",
"description": "Add a new transaction",
"url": "/add",
"icons": [{ "src": "/icons/add-icon-96x96.png", "sizes": "96x96" }]
},
{
"name": "View Reports",
"short_name": "Reports",
"description": "View budget reports",
"url": "/reports",
"icons": [{ "src": "/icons/report-icon-96x96.png", "sizes": "96x96" }]
}
]
}
```
### 3.2 Service Worker
Service Worker obsługuje:
- Cache'owanie zasobów dla dostępu offline
- Synchronizację w tle (background sync)
Kluczowe fragmenty implementacji:
```javascript
// Instalacja Service Worker'a
self.addEventListener("install", (event) => {
console.log("Service worker install event");
event.waitUntil(
caches
.open(CACHE_NAME)
.then((cache) => {
return cache.addAll(precacheResources);
})
.then(() => {
return self.skipWaiting();
}),
);
});
// Strategia fetch: cache-first, następnie sieć
self.addEventListener("fetch", (event) => {
// Pomijanie żądań cross-origin i API
if (
!event.request.url.startsWith(self.location.origin) ||
event.request.url.includes("/api/")
) {
return;
}
event.respondWith(
caches.match(event.request).then((cachedResponse) => {
if (cachedResponse) {
return cachedResponse;
}
return fetch(event.request)
.then((response) => {
// Cache'owanie odpowiedzi
if (
response &&
response.status === 200 &&
response.type === "basic"
) {
const responseToCache = response.clone();
caches.open(CACHE_NAME).then((cache) => {
cache.put(event.request, responseToCache);
});
}
return response;
})
.catch(() => {
// Fallback w przypadku braku sieci
if (event.request.mode === "navigate") {
return caches.match("/");
}
return null;
});
}),
);
});
// Obsługa background sync
self.addEventListener("sync", (event) => {
if (event.tag === "sync-transactions") {
event.waitUntil(syncTransactions());
} else if (event.tag === "sync-categories") {
event.waitUntil(syncCategories());
}
});
```
### 3.3 Rejestracja Service Worker
Rejestracja Service Worker'a w aplikacji:
```typescript
export async function registerServiceWorker() {
if ("serviceWorker" in navigator) {
try {
const registration = await navigator.serviceWorker.register("/sw.js", {
scope: "/",
});
if (registration.installing) {
console.log("Service worker installing");
} else if (registration.waiting) {
console.log("Service worker installed");
} else if (registration.active) {
console.log("Service worker active");
}
// Upewniamy się, że service worker jest gotowy przed próbą subskrypcji
await navigator.serviceWorker.ready;
return registration;
} catch (error) {
console.error("Registration failed with error:", error);
return null;
}
}
return null;
}
```
### 3.4 Powiadomienia Push
Powiadomienia Push są kluczowym elementem aplikacji PWA, umożliwiającym informowanie użytkownika o ważnych zdarzeniach, nawet gdy aplikacja nie jest aktywna w przeglądarce. W Budget Manager PWA, powiadomienia push są wykorzystywane do informowania użytkowników o nowych transakcjach dodanych do ich budżetu.
#### 3.4.1 Klucze VAPID
Do bezpiecznego wysyłania powiadomień push wykorzystywany jest protokół VAPID (Voluntary Application Server Identification). Klucze VAPID (publiczny i prywatny) są generowane za pomocą skryptu i przechowywane jako zmienne środowiskowe na serwerze.
Skrypt `backend/scripts/generate-vapid-keys.js` generuje te klucze:
```bash
# backend/scripts/generate-vapid-keys.js
const webpush = require('web-push');
const fs = require('fs');
const vapidKeys = webpush.generateVAPIDKeys();
console.log('Public Key: ', vapidKeys.publicKey);
console.log('Private Key: ', vapidKeys.privateKey);
// Zapisz klucze do pliku .env lub użyj bezpośrednio
fs.appendFileSync('.env.example', `\nVAPID_PUBLIC_KEY=${vapidKeys.publicKey}`);
fs.appendFileSync('.env.example', `VAPID_PRIVATE_KEY=${vapidKeys.privateKey}\n`);
console.log('VAPID keys appended to .env.example');
```
Klucz publiczny VAPID jest udostępniany frontendowi, aby mógł zasubskrybować usługę push. Klucze są ładowane na serwerze przy starcie aplikacji.
#### 3.4.2 Implementacja Frontend
**Subskrypcja powiadomień:**
Logika subskrypcji znajduje się w `frontend/src/lib/pwa-utils.ts`. Użytkownik może włączyć lub wyłączyć powiadomienia na stronie ustawień (`frontend/src/pages/Settings.vue`). Proces obejmuje:
1. Sprawdzenie wsparcia dla Service Worker i Push API.
2. Poproszenie użytkownika o zgodę na otrzymywanie powiadomień.
3. Pobranie klucza publicznego VAPID z backendu (`/api/push/vapid-public-key`).
4. Subskrypcja usługi push za pomocą `registration.pushManager.subscribe()`.
5. Wysłanie obiektu subskrypcji na backend (`/api/push/subscribe`) w celu zapisania.
Fragment kodu z `pwa-utils.ts` odpowiedzialny za subskrypcję:
```typescript
// frontend/src/lib/pwa-utils.ts
// ... (uproszczony przykład)
async function subscribeToPushNotifications(registration: ServiceWorkerRegistration) {
try {
const vapidPublicKeyResponse = await fetch('/api/push/vapid-public-key', { headers: { 'Accept': 'text/plain' } });
if (!vapidPublicKeyResponse.ok) {
throw new Error(`Failed to get VAPID public key: ${vapidPublicKeyResponse.statusText}`);
}
const vapidPublicKey = await vapidPublicKeyResponse.text();
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(vapidPublicKey),
});
// Odczyt tokena JWT z localStorage
const token = localStorage.getItem('authToken'); // Upewnij się, że klucz jest poprawny
if (!token) {
console.warn('No auth token found, cannot save subscription to backend.');
// Można rozważyć zapisanie subskrypcji lokalnie i wysłanie później
return false;
}
await fetch('/api/push/subscribe', {
method: 'POST',
body: JSON.stringify(subscription),
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
});
console.log('User is subscribed.');
return true;
} catch (error) {
console.error('Failed to subscribe the user: ', error);
return false;
}
}
function urlBase64ToUint8Array(base64String: string): ArrayBuffer {
const padding = '='.repeat((4 - base64String.length % 4) % 4);
const base64 = (base64String + padding)
.replace(/-/g, '+')
.replace(/_/g, '/');
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray.buffer; // Zwraca ArrayBuffer
}
```
**Obsługa powiadomień w Service Worker (`frontend/public/sw.js`):**
Service Worker nasłuchuje na zdarzenie `push` i `notificationclick`.
- Po otrzymaniu zdarzenia `push`, Service Worker wyświetla powiadomienie systemowe z danymi otrzymanymi od serwera (np. tytuł, treść, ikonę). Wiadomość jest również przekazywana do aktywnych klientów aplikacji w celu wyświetlenia toastu.
- Zdarzenie `notificationclick` obsługuje interakcję użytkownika z powiadomieniem, np. otwarcie aplikacji lub konkretnej strony.
```javascript
// frontend/public/sw.js
self.addEventListener('push', event => {
let data = { title: 'Nowa wiadomość', body: 'Otrzymałeś nowe powiadomienie.', data: { url: '/' } };
if (event.data) {
try {
data = event.data.json();
} catch (e) {
console.error('Push event data is not valid JSON:', event.data.text());
data.body = event.data.text(); // Użyj tekstu jako body jeśli JSON się nie sparsuje
}
}
const title = data.title || 'Budget Manager';
const options = {
body: data.body || 'Nowa aktywność na Twoim koncie.',
icon: data.icon || '/icons/icon-192px.jpg',
badge: '/icons/icon-192px.jpg', // Użyj tej samej ikony dla badge
data: data.data || { url: '/' }
};
event.waitUntil(self.registration.showNotification(title, options));
// Wysyłanie wiadomości do aktywnych klientów (zakładek aplikacji)
self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then(clients => {
if (clients && clients.length) {
clients.forEach(client => {
client.postMessage({
type: 'PUSH_NOTIFICATION_RECEIVED',
payload: data // Przekaż całe dane, w tym title, body, data.url
});
});
}
});
});
self.addEventListener('notificationclick', event => {
event.notification.close();
const urlToOpen = event.notification.data && event.notification.data.url
? event.notification.data.url
: '/';
event.waitUntil(
clients.matchAll({ type: 'window', includeUncontrolled: true }).then(windowClients => {
for (let i = 0; i < windowClients.length; i++) {
const client = windowClients[i];
// Sprawdź, czy URL klienta (po usunięciu hasha) pasuje do urlToOpen
const clientURL = new URL(client.url);
const targetURL = new URL(urlToOpen, self.location.origin); // Upewnij się, że targetURL jest absolutny
if (clientURL.origin + clientURL.pathname === targetURL.origin + targetURL.pathname && 'focus' in client) {
return client.focus();
}
}
if (clients.openWindow) {
return clients.openWindow(urlToOpen);
}
})
);
});
```
#### 3.4.3 Implementacja Backend
Backend (`backend/src/controllers/push.ts`) odpowiada za:
1. Udostępnianie publicznego klucza VAPID (`/api/push/vapid-public-key`). Ten endpoint jest publicznie dostępny.
2. Rejestrowanie subskrypcji użytkowników (`/api/push/subscribe`). Subskrypcje są przechowywane w bazie danych, powiązane z użytkownikiem.
3. Wysłanie powiadomień do zasubskrybowanych użytkowników. Na przykład, po dodaniu nowej transakcji (`backend/src/controllers/protected.ts`), serwer wysyła powiadomienie.
Fragment kodu z `pushController.ts` (uproszczony):
```typescript
// backend/src/controllers/push.ts
import webpush from 'web-push';
import { Context } from 'koa';
import User from '../models'; // Załóżmy, że User model ma pole pushSubscriptions
// Konfiguracja VAPID keys przy starcie aplikacji (np. w app.ts lub dedykowanym pliku konfiguracyjnym)
// webpush.setVapidDetails(
// 'mailto:your-email@example.com',
// process.env.VAPID_PUBLIC_KEY!,
// process.env.VAPID_PRIVATE_KEY!
// );
export const getVapidPublicKey = async (ctx: Context) => {
if (!process.env.VAPID_PUBLIC_KEY) {
ctx.throw(500, 'VAPID public key not configured');
return;
}
ctx.body = process.env.VAPID_PUBLIC_KEY;
ctx.set('Content-Type', 'text/plain');
};
export const subscribe = async (ctx: Context) => {
const subscription = ctx.request.body as webpush.PushSubscription;
const userId = ctx.state.user.id; // ID zalogowanego użytkownika
try {
// Zapisz subskrypcję w dokumencie użytkownika
// Załóżmy, że model User ma pole pushSubscriptions: webpush.PushSubscription[]
await User.findByIdAndUpdate(userId, { $addToSet: { pushSubscriptions: subscription } });
ctx.status = 201;
ctx.body = { message: 'Subscribed successfully' };
} catch (error) {
console.error('Error saving subscription:', error);
ctx.throw(500, 'Failed to save subscription');
}
};
// Funkcja do wysyłania powiadomień (może być w serwisie)
export const sendPushNotification = async (userId: string, payload: object) => {
try {
const user = await User.findById(userId);
if (!user || !user.pushSubscriptions || user.pushSubscriptions.length === 0) {
console.log(`No push subscriptions found for user ${userId}`);
return;
}
const notificationPayload = JSON.stringify(payload);
for (const sub of user.pushSubscriptions) {
try {
await webpush.sendNotification(sub, notificationPayload);
} catch (error: any) {
console.error('Error sending notification to a subscription: ', error.body);
// Jeśli subskrypcja jest nieaktualna (np. status 410 Gone), usuń ją
if (error.statusCode === 410 || error.statusCode === 404) {
await User.findByIdAndUpdate(userId, { $pull: { pushSubscriptions: sub } });
console.log('Removed outdated subscription.');
}
}
}
} catch (error) {
console.error(`Failed to send push notifications for user ${userId}:`, error);
}
};
```
Integracja wysyłania powiadomień przy tworzeniu transakcji w `protectedController.ts`:
```typescript
// backend/src/controllers/protected.ts
// ...
import { sendPushNotification } from './push'; // Zaimportuj funkcję
export const addTransaction = async (ctx: Context) => {
// ... logika dodawania transakcji ...
// const newTransaction = ...; // Wynik dodania transakcji
// const userId = ctx.state.user.id;
// Po pomyślnym dodaniu transakcji:
if (newTransaction && userId) { // Upewnij się, że newTransaction i userId istnieją
const notificationPayload = {
title: 'Nowa transakcja',
body: `Dodano: ${newTransaction.description} (${newTransaction.amount} ${newTransaction.currency || 'PLN'})`,
data: { url: `/transactions` } // Ogólny link do transakcji lub ID: /transactions/${newTransaction._id}
};
// Wyślij asynchronicznie, aby nie blokować odpowiedzi
sendPushNotification(userId, notificationPayload).catch(console.error);
}
// ...
};
```
Powiadomienia push znacząco zwiększają zaangażowanie użytkowników i użyteczność aplikacji Budget Manager PWA, informując ich na bieżąco o zmianach w ich budżecie.
## 4. Zarządzanie stanem aplikacji
### 4.1 Stores w Pinia
Aplikacja używa Pinia do zarządzania stanem, co ułatwia komunikację między komponentami i zarządzanie danymi aplikacji.
Przykładowy store dla transakcji:
```typescript
export const useTransactionsStore = defineStore("transactions", () => {
// Stan
const transactions = ref<Transaction[]>([]);
const loading = ref(false);
const error = ref("");
// API URL
const apiBaseUrl = "http://localhost:3000/api";
// Store autoryzacji
const authStore = useAuthStore();
// Gettery
const incomes = computed(() =>
transactions.value.filter((t) => t.type === "income"),
);
const expenses = computed(() =>
transactions.value.filter((t) => t.type === "expense"),
);
const totalIncome = computed(() =>
incomes.value.reduce((sum, t) => sum + t.amount, 0),
);
const totalExpenses = computed(() =>
expenses.value.reduce((sum, t) => sum + t.amount, 0),
);
const balance = computed(() => totalIncome.value - totalExpenses.value);
// Akcje
async function fetchTransactions() {
if (!authStore.token) return;
loading.value = true;
error.value = "";
try {
const response = await fetch(`${apiBaseUrl}/transactions`, {
headers: {
Authorization: `Bearer ${authStore.token}`,
},
});
if (!response.ok) {
throw new Error("Failed to fetch transactions");
}
const data = await response.json();
transactions.value = data.data;
// Zapisywanie do localStorage dla dostępu offline
saveToLocalStorage();
} catch (err: any) {
error.value = err.message;
// Jeśli API call nie powiedzie się, próba załadowania z localStorage
loadFromLocalStorage();
} finally {
loading.value = false;
}
}
// ... inne metody
return {
transactions,
loading,
error,
incomes,
expenses,
totalIncome,
totalExpenses,
balance,
fetchTransactions,
// ... inne eksportowane metody
};
});
```
### 4.2 Synchronizacja offline/online
Aplikacja wykorzystuje strategię "offline-first", gdzie dane są najpierw zapisywane lokalnie, a następnie synchronizowane z serwerem gdy połączenie jest dostępne:
```typescript
// Dodawanie transakcji
async function addTransaction(transaction: Omit<Transaction, "_id">) {
try {
error.value = "";
// Generujemy tymczasowe ID dla transakcji
const tempId = `temp_${Date.now()}`;
const newTransaction = {
...transaction,
_id: tempId,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
synced: false
};
// Dodajemy do listy lokalnie
transactions.value.push(newTransaction);
saveToLocalStorage();
// Próbujemy zapisać na serwerze
if (navigator.onLine && authStore.token) {
const response = await fetch(`${apiBaseUrl}/transactions`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${authStore.token}`,
},
body: JSON.stringify(transaction),
});
if (response.ok) {
// Aktualizujemy transakcję z danymi z serwera
const data = await response.json();
const index = transactions.value.findIndex(t => t._id === tempId);
if (index !== -1) {
transactions.value[index] = { ...data.data, synced: true };
saveToLocalStorage();
}
} else {
// Oznaczamy transakcję do późniejszej synchronizacji
const syncStore = useSyncStore();
syncStore.addPendingTransaction(newTransaction);
throw new Error("Failed to save transaction to server");
}
} else {
// Jesteśmy offline, dodajemy do listy oczekujących na sync
const syncStore = useSyncStore();
syncStore.addPendingTransaction(newTransaction);
}
} catch (err: any) {
error.value = err.message;
throw err;
}
}
```
## 5. Interfejs użytkownika
### 5.1 Komponenty
Aplikacja wykorzystuje komponenty Vue do zbudowania interfejsu użytkownika. Przykładowy komponent Dashboard:
![Komponent Dashboard](dashboard-preview.png)
```vue
<template>
<PageLayout header="Dashboard">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<!-- Podsumowanie salda -->
<div class="lg:col-span-2">
<BalanceSummary />
</div>
<!-- Szybkie akcje -->
<Card>
<CardHeader>
<CardTitle>Quick Actions</CardTitle>
</CardHeader>
<CardContent class="space-y-4">
<router-link to="/add" class="w-full block">
<Button class="w-full" variant="default">Add New Transaction</Button>
</router-link>
<router-link to="/reports" class="w-full block">
<Button class="w-full" variant="outline">View Reports</Button>
</router-link>
<!-- Inne przyciski akcji -->
</CardContent>
</Card>
<!-- Ostatnie transakcje -->
<Card class="lg:col-span-3">
<CardHeader>
<CardTitle>Recent Transactions</CardTitle>
</CardHeader>
<CardContent>
<!-- Zawartość sekcji transakcji -->
</CardContent>
</Card>
</div>
</PageLayout>
</template>
```
### 5.2 Responsywność
Aplikacja wykorzystuje Tailwind CSS do tworzenia responsywnego interfejsu, który działa dobrze na urządzeniach mobilnych i desktopowych:
![Dashboard mobilny](responsive-dashboard-image.png)
- Używanie systemu grid z breakpointami (md:, lg:)
- Elastyczne komponenty dopasowujące się do różnych rozmiarów ekranów
- Mobilne menu nawigacyjne dla małych ekranów
### 5.3 Dostępność offline
Interfejs uwzględnia stan połączenia internetowego:
![Tryb offline](no-internet-image.png)
- Wyświetlanie informacji o trybie offline
- Możliwość dodawania transakcji offline
- Automatyczna synchronizacja po ponownym połączeniu
## 6. Backend API
### 6.1 Struktura API
Backend implementuje RESTful API do komunikacji z frontendem:
- `POST /api/auth/login` - logowanie użytkownika
- `POST /api/auth/register` - rejestracja nowego użytkownika
- `GET /api/transactions` - pobieranie listy transakcji
- `POST /api/transactions` - dodawanie nowej transakcji
- `PUT /api/transactions/:id` - aktualizacja transakcji
- `DELETE /api/transactions/:id` - usunięcie transakcji
- `GET /api/categories` - pobieranie kategorii
- `PUT /api/categories` - dodawanie nowej kategorii
### 6.2 Bezpieczeństwo API
API zabezpieczone jest przez:
- Tokeny JWT do autoryzacji
- Middleware uwierzytelniające
- Zabezpieczenie przed CSRF
- CORS
```typescript
// Middleware autoryzacji
export const authenticate = async (ctx: Context, next: Next) => {
// Pomijamy middleware dla ścieżek publicznych
if (ctx.path.startsWith('/api/auth/login') ||
ctx.path.startsWith('/api/auth/register')) {
return next();
}
const token = ctx.headers.authorization?.split(' ')[1];
if (!token) {
ctx.status = 401;
ctx.body = {
success: false,
message: 'No token provided'
};
return;
}
try {
const decoded = jwt.verify(token, jwtConfig.secret);
ctx.state.user = decoded;
return next();
} catch (err) {
ctx.throw(401, 'Invalid or expired token');
}
};
```
## 7. Baza danych
### 7.1 Model danych
Aplikacja wykorzystuje MongoDB do przechowywania danych, z następującymi kolekcjami:
1. `users` - informacje o użytkownikach
2. `transactions` - transakcje finansowe
3. `categories` - kategorie transakcji
### 7.2 Usługa bazodanowa
Abstrakcja dostępu do bazy danych zapewniona jest przez klasę DatabaseService:
```typescript
export default class DatabaseService {
private static instance: DatabaseService;
private client: MongoClient | null = null;
private db: Db | null = null;
private constructor() {}
// Wzorzec Singleton
public static getInstance(): DatabaseService {
if (!DatabaseService.instance) {
DatabaseService.instance = new DatabaseService();
}
return DatabaseService.instance;
}
// Połączenie z bazą danych
public async connect() {
try {
this.client = await MongoClient.connect(dbConfig.url);
this.db = this.client.db(dbConfig.name);
console.log('Connected to MongoDB');
} catch (error) {
console.error('Error connecting to MongoDB:', error);
throw error;
}
}
// Pobranie kolekcji
public getCollection(collectionName: string): Collection {
if (!this.db) {
throw new Error('Database not connected');
}
return this.db.collection(collectionName);
}
// Zamknięcie połączenia
public async disconnect() {
if (this.client) {
await this.client.close();
this.client = null;
this.db = null;
console.log('Disconnected from MongoDB');
}
}
}
```
## 8. Testy i użycie
### 8.1 Testowanie offline
Aplikacja została przetestowana w różnych scenariuszach:
- Dodawanie transakcji w trybie offline
- Automatyczna synchronizacja po przywróceniu połączenia
- Działanie Service Worker'a i cache'owanie zasobów
### 8.2 Instalacja jako PWA
Aplikacja może być zainstalowana na urządzeniach:
- Poprawnie wyświetla prompt instalacyjny
- Działa jako samodzielna aplikacja po zainstalowaniu
- Wykorzystuje ikony i kolory zdefiniowane w manifeście
## 9. Wnioski i perspektywy rozwoju
### 9.1 Osiągnięte cele
Aplikacja spełnia wszystkie założenia projektowe:
- Funkcjonuje jako pełnoprawna PWA
- Umożliwia zarządzanie budżetem osobistym
- Działa offline z synchronizacją
- Ma responsywny i przyjazny interfejs
### 9.2 Możliwe rozszerzenia
Potencjalne kierunki rozwoju aplikacji:
- Dodanie eksportu danych do CSV/PDF
- Implementacja celów oszczędnościowych
- Rozbudowa funkcji raportowania o wykresy
- Dodanie powiadomień o zbliżających się regularnych płatnościach
- Integracja z bankami poprzez API
## 10. Zrzuty ekranu
### 10.1 Ekran logowania
![Ekran logowania](login_screen.png)
### 10.2 Ekran transakcji
![Ekran transakcji](transaction_screen.png)
### 10.3 Ekran kategorii
![Ekran kategorii](category_screen.png)
### 10.4 Ekran raportów
![Ekran raportów](report_screenshot_1.png)
### 10.5 Ekran ustawień
![Ekran ustawień](settings_screen.png)
## 11. Bibliografia
1. Vue.js Documentation: [https://vuejs.org/guide/introduction.html](https://vuejs.org/guide/introduction.html)
2. Progressive Web Apps: [https://web.dev/progressive-web-apps/](https://web.dev/progressive-web-apps/)
3. MDN Web Docs - Service Worker API: [https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API](https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API)
4. Tailwind CSS Documentation: [https://tailwindcss.com/docs](https://tailwindcss.com/docs)
5. Koa.js Documentation: [https://koajs.com/](https://koajs.com/)
6. MongoDB Documentation: [https://docs.mongodb.com/](https://docs.mongodb.com/)