Progressive Web App Features - dinesh-git17/my-progress-planner GitHub Wiki
Comprehensive guide to My Progress Planner's PWA capabilities, offline functionality, and native app-like experience.
My Progress Planner is built as a Progressive Web App, providing a native app experience while remaining accessible through any modern web browser.
- ๐ Instant Loading - App shell loads immediately
- ๐ฑ Installable - Add to home screen like a native app
- ๐ Offline-First - Works without internet connection
- ๐ Push Notifications - Stay engaged with meal reminders
- ๐ Background Sync - Data syncs when connection returns
- ๐พ Reliable - Always works, regardless of network quality
graph TB
subgraph "Browser Environment"
A[Web App] --> B[Service Worker]
B --> C[Cache API]
B --> D[IndexedDB]
B --> E[Background Sync]
B --> F[Push Notifications]
end
subgraph "Caching Layers"
C --> G[App Shell Cache]
C --> H[API Cache]
C --> I[Image Cache]
C --> J[Dynamic Cache]
end
subgraph "Offline Storage"
D --> K[User Data]
D --> L[Pending Syncs]
D --> M[Cached Responses]
end
A --> N[Network]
N --> O[API Server]
N --> P[CDN Assets]
{
"name": "My Progress Planner",
"short_name": "Progress",
"description": "Track your meals with love and support",
"start_url": "/",
"display": "standalone",
"display_override": ["fullscreen", "standalone"],
"background_color": "#f5ede6",
"theme_color": "#f5ede6",
"orientation": "portrait-primary",
"categories": ["health", "lifestyle", "productivity"],
"shortcuts": [
{
"name": "Log Breakfast",
"url": "/breakfast",
"icons": [{"src": "/icon-192.png", "sizes": "192x192"}]
},
{
"name": "Log Lunch",
"url": "/lunch",
"icons": [{"src": "/icon-192.png", "sizes": "192x192"}]
},
{
"name": "Log Dinner",
"url": "/dinner",
"icons": [{"src": "/icon-192.png", "sizes": "192x192"}]
}
]
}
interface InstallPrompt {
platform: 'ios' | 'android' | 'desktop';
trigger: 'automatic' | 'manual' | 'engagement-based';
timing: 'immediate' | 'after-usage' | 'on-return-visit';
}
class PWAInstallManager {
private deferredPrompt: any = null;
constructor() {
this.setupInstallPromptHandling();
}
private setupInstallPromptHandling() {
window.addEventListener('beforeinstallprompt', (e) => {
e.preventDefault();
this.deferredPrompt = e;
this.showInstallButton();
});
window.addEventListener('appinstalled', () => {
this.trackInstallation();
this.hideInstallButton();
});
}
async promptInstall(): Promise<boolean> {
if (!this.deferredPrompt) return false;
this.deferredPrompt.prompt();
const result = await this.deferredPrompt.userChoice;
if (result.outcome === 'accepted') {
this.trackInstallation();
return true;
}
return false;
}
}
// iOS-specific install guidance
const IOSInstallPrompt = () => {
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent);
const isInStandaloneMode = window.matchMedia('(display-mode: standalone)').matches;
if (isIOS && !isInStandaloneMode) {
return (
<div className="ios-install-prompt">
<p>Install this app on your iPhone:</p>
<ol>
<li>Tap the Share button in Safari</li>
<li>Select "Add to Home Screen"</li>
<li>Tap "Add" to install</li>
</ol>
</div>
);
}
return null;
};
// Android-specific features
const AndroidFeatures = {
webApk: true, // Generate WebAPK
shortcuts: true, // App shortcuts
shareTarget: true, // Handle shared content
backgroundSync: true, // Background data sync
pushNotifications: true // Native notifications
};
// Service worker with multiple caching strategies
const CACHE_STRATEGIES = {
// Static assets - cache first, update in background
'app-shell': {
strategy: 'CacheFirst',
cacheName: 'app-shell-v1',
resources: ['/', '/manifest.json', '/icons/*']
},
// API data - network first, fallback to cache
'api-data': {
strategy: 'NetworkFirst',
cacheName: 'api-cache-v1',
endpoints: ['/api/meals/*', '/api/streak', '/api/summaries/*']
},
// Images - cache first with expiration
'images': {
strategy: 'CacheFirst',
cacheName: 'image-cache-v1',
expiration: { maxAgeSeconds: 30 * 24 * 60 * 60 } // 30 days
},
// AI quotes - stale while revalidate
'ai-quotes': {
strategy: 'StaleWhileRevalidate',
cacheName: 'ai-cache-v1',
endpoints: ['/api/ai/quote']
}
};
interface PendingSync {
id: string;
type: 'meal-log' | 'profile-update' | 'friend-add';
data: any;
timestamp: Date;
retries: number;
maxRetries: number;
}
class BackgroundSyncManager {
private readonly SYNC_TAG = 'progress-planner-sync';
private readonly DB_NAME = 'offline-queue';
async queueSync(syncData: Omit<PendingSync, 'id' | 'retries'>): Promise<void> {
const pendingSync: PendingSync = {
id: crypto.randomUUID(),
retries: 0,
maxRetries: 3,
...syncData
};
// Store in IndexedDB
await this.storePendingSync(pendingSync);
// Register background sync
if ('serviceWorker' in navigator && 'sync' in window.ServiceWorkerRegistration.prototype) {
const registration = await navigator.serviceWorker.ready;
await registration.sync.register(this.SYNC_TAG);
} else {
// Fallback: Retry immediately if online
if (navigator.onLine) {
await this.processPendingSync(pendingSync);
}
}
}
async processPendingSyncs(): Promise<void> {
const pendingSyncs = await this.getPendingSyncs();
for (const sync of pendingSyncs) {
try {
await this.processPendingSync(sync);
await this.removePendingSync(sync.id);
} catch (error) {
await this.handleSyncError(sync, error);
}
}
}
private async processPendingSync(sync: PendingSync): Promise<void> {
switch (sync.type) {
case 'meal-log':
await this.syncMealLog(sync.data);
break;
case 'profile-update':
await this.syncProfileUpdate(sync.data);
break;
case 'friend-add':
await this.syncFriendAdd(sync.data);
break;
}
}
}
interface OfflineStorage {
meals: Map<string, Meal>;
userProfile: UserProfile | null;
streakData: StreakData | null;
cachedResponses: Map<string, CachedResponse>;
lastSyncTime: Date | null;
}
class OfflineStorageManager {
private db: IDBDatabase | null = null;
private readonly DB_NAME = 'progress-planner-offline';
private readonly DB_VERSION = 1;
async initialize(): Promise<void> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(this.DB_NAME, this.DB_VERSION);
request.onerror = () => reject(request.error);
request.onsuccess = () => {
this.db = request.result;
resolve();
};
request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
// Create object stores
if (!db.objectStoreNames.contains('meals')) {
const mealStore = db.createObjectStore('meals', { keyPath: 'id' });
mealStore.createIndex('date', 'logged_at');
mealStore.createIndex('type', 'meal_type');
}
if (!db.objectStoreNames.contains('sync-queue')) {
db.createObjectStore('sync-queue', { keyPath: 'id' });
}
if (!db.objectStoreNames.contains('user-data')) {
db.createObjectStore('user-data', { keyPath: 'key' });
}
};
});
}
async storeMeal(meal: Meal): Promise<void> {
const transaction = this.db!.transaction(['meals'], 'readwrite');
const store = transaction.objectStore('meals');
await store.put(meal);
}
async getMealsForDate(date: string): Promise<Meal[]> {
const transaction = this.db!.transaction(['meals'], 'readonly');
const store = transaction.objectStore('meals');
const index = store.index('date');
const meals = await index.getAll(IDBKeyRange.bound(
new Date(date + 'T00:00:00'),
new Date(date + 'T23:59:59')
));
return meals;
}
}
interface NotificationConfig {
enabled: boolean;
mealReminders: boolean;
streakCelebrations: boolean;
dailySummaries: boolean;
friendActivity: boolean;
}
class PushNotificationManager {
private registration: ServiceWorkerRegistration | null = null;
async requestPermission(): Promise<NotificationPermission> {
if (!('Notification' in window)) {
throw new Error('Notifications not supported');
}
const permission = await Notification.requestPermission();
if (permission === 'granted') {
await this.subscribeToNotifications();
}
return permission;
}
private async subscribeToNotifications(): Promise<void> {
this.registration = await navigator.serviceWorker.ready;
const subscription = await this.registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: this.urlBase64ToUint8Array(VAPID_PUBLIC_KEY)
});
// Send subscription to server
await fetch('/api/notifications/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ subscription })
});
}
async scheduleReminder(mealType: string, time: string): Promise<void> {
await fetch('/api/notifications/schedule', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
type: 'meal-reminder',
mealType,
scheduledTime: time
})
});
}
}
interface NotificationData {
type: 'meal-reminder' | 'streak-milestone' | 'daily-summary' | 'encouragement';
title: string;
body: string;
icon: string;
badge: string;
actions?: NotificationAction[];
data?: any;
}
const NOTIFICATION_TEMPLATES = {
'meal-reminder': {
breakfast: {
title: "Good morning! ๐
",
body: "Ready to start your day with some nourishing fuel?",
actions: [
{ action: 'log-meal', title: 'Log Breakfast' },
{ action: 'remind-later', title: 'Remind me later' }
]
},
lunch: {
title: "Lunch time! ๐ฝ๏ธ",
body: "Time to refuel and take care of yourself!",
actions: [
{ action: 'log-meal', title: 'Log Lunch' },
{ action: 'dismiss', title: 'Not now' }
]
},
dinner: {
title: "Dinner time! ๐",
body: "End your day with some delicious nourishment!",
actions: [
{ action: 'log-meal', title: 'Log Dinner' },
{ action: 'view-summary', title: 'View today' }
]
}
},
'streak-milestone': {
title: "Amazing streak! ๐ฅ",
body: "You've logged meals for {streak} days straight! So proud of you!",
actions: [
{ action: 'celebrate', title: 'Celebrate!' },
{ action: 'share', title: 'Share progress' }
]
}
};
// App shell provides instant loading
const AppShell = () => {
return (
<div className="app-shell">
<header className="app-header">
<nav className="navigation">
<div className="nav-skeleton" />
</nav>
</header>
<main className="app-main">
<div className="content-skeleton">
<div className="meal-card-skeleton" />
<div className="meal-card-skeleton" />
<div className="meal-card-skeleton" />
</div>
</main>
<footer className="app-footer">
<div className="bottom-nav-skeleton" />
</footer>
</div>
);
};
// Comprehensive icon set for all platforms
const PWA_ICONS = {
'icon-48': { size: '48x48', purpose: 'any' },
'icon-72': { size: '72x72', purpose: 'any' },
'icon-96': { size: '96x96', purpose: 'any' },
'icon-144': { size: '144x144', purpose: 'any' },
'icon-192': { size: '192x192', purpose: 'any' },
'icon-512': { size: '512x512', purpose: 'any' },
'icon-maskable-192': { size: '192x192', purpose: 'maskable' },
'icon-maskable-512': { size: '512x512', purpose: 'maskable' }
};
// iOS splash screens for different devices
const IOS_SPLASH_SCREENS = [
// iPhone 15 Pro Max, 15 Plus, 14 Pro Max
{
media: '(device-width: 430px) and (device-height: 932px) and (-webkit-device-pixel-ratio: 3)',
href: '/splash/iphone-15-pro-max.png'
},
// iPhone 15 Pro, 15, 14 Pro
{
media: '(device-width: 393px) and (device-height: 852px) and (-webkit-device-pixel-ratio: 3)',
href: '/splash/iphone-15-pro.png'
},
// iPad Pro 12.9"
{
media: '(device-width: 1024px) and (device-height: 1366px) and (-webkit-device-pixel-ratio: 2)',
href: '/splash/ipad-pro-12.9.png'
}
];
// Touch gestures and native feel
class NativeInteractions {
private setupTouchHandlers() {
// Pull-to-refresh
let startY = 0;
let isRefreshing = false;
document.addEventListener('touchstart', (e) => {
startY = e.touches[0].clientY;
});
document.addEventListener('touchmove', (e) => {
if (window.scrollY === 0 && !isRefreshing) {
const currentY = e.touches[0].clientY;
const pullDistance = currentY - startY;
if (pullDistance > 100) {
this.showRefreshIndicator();
}
}
});
document.addEventListener('touchend', async () => {
if (this.shouldRefresh()) {
isRefreshing = true;
await this.refreshData();
this.hideRefreshIndicator();
isRefreshing = false;
}
});
}
private setupSwipeGestures() {
// Swipe between meal types
let startX = 0;
let startTime = 0;
document.addEventListener('touchstart', (e) => {
startX = e.touches[0].clientX;
startTime = Date.now();
});
document.addEventListener('touchend', (e) => {
const endX = e.changedTouches[0].clientX;
const endTime = Date.now();
const distance = Math.abs(endX - startX);
const duration = endTime - startTime;
// Fast swipe detection
if (distance > 50 && duration < 300) {
const direction = endX > startX ? 'right' : 'left';
this.handleSwipe(direction);
}
});
}
}
// Route-based code splitting
const LazyRoutes = {
Dashboard: lazy(() => import('../pages/Dashboard')),
MealLog: lazy(() => import('../pages/MealLog')),
Summaries: lazy(() => import('../pages/Summaries')),
Profile: lazy(() => import('../pages/Profile'))
};
// Component-based lazy loading
const LazyComponents = {
AIResponseCard: lazy(() => import('../components/AIResponseCard')),
StreakVisualizer: lazy(() => import('../components/StreakVisualizer')),
FriendsManager: lazy(() => import('../components/FriendsManager'))
};
// Preload critical resources
class ResourceOptimizer {
preloadCriticalResources() {
// Preload essential fonts
const fontLink = document.createElement('link');
fontLink.rel = 'preload';
fontLink.href = '/fonts/dm-sans-400.woff2';
fontLink.as = 'font';
fontLink.type = 'font/woff2';
fontLink.crossOrigin = 'anonymous';
document.head.appendChild(fontLink);
// Preload critical images
const heroImage = new Image();
heroImage.src = '/images/hero-meal-tracking.webp';
// Prefetch likely next pages
this.prefetchRoutes(['/breakfast', '/lunch', '/dinner']);
}
private prefetchRoutes(routes: string[]) {
routes.forEach(route => {
const link = document.createElement('link');
link.rel = 'prefetch';
link.href = route;
document.head.appendChild(link);
});
}
}
// Next.js bundle analysis
const BundleAnalyzer = require('@next/bundle-analyzer');
const withBundleAnalyzer = BundleAnalyzer({
enabled: process.env.ANALYZE === 'true'
});
const nextConfig = {
// Optimize chunks
webpack: (config) => {
config.optimization.splitChunks = {
chunks: 'all',
cacheGroups: {
vendor: {
test: /[\\/]node_modules[\\/]/,
name: 'vendors',
chunks: 'all',
},
common: {
minChunks: 2,
priority: -10,
reuseExistingChunk: true,
},
},
};
return config;
}
};
module.exports = withBundleAnalyzer(nextConfig);
interface PWAMetrics {
installRate: number;
retentionRate: number;
offlineUsage: number;
syncSuccessRate: number;
notificationEngagement: number;
loadTimes: {
firstContentfulPaint: number;
largestContentfulPaint: number;
timeToInteractive: number;
};
}
class PWAAnalytics {
private metrics: PWAMetrics = {
installRate: 0,
retentionRate: 0,
offlineUsage: 0,
syncSuccessRate: 0,
notificationEngagement: 0,
loadTimes: {
firstContentfulPaint: 0,
largestContentfulPaint: 0,
timeToInteractive: 0
}
};
trackInstall() {
this.sendEvent('pwa_install', {
timestamp: new Date(),
platform: this.detectPlatform(),
userAgent: navigator.userAgent
});
}
trackOfflineUsage() {
if (!navigator.onLine) {
this.sendEvent('offline_usage', {
timestamp: new Date(),
action: 'meal_logged_offline'
});
}
}
private sendEvent(eventName: string, data: any) {
// Send to analytics service
fetch('/api/analytics/track', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ event: eventName, data })
}).catch(() => {
// Store in IndexedDB for later sync
this.queueAnalyticsEvent(eventName, data);
});
}
}
interface EngagementMetrics {
sessionDuration: number;
pagesPerSession: number;
bounceRate: number;
returnVisitorRate: number;
featureUsage: Record<string, number>;
}
class EngagementTracker {
private sessionStart: Date;
private pageViews: number = 0;
constructor() {
this.sessionStart = new Date();
this.setupTracking();
}
private setupTracking() {
// Page visibility changes
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
this.trackSessionEnd();
} else {
this.trackSessionStart();
}
});
// Route changes
this.trackPageView();
// Feature usage
this.trackFeatureUsage();
}
private trackFeatureUsage() {
// Track meal logging
document.addEventListener('meal-logged', (e) => {
this.trackEvent('meal_logged', {
mealType: (e as CustomEvent).detail.mealType,
offline: !navigator.onLine
});
});
// Track AI interactions
document.addEventListener('ai-response-received', () => {
this.trackEvent('ai_interaction');
});
// Track PWA features
document.addEventListener('notification-clicked', () => {
this.trackEvent('notification_engagement');
});
}
}
interface PWATestSuite {
manifest: {
validManifest: boolean;
installable: boolean;
iconsPresent: boolean;
splashScreens: boolean;
};
serviceWorker: {
registered: boolean;
cachingWorking: boolean;
offlineSupport: boolean;
backgroundSync: boolean;
};
performance: {
lighthouse: number;
loadTime: number;
interactivity: number;
};
notifications: {
permission: boolean;
delivery: boolean;
actions: boolean;
};
}
class PWATester {
async runFullTest(): Promise<PWATestSuite> {
return {
manifest: await this.testManifest(),
serviceWorker: await this.testServiceWorker(),
performance: await this.testPerformance(),
notifications: await this.testNotifications()
};
}
private async testManifest(): Promise<PWATestSuite['manifest']> {
const manifestResponse = await fetch('/manifest.json');
const manifest = await manifestResponse.json();
return {
validManifest: this.validateManifest(manifest),
installable: await this.checkInstallability(),
iconsPresent: manifest.icons && manifest.icons.length > 0,
splashScreens: this.checkSplashScreens(manifest)
};
}
private async testServiceWorker(): Promise<PWATestSuite['serviceWorker']> {
const registration = await navigator.serviceWorker.getRegistration();
return {
registered: !!registration,
cachingWorking: await this.testCaching(),
offlineSupport: await this.testOfflineSupport(),
backgroundSync: 'sync' in ServiceWorkerRegistration.prototype
};
}
}
// Playwright PWA testing
const { test, expect } = require('@playwright/test');
test.describe('PWA Features', () => {
test('should be installable', async ({ page }) => {
await page.goto('/');
// Check manifest
const manifestResponse = await page.request.get('/manifest.json');
expect(manifestResponse.ok()).toBeTruthy();
const manifest = await manifestResponse.json();
expect(manifest.name).toBe('My Progress Planner');
expect(manifest.display).toBe('standalone');
});
test('should work offline', async ({ page, context }) => {
await page.goto('/');
await page.waitForLoadState('networkidle');
// Go offline
await context.setOffline(true);
// Try to log a meal
await page.click('[data-testid="breakfast-card"]');
await page.fill('[data-testid="meal-input"]', 'Oatmeal');
await page.click('[data-testid="log-meal-button"]');
// Should show offline indicator but allow logging
await expect(page.locator('[data-testid="offline-indicator"]')).toBeVisible();
await expect(page.locator('[data-testid="meal-logged-message"]')).toBeVisible();
});
test('should cache resources', async ({ page }) => {
await page.goto('/');
// Check if service worker is registered
const swRegistered = await page.evaluate(() => {
return 'serviceWorker' in navigator;
});
expect(swRegistered).toBeTruthy();
// Check cache contents
const cacheNames = await page.evaluate(async () => {
return await caches.keys();
});
expect(cacheNames.length).toBeGreaterThan(0);
});
});
class PWADebugger {
logCacheStatus() {
caches.keys().then(cacheNames => {
console.log('Available caches:', cacheNames);
cacheNames.forEach(cacheName => {
caches.open(cacheName).then(cache => {
cache.keys().then(keys => {
console.log(`${cacheName} contains:`, keys.map(k => k.url));
});
});
});
});
}
logServiceWorkerStatus() {
if ('serviceWorker' in navigator) {
navigator.serviceWorker.getRegistrations().then(registrations => {
console.log('Service Worker registrations:', registrations);
registrations.forEach(registration => {
console.log('SW state:', registration.active?.state);
console.log('SW scope:', registration.scope);
});
});
}
}
testOfflineCapability() {
const originalOnline = navigator.onLine;
// Simulate offline
Object.defineProperty(navigator, 'onLine', {
value: false,
writable: true
});
console.log('Testing offline capability...');
// Test key functionality
this.testMealLogging();
this.testDataAccess();
// Restore online status
Object.defineProperty(navigator, 'onLine', {
value: originalOnline,
writable: true
});
}
}
// next.config.mjs - PWA optimization
const withPWA = require('next-pwa')({
dest: 'public',
disable: process.env.NODE_ENV === 'development',
register: true,
skipWaiting: true,
runtimeCaching: [
{
urlPattern: /^https:\/\/fonts\.googleapis\.com\/.*/i,
handler: 'CacheFirst',
options: {
cacheName: 'google-fonts',
expiration: {
maxEntries: 4,
maxAgeSeconds: 365 * 24 * 60 * 60 // 1 year
}
}
},
{
urlPattern: /\/api\/meals\/.*/i,
handler: 'NetworkFirst',
options: {
cacheName: 'api-cache',
networkTimeoutSeconds: 10,
expiration: {
maxEntries: 100,
maxAgeSeconds: 24 * 60 * 60 // 1 day
}
}
}
]
});
module.exports = withPWA({
// Other Next.js config
});
For questions about PWA features, offline functionality, or installation issues:
-
PWA Issues: GitHub Issues with
pwa
label - Installation Help: GitHub Discussions
-
Technical Support:
[email protected]
Our PWA implementation ensures My Progress Planner works seamlessly across all devices and network conditions, providing a native app experience while maintaining the accessibility of the web. ๐ฑ๐