- 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.
940 lines
29 KiB
Markdown
940 lines
29 KiB
Markdown
---
|
|
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:
|
|
|
|

|
|
|
|
```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:
|
|
|
|

|
|
|
|
- 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:
|
|
|
|

|
|
|
|
- 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
|
|
|
|

|
|
|
|
### 10.2 Ekran transakcji
|
|
|
|

|
|
|
|
### 10.3 Ekran kategorii
|
|
|
|

|
|
|
|
### 10.4 Ekran raportów
|
|
|
|

|
|
|
|
### 10.5 Ekran ustawień
|
|
|
|

|
|
|
|
## 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/)
|