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
+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"
},
+105 -9
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();
// 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('/');
})
);
}
});
event.waitUntil(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>
+9 -10
View File
@@ -48,18 +48,17 @@
-->
<div class="py-2">
<ul class="space-y-1">
<li>
<router-link
to="/"
class="flex items-center px-4 py-2 text-sm font-medium rounded-md transition-colors"
active-class="bg-secondary">
<Home class="mr-3 h-5 w-5" />
Dashboard
</router-link>
</li>
<!-- Auth-required links -->
<template v-if="authStore.isAuthenticated">
<li>
<router-link
to="/"
class="flex items-center px-4 py-2 text-sm font-medium rounded-md transition-colors"
active-class="bg-secondary">
<Home class="mr-3 h-5 w-5" />
Dashboard
</router-link>
</li>
<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,13 +4,29 @@ 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
history: createWebHistory(),
@@ -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();
@@ -165,6 +168,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) => {
@@ -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(
@@ -96,6 +119,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 = () => {
@@ -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