- 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.
312 lines
8.3 KiB
JavaScript
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]);
|
|
});
|
|
}
|