Files
budget-manager/frontend/public/sw.js
T
biggy 8681aa88af 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.
2025-05-23 23:54:46 +02:00

312 lines
8.3 KiB
JavaScript

// Service Worker for Budget Manager PWA
const CACHE_NAME = "budget-manager-v1";
// Assets to precache
const precacheResources = [
"/",
"/index.html",
"/src/main.ts",
"/src/App.vue",
"/styles/tailwind.css",
"/styles/transitions.css",
"/styles/style.css",
"/icons/icon-192px.jpg",
"/icons/icon-512px.jpg",
];
// Install event - precache assets
self.addEventListener("install", (event) => {
console.log("Service worker install event");
event.waitUntil(
caches
.open(CACHE_NAME)
.then((cache) => {
return cache.addAll(precacheResources);
})
.then(() => {
// Skip waiting to activate immediately
return self.skipWaiting();
}),
);
});
// Activate event - clean up old caches
self.addEventListener("activate", (event) => {
console.log("Service worker activate event");
event.waitUntil(
caches
.keys()
.then((cacheNames) => {
return Promise.all(
cacheNames.map((cacheName) => {
if (cacheName !== CACHE_NAME) {
console.log("Deleting old cache:", cacheName);
return caches.delete(cacheName);
}
}),
);
})
.then(() => {
// Take control of all clients
return self.clients.claim();
}),
);
});
// Fetch event - serve from cache first, then network
self.addEventListener("fetch", (event) => {
// Skip cross-origin requests
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 successful responses
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(() => {
// If fetch fails (offline), return a fallback
if (event.request.mode === "navigate") {
return caches.match("/");
}
return null;
});
}),
);
});
// Background sync for offline transactions
self.addEventListener("sync", (event) => {
if (event.tag === "sync-transactions") {
event.waitUntil(syncTransactions());
} else if (event.tag === "sync-categories") {
event.waitUntil(syncCategories());
}
});
// Push notification handler
self.addEventListener("push", (event) => {
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: messageText,
icon: "/icons/icon-192px.jpg",
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'
};
// 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('/');
})
);
}
});
// 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
async function syncTransactions() {
try {
const pendingTransactions = await getPendingItems("pendingTransactions");
const token = await getAuthToken();
if (!token || pendingTransactions.length === 0) return;
for (const transaction of pendingTransactions) {
const response = await fetch("http://localhost:3000/api/transactions", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(transaction),
});
if (response.ok) {
await removePendingItem("pendingTransactions", transaction.id);
}
}
} catch (error) {
console.error("Error syncing transactions:", error);
}
}
// Sync categories with the server
async function syncCategories() {
try {
const pendingCategories = await getPendingItems("pendingCategories");
const token = await getAuthToken();
if (!token || pendingCategories.length === 0) return;
for (const category of pendingCategories) {
const response = await fetch("http://localhost:3000/api/categories", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(category),
});
if (response.ok) {
await removePendingItem("pendingCategories", category.id);
}
}
} catch (error) {
console.error("Error syncing categories:", error);
}
}
// Helper function to get pending items from IndexedDB
async function getPendingItems(storeName) {
return []; // Placeholder - in a real app, this would access IndexedDB
}
// Helper function to remove a pending item from IndexedDB
async function removePendingItem(storeName, id) {
// Placeholder - in a real app, this would access IndexedDB
}
// Helper function to get auth token from client
async function getAuthToken() {
const clients = await self.clients.matchAll();
if (clients.length === 0) {
return localStorage.getItem("token");
}
// Ask client for token
const client = clients[0];
return new Promise((resolve) => {
const channel = new MessageChannel();
channel.port1.onmessage = (event) => {
resolve(event.data.token);
};
client.postMessage({ type: "GET_AUTH_TOKEN" }, [channel.port2]);
});
}