diff --git a/PWA.md b/PWA.md new file mode 100644 index 0000000..0f03bb5 --- /dev/null +++ b/PWA.md @@ -0,0 +1,240 @@ +# PWA Implementation Guide + +## Overview +SubGrid is now a fully functional Progressive Web App (PWA) with offline support, installation capabilities, and long-term device storage. + +## Features Implemented + +### ✅ Service Worker (sw.js) +- **Cache-first strategy** for static assets (HTML, CSS, JS, icons) +- **Network-first strategy** for exchange rate API calls +- **Offline fallback** to custom offline page when network unavailable +- **Automatic cache updates** with version management +- **Background sync** for exchange rates (when supported) +- **CDN resource caching** for Tailwind, Iconify, fonts + +### ✅ Web App Manifest (manifest.json) +- App metadata (name, description, icons) +- Standalone display mode (feels like native app) +- Theme colors matching app design +- App shortcuts for quick actions +- Share target integration +- Multiple icon sizes (192x192, 512x512) + +### ✅ Offline Support +- Custom offline page (offline.html) with: + - Connection status monitoring + - Auto-reload when online + - Clear messaging about offline capabilities + - Visual feedback +- All app functionality works offline (except exchange rate updates) + +### ✅ Installation +- Install prompt banner at top of page +- Detects PWA installation capability +- One-click installation +- Apple iOS support with meta tags +- Android support with manifest + +### ✅ Long-term Storage +- LocalStorage for subscription data (already implemented) +- Service worker caching for app shell +- Persistent across sessions and offline use +- Data survives browser restarts + +## How to Use + +### Testing Locally + +1. **Serve with HTTPS** (required for service workers): + ```bash + # Option 1: Using Python + python -m http.server 8000 + + # Option 2: Using npx + npx serve . + + # Option 3: Using local-web-server + npx local-web-server --https + ``` + +2. **Access via localhost**: + - Navigate to `http://localhost:8000` + - Open browser DevTools > Application tab + - Check "Service Workers" to see registration status + - Check "Manifest" to verify PWA configuration + +3. **Test Offline**: + - Load the page once (caches resources) + - Go to DevTools > Network tab + - Check "Offline" mode + - Refresh page - should still work! + - Try adding subscriptions offline + +4. **Test Installation**: + - Chrome: Look for install banner or + icon in address bar + - Edge: Similar to Chrome + - Safari iOS: Share > Add to Home Screen + - Android: Install banner appears automatically + +### Deployment (Cloudflare Pages) + +Already configured in `wrangler.jsonc`: + +```bash +# Deploy to Cloudflare Pages +npx wrangler pages deploy . + +# Or use Cloudflare dashboard +# - Connect your GitHub repo +# - Auto-deploys on push to main +``` + +### PWA Best Practices Implemented + +✅ **HTTPS only** - Service workers require secure context +✅ **Responsive design** - Works on all screen sizes +✅ **Fast loading** - Cached assets load instantly +✅ **Works offline** - Core features available without network +✅ **Installable** - Meets all PWA criteria +✅ **App-like experience** - Standalone display mode +✅ **Theme colors** - Matches system/browser theme +✅ **Proper icons** - Multiple sizes for different devices + +## File Structure + +``` +/ +├── manifest.json # PWA manifest +├── sw.js # Service worker +├── offline.html # Offline fallback page +├── index.html # Main app (with PWA meta tags) +├── icons/ +│ ├── icon-192.png # App icon (192x192) +│ ├── icon-512.png # App icon (512x512) +│ ├── icon-192.svg # Source SVG +│ └── icon-512.svg # Source SVG +└── screenshots/ + └── README.md # Placeholder for app screenshots +``` + +## Caching Strategy + +### Precached on Install +- `/` (home page) +- `/index.html` +- `/offline.html` +- `/styles.css` +- All JavaScript files +- App icons + +### Cached on First Use +- CDN resources (Tailwind, Iconify, fonts) +- Exchange rate API responses + +### Network First +- Exchange rate API (with cache fallback) + +## Browser Support + +| Feature | Chrome | Edge | Safari | Firefox | +|---------|--------|------|--------|---------| +| Service Worker | ✅ | ✅ | ✅ | ✅ | +| Web Manifest | ✅ | ✅ | ⚠️ * | ✅ | +| Installation | ✅ | ✅ | ⚠️ * | ❌ | +| Offline | ✅ | ✅ | ✅ | ✅ | +| Background Sync | ✅ | ✅ | ❌ | ❌ | + +\* Safari uses Add to Home Screen instead of install prompts + +## Debugging + +### Chrome DevTools +1. Open DevTools (F12) +2. Go to **Application** tab +3. Check sections: + - **Manifest** - Verify manifest.json + - **Service Workers** - See registration & status + - **Cache Storage** - Inspect cached files + - **Local Storage** - View subscription data + +### Common Issues + +**Service worker not registering?** +- Must be served over HTTPS (or localhost) +- Check browser console for errors +- Verify sw.js path is correct + +**Install prompt not showing?** +- Manifest must be valid +- Icons must be accessible +- App must be served over HTTPS +- User must visit site at least once + +**Offline not working?** +- Service worker must be active +- Page must be cached +- Check Network tab in DevTools (set to Offline) + +## Updating the PWA + +When you make changes: + +1. **Update cache version** in `sw.js`: + ```javascript + const CACHE_VERSION = 'subgrid-v1.0.1'; // Increment version + ``` + +2. **Service worker will auto-update**: + - Detects new version + - Shows reload prompt to user + - Cleans up old caches + +## Performance Metrics + +Expected Lighthouse scores (PWA audit): +- ✅ Progressive Web App: 100 +- ✅ Performance: 95+ +- ✅ Accessibility: 90+ +- ✅ Best Practices: 100 +- ✅ SEO: 100 + +## Future Enhancements + +Consider adding: +- [ ] Push notifications for subscription renewals +- [ ] Background sync for data backup +- [ ] Periodic background sync for exchange rates +- [ ] Web Share API for sharing stats +- [ ] Payment Request API integration +- [ ] Device contacts API for split subscriptions +- [ ] IndexedDB for larger data sets +- [ ] Advanced caching strategies per route + +## Security Considerations + +✅ **No sensitive data in cache** - Only app shell +✅ **LocalStorage encryption** - Consider implementing +✅ **HTTPS required** - Enforced by service workers +✅ **Content Security Policy** - Consider adding headers +✅ **No external tracking** - Privacy-first approach + +## Testing Checklist + +- [ ] Service worker registers successfully +- [ ] App works offline after first visit +- [ ] Install prompt appears on supported browsers +- [ ] Installed app opens in standalone mode +- [ ] Icons appear correctly on home screen +- [ ] Offline page shows when network unavailable +- [ ] Cache updates on new deployment +- [ ] LocalStorage persists data +- [ ] Exchange rates update when online +- [ ] All visualizations work offline + +## Resources + +- [MDN: Progressive Web Apps](https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps) +- [Google: PWA Checklist](https://web.dev/pwa-checklist/) +- [Service Worker API](https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API) +- [Web App Manifest](https://developer.mozilla.org/en-US/docs/Web/Manifest) diff --git a/README.md b/README.md index f43b938..087f5e4 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,17 @@ A simple tool to visualize your subscription costs. See where your money goes ea - Import subscriptions from bank statements (CSV) - Export your visualization as an image - Supports 38+ currencies +- **NEW: Progressive Web App (PWA)** - Install on your device, works offline! + +## PWA Features + +✨ **Install as an App** - Add to your home screen on mobile or desktop +📴 **Works Offline** - Access your subscription data without internet +⚡ **Fast Loading** - Cached assets load instantly +💾 **Long-term Storage** - Your data persists on your device +🔄 **Auto Updates** - Get the latest features automatically + +See [PWA.md](PWA.md) for detailed PWA implementation documentation. ## How to use diff --git a/icons/apple-touch-icon.png b/icons/apple-touch-icon.png new file mode 100644 index 0000000..b68853c Binary files /dev/null and b/icons/apple-touch-icon.png differ diff --git a/icons/favicon-16x16.png b/icons/favicon-16x16.png new file mode 100644 index 0000000..8aa159c Binary files /dev/null and b/icons/favicon-16x16.png differ diff --git a/icons/favicon-32x32.png b/icons/favicon-32x32.png new file mode 100644 index 0000000..55b9a0a Binary files /dev/null and b/icons/favicon-32x32.png differ diff --git a/icons/icon-192x192.png b/icons/icon-192x192.png new file mode 100644 index 0000000..cf48a8a Binary files /dev/null and b/icons/icon-192x192.png differ diff --git a/icons/icon-512x512.png b/icons/icon-512x512.png new file mode 100644 index 0000000..37ce951 Binary files /dev/null and b/icons/icon-512x512.png differ diff --git a/index.html b/index.html index d7c523e..1969a97 100644 --- a/index.html +++ b/index.html @@ -8,6 +8,33 @@ name="description" content="Visualize your subscription costs with an interactive grid. See the true proportion of your monthly spending at a glance." /> + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -17,6 +44,7 @@ /> + @@ -794,5 +964,183 @@

Import Bank + + + + + diff --git a/js/app.js b/js/app.js index d39a2b2..edbb511 100644 --- a/js/app.js +++ b/js/app.js @@ -2,6 +2,15 @@ let subs = []; let step = 1; let selectedCurrency = "USD"; let currentView = "treemap"; +let filteredSubs = []; // For search functionality +let searchQuery = ""; + +// Grid search variables +window.filteredSubsGrid = []; +window.searchQueryGrid = ""; + +// Make selectedCurrency accessible globally +window.selectedCurrency = selectedCurrency; window.currencies = { USD: { symbol: "$", name: "US Dollar", rate: 1 }, @@ -208,13 +217,15 @@ function setView(view) { // Update button styles const views = ["treemap", "beeswarm", "circlepack"]; - const activeClass = "bg-slate-900 text-white"; - const inactiveClass = "bg-white text-slate-600"; + const activeClass = "bg-slate-900 dark:bg-slate-100 text-white dark:text-slate-900"; + const inactiveClass = "bg-white dark:bg-slate-700 text-slate-600 dark:text-slate-300"; views.forEach(v => { const btn = document.getElementById("view-" + v); if (btn) { - btn.classList.remove(...activeClass.split(" "), ...inactiveClass.split(" ")); + // Remove all active and inactive classes + btn.classList.remove("bg-slate-900", "dark:bg-slate-100", "text-white", "dark:text-slate-900", + "bg-white", "dark:bg-slate-700", "text-slate-600", "dark:text-slate-300"); if (v === view) { btn.classList.add(...activeClass.split(" ")); } else { @@ -249,10 +260,16 @@ function renderList() { const emptyState = document.getElementById("empty-state"); const nextBtn = document.getElementById("next-btn-1"); const clearBtn = document.getElementById("clear-btn"); + const searchSection = document.getElementById("search-section"); + const searchResultsInfo = document.getElementById("search-results-info"); + + // Determine which subscriptions to display + const displaySubs = searchQuery ? filteredSubs : subs; if (subs.length === 0) { listContainer.classList.add("hidden"); emptyState.classList.remove("hidden"); + searchSection.classList.add("hidden"); nextBtn.disabled = true; nextBtn.classList.add("opacity-50", "cursor-not-allowed"); clearBtn.classList.add("hidden"); @@ -262,32 +279,61 @@ function renderList() { emptyState.classList.add("hidden"); listContainer.classList.remove("hidden"); + searchSection.classList.remove("hidden"); nextBtn.disabled = false; nextBtn.classList.remove("opacity-50", "cursor-not-allowed"); clearBtn.classList.remove("hidden"); clearBtn.classList.add("flex"); - let html = ""; - for (let i = 0; i < subs.length; i++) { - const sub = subs[i]; - const color = getColor(sub.color); - - html += '
'; - html += '
'; - html += '
'; - html += iconHtml(sub, "w-10 h-10"); - html += '
'; - html += '
' + sub.name + '
'; - html += '
' + formatOriginalPrice(sub) + ' / ' + sub.cycle + '
'; - html += '
'; - html += '
'; - html += ''; - html += ''; - html += '
'; + // Show search results info + if (searchQuery) { + searchResultsInfo.classList.remove("hidden"); + if (displaySubs.length === 0) { + searchResultsInfo.textContent = `No subscriptions found for "${searchQuery}"`; + searchResultsInfo.classList.add("text-red-500", "dark:text-red-400"); + searchResultsInfo.classList.remove("text-slate-500", "dark:text-slate-400"); + } else { + searchResultsInfo.textContent = `Found ${displaySubs.length} of ${subs.length} subscriptions`; + searchResultsInfo.classList.remove("text-red-500", "dark:text-red-400"); + searchResultsInfo.classList.add("text-slate-500", "dark:text-slate-400"); + } + } else { + searchResultsInfo.classList.add("hidden"); } - html += ''; + let html = ""; + + if (displaySubs.length === 0 && searchQuery) { + // Show empty search state + html = '
'; + html += ''; + html += '

No subscriptions found

'; + html += '

Try a different search term

'; + html += '
'; + } else { + for (let i = 0; i < displaySubs.length; i++) { + const sub = displaySubs[i]; + const color = getColor(sub.color); + + html += '
'; + html += '
'; + html += '
'; + html += iconHtml(sub, "w-10 h-10"); + html += '
'; + html += '
' + sub.name + '
'; + html += '
' + formatOriginalPrice(sub) + ' / ' + sub.cycle + '
'; + html += '
'; + html += '
'; + html += ''; + html += ''; + html += '
'; + } + + if (!searchQuery) { + html += ''; + } + } listContainer.innerHTML = html; } @@ -379,9 +425,9 @@ function renderPresets() { const logo = "https://img.logo.dev/" + preset.domain + "?token=pk_KuI_oR-IQ1-fqpAfz3FPEw&size=100&retina=true&format=png"; html += ''; } grid.innerHTML = html; @@ -392,10 +438,23 @@ function removeSub(subId) { save(); } -function clearAllSubs() { - if (!confirm("Delete all subscriptions?")) return; +async function clearAllSubs() { + const result = await showConfirmAlert( + 'Delete all subscriptions?', + 'This will remove all your subscriptions. Settings and currency preferences will be kept.', + 'Delete All', + 'Cancel' + ); + + if (!result.isConfirmed) return; + subs = []; save(); + + showSuccessAlert( + 'Subscriptions Cleared', + 'All subscriptions have been deleted successfully.' + ); } function editSub(subId) { @@ -533,6 +592,105 @@ function handleFormSubmit(evt) { hideModal(); } +// Search functionality +function filterSubscriptions() { + const searchInput = document.getElementById("search-input"); + const clearSearchBtn = document.getElementById("clear-search-btn"); + + searchQuery = searchInput.value.toLowerCase().trim(); + + // Show/hide clear button + if (searchQuery) { + clearSearchBtn.classList.remove("hidden"); + } else { + clearSearchBtn.classList.add("hidden"); + } + + if (!searchQuery) { + filteredSubs = []; + renderList(); + return; + } + + // Filter subscriptions by name, cycle, or price + filteredSubs = subs.filter(sub => { + const nameMatch = sub.name.toLowerCase().includes(searchQuery); + const cycleMatch = sub.cycle.toLowerCase().includes(searchQuery); + const priceMatch = sub.price.toString().includes(searchQuery); + const currencyMatch = sub.currency.toLowerCase().includes(searchQuery); + + return nameMatch || cycleMatch || priceMatch || currencyMatch; + }); + + renderList(); +} + +function clearSearch() { + const searchInput = document.getElementById("search-input"); + const clearSearchBtn = document.getElementById("clear-search-btn"); + + searchInput.value = ""; + searchQuery = ""; + filteredSubs = []; + clearSearchBtn.classList.add("hidden"); + + renderList(); +} + +// Grid search functionality +function filterGridSubscriptions() { + const searchInput = document.getElementById("search-input-grid"); + const clearSearchBtn = document.getElementById("clear-search-btn-grid"); + + window.searchQueryGrid = searchInput.value.toLowerCase().trim(); + + // Show/hide clear button + if (window.searchQueryGrid) { + clearSearchBtn.classList.remove("hidden"); + } else { + clearSearchBtn.classList.add("hidden"); + } + + if (!window.searchQueryGrid) { + window.filteredSubsGrid = []; + renderGrid(); + // Also update other views + if (typeof window.renderBeeswarm === 'function') window.renderBeeswarm(); + if (typeof window.renderCirclePack === 'function') window.renderCirclePack(); + return; + } + + // Filter subscriptions by name, cycle, or price + window.filteredSubsGrid = subs.filter(sub => { + const nameMatch = sub.name.toLowerCase().includes(window.searchQueryGrid); + const cycleMatch = sub.cycle.toLowerCase().includes(window.searchQueryGrid); + const priceMatch = sub.price.toString().includes(window.searchQueryGrid); + const currencyMatch = sub.currency.toLowerCase().includes(window.searchQueryGrid); + + return nameMatch || cycleMatch || priceMatch || currencyMatch; + }); + + renderGrid(); + // Also update other views + if (typeof window.renderBeeswarm === 'function') window.renderBeeswarm(); + if (typeof window.renderCirclePack === 'function') window.renderCirclePack(); +} + +function clearGridSearch() { + const searchInput = document.getElementById("search-input-grid"); + const clearSearchBtn = document.getElementById("clear-search-btn-grid"); + + searchInput.value = ""; + window.searchQueryGrid = ""; + window.filteredSubsGrid = []; + clearSearchBtn.classList.add("hidden"); + + renderGrid(); + // Also update other views + if (typeof window.renderBeeswarm === 'function') window.renderBeeswarm(); + if (typeof window.renderCirclePack === 'function') window.renderCirclePack(); +} + document.addEventListener("DOMContentLoaded", async () => { await window.initRates(); load(); diff --git a/js/beeswarm.js b/js/beeswarm.js index c7d61f0..efb37fd 100644 --- a/js/beeswarm.js +++ b/js/beeswarm.js @@ -105,13 +105,27 @@ class Beeswarm { function renderBeeswarm() { const container = document.getElementById("beeswarm-container"); - if (!container || !subs.length) { + + // Use filtered subscriptions if search is active + const displaySubs = (typeof window.searchQueryGrid !== 'undefined' && window.searchQueryGrid) ? window.filteredSubsGrid : subs; + + if (!container || !displaySubs.length) { if (container) { - container.innerHTML = ` -
-

Add subscriptions to see the beeswarm plot

-
- `; + if (window.searchQueryGrid && subs.length > 0) { + container.innerHTML = ` +
+ +

No subscriptions found

+

Try a different search term

+
+ `; + } else { + container.innerHTML = ` +
+

Add subscriptions to see the beeswarm plot

+
+ `; + } } return; } @@ -122,7 +136,7 @@ function renderBeeswarm() { const isMobile = width < 500; const padding = isMobile ? 20 : 40; - const items = subs.map(sub => ({ ...sub, cost: toMonthly(sub) })); + const items = displaySubs.map(sub => ({ ...sub, cost: toMonthly(sub) })); const beeswarm = new Beeswarm(width, height, padding, isMobile); const positioned = beeswarm.layout(items); diff --git a/js/circlepack.js b/js/circlepack.js index 82a4837..03c4db2 100644 --- a/js/circlepack.js +++ b/js/circlepack.js @@ -189,13 +189,27 @@ class CirclePack { function renderCirclePack() { const container = document.getElementById("circlepack-container"); - if (!container || !subs.length) { + + // Use filtered subscriptions if search is active + const displaySubs = (typeof window.searchQueryGrid !== 'undefined' && window.searchQueryGrid) ? window.filteredSubsGrid : subs; + + if (!container || !displaySubs.length) { if (container) { - container.innerHTML = ` -
-

Add subscriptions to see the circle pack

-
- `; + if (window.searchQueryGrid && subs.length > 0) { + container.innerHTML = ` +
+ +

No subscriptions found

+

Try a different search term

+
+ `; + } else { + container.innerHTML = ` +
+

Add subscriptions to see the circle pack

+
+ `; + } } return; } @@ -204,7 +218,7 @@ function renderCirclePack() { const width = rect.width || 800; const height = rect.height || 600; - const items = subs.map(sub => ({ ...sub, cost: toMonthly(sub) })); + const items = displaySubs.map(sub => ({ ...sub, cost: toMonthly(sub) })); const packer = new CirclePack(width, height, 30); const positioned = packer.layout(items); diff --git a/js/clear-data.js b/js/clear-data.js new file mode 100644 index 0000000..dd8819a --- /dev/null +++ b/js/clear-data.js @@ -0,0 +1,120 @@ +// Clear All App Data Functionality + +async function clearAllAppData() { + // Show confirmation dialog with SweetAlert2 + const result = await showConfirmAlert( + 'Clear All Data?', + `This will permanently delete:

+ +
This action cannot be undone.`, + 'Clear All Data', + 'Cancel' + ); + + if (!result.isConfirmed) { + return; + } + + try { + console.log('Clearing all app data...'); + + // 1. Clear localStorage + localStorage.clear(); + console.log('✓ LocalStorage cleared'); + + // 2. Clear sessionStorage + sessionStorage.clear(); + console.log('✓ SessionStorage cleared'); + + // 3. Clear all service worker caches + if ('caches' in window) { + const cacheNames = await caches.keys(); + await Promise.all( + cacheNames.map(cacheName => { + console.log(`Deleting cache: ${cacheName}`); + return caches.delete(cacheName); + }) + ); + console.log('✓ Service worker caches cleared'); + } + + // 4. Clear IndexedDB (if any databases exist) + if ('indexedDB' in window) { + const databases = await indexedDB.databases(); + databases.forEach(db => { + if (db.name) { + indexedDB.deleteDatabase(db.name); + console.log(`✓ Deleted IndexedDB: ${db.name}`); + } + }); + } + + // 5. Unregister service worker (optional - keeps PWA but clears cache) + if ('serviceWorker' in navigator) { + const registrations = await navigator.serviceWorker.getRegistrations(); + for (let registration of registrations) { + await registration.unregister(); + console.log('✓ Service worker unregistered'); + } + } + + console.log('✓ All app data cleared successfully'); + + // Show success message + await showSuccessAlert('Success!', 'All data cleared successfully. The page will now reload.'); + + // Reload the page to reset the app + window.location.reload(); + + } catch (error) { + console.error('Error clearing app data:', error); + await showErrorAlert('Error', 'Failed to clear data: ' + error.message + '\n\nPlease try clearing your browser data manually.'); + } +} + +// Alternative: Clear only app data, keep service worker (for PWA users who want to stay installed) +async function clearAppDataKeepPWA() { + const result = await showConfirmAlert( + 'Clear App Data?', + 'This will delete subscriptions and settings but keep the PWA installed.', + 'Clear Data', + 'Cancel' + ); + + if (!result.isConfirmed) { + return; + } + + try { + console.log('Clearing app data (keeping PWA)...'); + + // Clear storage only + localStorage.clear(); + sessionStorage.clear(); + + // Clear caches except service worker registration + if ('caches' in window) { + const cacheNames = await caches.keys(); + await Promise.all( + cacheNames.map(cacheName => caches.delete(cacheName)) + ); + } + + console.log('✓ App data cleared (PWA preserved)'); + await showSuccessAlert('Success!', 'App data cleared. The page will now reload.'); + window.location.reload(); + + } catch (error) { + console.error('Error clearing app data:', error); + await showErrorAlert('Error', error.message); + } +} + +// Make functions globally accessible +window.clearAllAppData = clearAllAppData; +window.clearAppDataKeepPWA = clearAppDataKeepPWA; diff --git a/js/geolocation.js b/js/geolocation.js new file mode 100644 index 0000000..b470ccb --- /dev/null +++ b/js/geolocation.js @@ -0,0 +1,375 @@ +// Geolocation and Auto-Currency Detection + +// Cache key for country-currency mapping +const COUNTRY_CURRENCY_CACHE_KEY = 'countryCurrencyMap'; +const COUNTRY_CURRENCY_CACHE_DURATION = 30 * 24 * 60 * 60 * 1000; // 30 days + +// Fetch and cache country-currency mapping +async function getCountryCurrencyMap() { + try { + // Check cache first + const cached = localStorage.getItem(COUNTRY_CURRENCY_CACHE_KEY); + if (cached) { + const data = JSON.parse(cached); + const age = Date.now() - data.timestamp; + if (age < COUNTRY_CURRENCY_CACHE_DURATION) { + return data.map; + } + } + + // Fetch from reliable static source (restcountries.com provides free country data) + const response = await fetch('https://restcountries.com/v3.1/all?fields=cca2,currencies'); + + if (!response.ok) { + throw new Error('Failed to fetch country-currency data'); + } + + const countries = await response.json(); + + // Build country code to primary currency map + const countryToCurrency = {}; + countries.forEach(country => { + if (country.cca2 && country.currencies) { + // Get first (primary) currency for the country + const currencyCode = Object.keys(country.currencies)[0]; + countryToCurrency[country.cca2] = currencyCode; + } + }); + + // Cache the mapping + localStorage.setItem(COUNTRY_CURRENCY_CACHE_KEY, JSON.stringify({ + map: countryToCurrency, + timestamp: Date.now() + })); + + return countryToCurrency; + + } catch (error) { + console.error('Failed to fetch country-currency map:', error); + // Fallback to basic USD if everything fails + return null; + } +} + +// Detect user's location and set currency +async function detectLocationAndCurrency() { + // Check if already detected or user declined + const alreadyDetected = localStorage.getItem('locationDetected'); + if (alreadyDetected === 'true' || alreadyDetected === 'declined') { + return; + } + + // Show consent dialog first + showLocationConsentDialog(); +} + +// Show consent dialog for location detection +function showLocationConsentDialog() { + const consent = document.createElement('div'); + consent.id = 'location-consent'; + consent.className = 'fixed bottom-4 left-4 right-4 sm:left-auto sm:right-4 sm:max-w-sm z-50 rounded-2xl bg-white dark:bg-slate-800 p-5 shadow-2xl border-2 border-indigo-200 dark:border-indigo-800 animate-slide-in'; + consent.innerHTML = ` +
+
+
+ +
+
+

Auto-detect Currency?

+

We'll use your IP address to detect your country and set the default currency.

+
+
+
+
+ +

No GPS or precise location used

+
+
+ +

Only country-level detection

+
+
+ +

You can change currency anytime in Settings

+
+
+
+
+ + +
+ `; + + document.body.appendChild(consent); +} + +// User accepts location detection +async function acceptLocationDetection() { + // Remove consent dialog + const consent = document.getElementById('location-consent'); + if (consent) consent.remove(); + + // Proceed with detection + await performLocationDetection(); +} + +// User declines location detection +function declineLocationDetection() { + // Remove consent dialog + const consent = document.getElementById('location-consent'); + if (consent) consent.remove(); + + // Mark as declined + localStorage.setItem('locationDetected', 'declined'); + + // Set USD as default if no currency is set + const currentCurrency = localStorage.getItem('currency'); + if (!currentCurrency) { + localStorage.setItem('currency', 'USD'); + window.selectedCurrency = 'USD'; + + // Initialize selectors + if (typeof window.initCurrencySelector === 'function') { + window.initCurrencySelector(); + } + if (typeof window.initFormCurrencySelector === 'function') { + window.initFormCurrencySelector(); + } + } + + // Show info message about manual selection + showManualCurrencyMessage(); +} + +// Show message about manual currency selection +function showManualCurrencyMessage() { + const message = document.createElement('div'); + message.className = 'fixed bottom-4 left-4 right-4 sm:left-auto sm:right-4 sm:max-w-sm z-50 rounded-xl bg-slate-100 dark:bg-slate-700 p-4 shadow-lg border border-slate-200 dark:border-slate-600 animate-slide-in'; + message.innerHTML = ` +
+ +
+

No problem!

+

You can select your currency in Settings

+
+ +
+ `; + + document.body.appendChild(message); + + // Auto-remove after 4 seconds + setTimeout(() => { + if (message.parentElement) { + message.style.opacity = '0'; + message.style.transition = 'opacity 0.3s ease-in-out'; + setTimeout(() => message.remove(), 300); + } + }, 4000); +} + +// Perform actual location detection (after consent) +async function performLocationDetection() { + + try { + // Fetch country-currency mapping first + const countryCurrencyMap = await getCountryCurrencyMap(); + if (!countryCurrencyMap) { + localStorage.setItem('locationDetected', 'true'); + return; + } + + // Use ipapi.co for IP-based geolocation (free, no API key required) + const response = await fetch('https://ipapi.co/json/', { + method: 'GET', + headers: { 'Accept': 'application/json' } + }); + + if (!response.ok) { + throw new Error('Geolocation API failed'); + } + + const data = await response.json(); + const countryCode = data.country_code; + const city = data.city; + const country = data.country_name; + + // Map country to currency using fetched data + const detectedCurrency = countryCurrencyMap[countryCode] || 'USD'; + + // Only auto-set if user hasn't manually selected a currency + const manuallySet = localStorage.getItem('currencyManuallySet'); + + // Only override if not manually set by user + if (manuallySet === 'true') { + return; + } + + // Set currency in localStorage + localStorage.setItem('currency', detectedCurrency); + + // Update selectedCurrency variable in app.js + if (typeof window.selectedCurrency !== 'undefined') { + window.selectedCurrency = detectedCurrency; + } + // Also update in app.js scope + if (typeof selectedCurrency !== 'undefined') { + selectedCurrency = detectedCurrency; + } + + // Update the dropdown directly + const currencyDropdown = document.getElementById('currency-selector'); + if (currencyDropdown) { + currencyDropdown.value = detectedCurrency; + } + + // Reinitialize currency selectors to reflect the new currency + if (typeof window.initCurrencySelector === 'function') { + window.initCurrencySelector(); + } + + if (typeof window.initFormCurrencySelector === 'function') { + window.initFormCurrencySelector(); + } + + // Refresh rates and render if subscriptions exist + if (window.subs && window.subs.length > 0) { + if (typeof window.loadRates === 'function') { + await window.loadRates(); + } + if (typeof window.renderList === 'function') { + window.renderList(); + } + if (typeof window.updateTotals === 'function') { + window.updateTotals(); + } + } + + // Store location info for display (optional) + localStorage.setItem('userLocation', JSON.stringify({ + city: city, + country: country, + countryCode: countryCode, + detectedAt: new Date().toISOString() + })); + + // Update settings modal if available + updateLocationDisplay(country); + + // Mark as detected + localStorage.setItem('locationDetected', 'true'); + + // Show notification to user (country only, not city) + showLocationDetectedNotification(country, detectedCurrency); + + } catch (error) { + console.error('Location detection failed:', error); + // Silently fail - user can manually select currency + localStorage.setItem('locationDetected', 'true'); + } +} + +// Show a subtle notification about auto-detected currency +function showLocationDetectedNotification(country, currency) { + // Create notification element at bottom (to avoid install banner) + const notification = document.createElement('div'); + notification.id = 'location-notification'; + notification.className = 'fixed bottom-4 left-4 right-4 sm:left-auto sm:right-4 sm:max-w-sm z-40 rounded-xl bg-white dark:bg-slate-800 p-4 shadow-2xl border border-slate-200 dark:border-slate-700 animate-slide-in'; + notification.innerHTML = ` +
+
+ +
+
+

Currency Set Successfully

+

${country}

+

${currency} selected as default

+
+ +
+ `; + + document.body.appendChild(notification); + + // Auto-remove after 5 seconds + setTimeout(() => { + if (notification.parentElement) { + notification.style.opacity = '0'; + notification.style.transition = 'opacity 0.3s ease-in-out'; + setTimeout(() => notification.remove(), 300); + } + }, 5000); +} + +// Reset location detection (for testing or manual reset) +function resetLocationDetection() { + localStorage.removeItem('locationDetected'); + localStorage.removeItem('userLocation'); + console.log('Location detection reset'); +} + +// Clear country-currency cache (for testing or updates) +function clearCountryCurrencyCache() { + localStorage.removeItem(COUNTRY_CURRENCY_CACHE_KEY); + console.log('Country-currency cache cleared'); +} + +// Update location display in settings modal +function updateLocationDisplay(country) { + const locationInfo = document.getElementById('location-info'); + const detectedLocation = document.getElementById('detected-location'); + + if (locationInfo && detectedLocation && country) { + detectedLocation.textContent = country; + locationInfo.classList.remove('hidden'); + } +} + +// Initialize location display on settings open +function initLocationDisplay() { + const userLocation = localStorage.getItem('userLocation'); + if (userLocation) { + try { + const location = JSON.parse(userLocation); + updateLocationDisplay(location.country); + } catch (e) { + console.error('Failed to parse location:', e); + } + } +} + +// Call on page load if location exists +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', initLocationDisplay); +} else { + initLocationDisplay(); +} + +// Initialize on page load +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', () => { + setTimeout(detectLocationAndCurrency, 1000); + }); +} else { + setTimeout(detectLocationAndCurrency, 1000); +} + +// Make functions globally accessible +window.acceptLocationDetection = acceptLocationDetection; +window.declineLocationDetection = declineLocationDetection; +window.resetLocationDetection = resetLocationDetection; +window.clearCountryCurrencyCache = clearCountryCurrencyCache; diff --git a/js/modals.js b/js/modals.js index 90f8dd5..5febdce 100644 --- a/js/modals.js +++ b/js/modals.js @@ -146,14 +146,14 @@ function renderCategoryFilters() { const cats = getCategories(); let html = ''; for (let i = 0; i < cats.length; i++) { const cat = cats[i]; const isActive = (selectedCategory === cat); html += ''; } @@ -197,7 +197,7 @@ function renderPresetsBrowserList(presetsToShow) { if (!container) return; if (presetsToShow.length === 0) { - container.innerHTML = '
No subscriptions found
'; + container.innerHTML = '
No subscriptions found
'; return; } @@ -218,7 +218,7 @@ function renderPresetsBrowserList(presetsToShow) { const items = byCategory[catName]; html += '
'; - html += '

' + catName + '

'; + html += '

' + catName + '

'; html += '
'; for (let i = 0; i < items.length; i++) { @@ -227,11 +227,11 @@ function renderPresetsBrowserList(presetsToShow) { const logo = "https://img.logo.dev/" + p.domain + "?token=pk_KuI_oR-IQ1-fqpAfz3FPEw&size=100&retina=true&format=png"; html += ''; } diff --git a/js/storage.js b/js/storage.js index da514fa..ed0412e 100644 --- a/js/storage.js +++ b/js/storage.js @@ -27,14 +27,19 @@ function loadCurrency() { // make sure it's a valid currency code if (saved && currencies[saved]) { selectedCurrency = saved; + window.selectedCurrency = saved; // Keep window reference in sync } else { + // Default to USD, but geolocation will override this if enabled selectedCurrency = "USD"; + window.selectedCurrency = "USD"; // Keep window reference in sync } } function saveCurrency(code) { selectedCurrency = code; + window.selectedCurrency = code; // Keep window reference in sync localStorage.setItem(CURRENCY_KEY, code); + localStorage.setItem('currencyManuallySet', 'true'); renderList(); if (step === 2) renderGrid(); @@ -69,7 +74,7 @@ function importData(evt) { const reader = new FileReader(); - reader.onload = function(e) { + reader.onload = async function(e) { try { const data = JSON.parse(e.target.result); @@ -86,11 +91,15 @@ function importData(evt) { let replaceExisting = true; if (subs.length > 0) { - replaceExisting = confirm( - "You have " + subs.length + " existing subscription(s).\n\n" + - "Click OK to replace them with " + data.subscriptions.length + " imported subscription(s).\n\n" + - "Click Cancel to merge (add imported to existing)." + const result = await showConfirmAlert( + 'Import Subscriptions', + `You have ${subs.length} existing subscription(s).

+ Do you want to replace them with ${data.subscriptions.length} imported subscription(s)?

+ Click No to merge (add imported to existing).`, + 'Replace', + 'Merge' ); + replaceExisting = result.isConfirmed; } if (replaceExisting || subs.length === 0) { @@ -116,10 +125,10 @@ function importData(evt) { save(); closeSettings(); - alert("Successfully imported " + data.subscriptions.length + " subscription(s)!"); + await showSuccessAlert('Success!', `Successfully imported ${data.subscriptions.length} subscription(s)!`); } catch (err) { - alert("Failed to import: " + err.message); + await showErrorAlert('Import Failed', err.message); } }; diff --git a/js/sweetalert-config.js b/js/sweetalert-config.js new file mode 100644 index 0000000..25cad11 --- /dev/null +++ b/js/sweetalert-config.js @@ -0,0 +1,109 @@ +// SweetAlert2 Configuration and Utilities + +// Configure SweetAlert2 defaults with dark mode support +function configureSweetAlert() { + const isDark = document.documentElement.classList.contains('dark'); + + Swal.mixin({ + customClass: { + popup: isDark ? 'dark-popup' : '', + title: isDark ? 'dark-title' : '', + htmlContainer: isDark ? 'dark-content' : '' + }, + background: isDark ? '#1e293b' : '#ffffff', + color: isDark ? '#f1f5f9' : '#0f172a', + confirmButtonColor: '#6366F1', + cancelButtonColor: isDark ? '#475569' : '#94a3b8', + showClass: { + popup: 'animate__animated animate__fadeIn animate__faster' + }, + hideClass: { + popup: 'animate__animated animate__fadeOut animate__faster' + } + }); +} + +// Initialize on load +configureSweetAlert(); + +// Re-configure when theme changes +document.addEventListener('themeChanged', configureSweetAlert); + +// Utility functions for common alerts +window.showSuccessAlert = function(title, text) { + const isDark = document.documentElement.classList.contains('dark'); + return Swal.fire({ + title: title, + text: text, + icon: 'success', + confirmButtonText: 'OK', + background: isDark ? '#1e293b' : '#ffffff', + color: isDark ? '#f1f5f9' : '#0f172a', + confirmButtonColor: '#10B981' + }); +}; + +window.showErrorAlert = function(title, text) { + const isDark = document.documentElement.classList.contains('dark'); + return Swal.fire({ + title: title, + text: text, + icon: 'error', + confirmButtonText: 'OK', + background: isDark ? '#1e293b' : '#ffffff', + color: isDark ? '#f1f5f9' : '#0f172a', + confirmButtonColor: '#EF4444' + }); +}; + +window.showConfirmAlert = function(title, text, confirmButtonText = 'Yes', cancelButtonText = 'No') { + const isDark = document.documentElement.classList.contains('dark'); + return Swal.fire({ + title: title, + html: text, + icon: 'warning', + showCancelButton: true, + confirmButtonText: confirmButtonText, + cancelButtonText: cancelButtonText, + background: isDark ? '#1e293b' : '#ffffff', + color: isDark ? '#f1f5f9' : '#0f172a', + confirmButtonColor: '#6366F1', + cancelButtonColor: isDark ? '#475569' : '#94a3b8', + reverseButtons: true + }); +}; + +window.showInfoAlert = function(title, text) { + const isDark = document.documentElement.classList.contains('dark'); + return Swal.fire({ + title: title, + html: text, + icon: 'info', + confirmButtonText: 'OK', + background: isDark ? '#1e293b' : '#ffffff', + color: isDark ? '#f1f5f9' : '#0f172a', + confirmButtonColor: '#6366F1' + }); +}; + +window.showToast = function(message, icon = 'success') { + const isDark = document.documentElement.classList.contains('dark'); + const Toast = Swal.mixin({ + toast: true, + position: 'top-end', + showConfirmButton: false, + timer: 3000, + timerProgressBar: true, + background: isDark ? '#1e293b' : '#ffffff', + color: isDark ? '#f1f5f9' : '#0f172a', + didOpen: (toast) => { + toast.addEventListener('mouseenter', Swal.stopTimer); + toast.addEventListener('mouseleave', Swal.resumeTimer); + } + }); + + Toast.fire({ + icon: icon, + title: message + }); +}; diff --git a/js/theme.js b/js/theme.js new file mode 100644 index 0000000..92c7917 --- /dev/null +++ b/js/theme.js @@ -0,0 +1,138 @@ +// Theme Management System +// Supports: auto (system), light, dark modes + +const THEME_KEY = 'vexly_theme_preference'; +const THEME_AUTO = 'auto'; +const THEME_LIGHT = 'light'; +const THEME_DARK = 'dark'; + +let currentTheme = THEME_AUTO; +let systemTheme = THEME_LIGHT; + +// Initialize theme system +function initTheme() { + // Load saved preference + const saved = localStorage.getItem(THEME_KEY); + currentTheme = saved || THEME_AUTO; + + // Detect system preference + updateSystemTheme(); + + // Apply theme + applyTheme(); + + // Listen for system theme changes + if (window.matchMedia) { + window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => { + updateSystemTheme(); + if (currentTheme === THEME_AUTO) { + applyTheme(); + } + }); + } + + // Update UI + updateThemeUI(); +} + +// Detect system theme preference +function updateSystemTheme() { + if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) { + systemTheme = THEME_DARK; + } else { + systemTheme = THEME_LIGHT; + } +} + +// Apply theme to document +function applyTheme() { + const effectiveTheme = currentTheme === THEME_AUTO ? systemTheme : currentTheme; + + if (effectiveTheme === THEME_DARK) { + document.documentElement.classList.add('dark'); + document.documentElement.setAttribute('data-theme', 'dark'); + updateMetaThemeColor('#0f172a'); // Dark background + } else { + document.documentElement.classList.remove('dark'); + document.documentElement.setAttribute('data-theme', 'light'); + updateMetaThemeColor('#6366F1'); // Indigo theme color + } +} + +// Update meta theme-color tag for mobile browsers +function updateMetaThemeColor(color) { + let meta = document.querySelector('meta[name="theme-color"]'); + if (meta) { + meta.setAttribute('content', color); + } +} + +// Set theme (light, dark, or auto) +function setTheme(theme) { + if (![THEME_AUTO, THEME_LIGHT, THEME_DARK].includes(theme)) { + return; + } + + currentTheme = theme; + localStorage.setItem(THEME_KEY, theme); + applyTheme(); + updateThemeUI(); +} + +// Get current theme setting +function getTheme() { + return currentTheme; +} + +// Get effective theme (resolves auto to light/dark) +function getEffectiveTheme() { + return currentTheme === THEME_AUTO ? systemTheme : currentTheme; +} + +// Update theme toggle UI +function updateThemeUI() { + const buttons = { + auto: document.getElementById('theme-auto'), + light: document.getElementById('theme-light'), + dark: document.getElementById('theme-dark') + }; + + // Update button states + Object.keys(buttons).forEach(key => { + const btn = buttons[key]; + if (btn) { + if (currentTheme === key) { + btn.classList.add('active-theme'); + btn.classList.remove('inactive-theme'); + } else { + btn.classList.remove('active-theme'); + btn.classList.add('inactive-theme'); + } + } + }); + + // Update theme icon in header (if exists) + const themeIcon = document.getElementById('theme-icon'); + if (themeIcon) { + const effectiveTheme = getEffectiveTheme(); + const icons = { + light: 'ph:sun-bold', + dark: 'ph:moon-bold' + }; + themeIcon.setAttribute('data-icon', icons[effectiveTheme] || icons.light); + } +} + +// Toggle between themes (for quick toggle button) +function toggleTheme() { + const effectiveTheme = getEffectiveTheme(); + const newTheme = effectiveTheme === THEME_LIGHT ? THEME_DARK : THEME_LIGHT; + setTheme(newTheme); +} + +// Initialize on load +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', initTheme); +} else { + initTheme(); +} diff --git a/js/treemap.js b/js/treemap.js index ba2f1d0..16c3848 100644 --- a/js/treemap.js +++ b/js/treemap.js @@ -158,12 +158,40 @@ function renderGrid() { const gridEl = document.getElementById("bento-grid"); const totalDisplay = document.getElementById("step-2-total"); const yearlyDisplay = document.getElementById("step-2-yearly"); + const searchSectionGrid = document.getElementById("search-section-grid"); + const searchResultsInfoGrid = document.getElementById("search-results-info-grid"); + + // Use filtered subscriptions if search is active + const displaySubs = (typeof window.searchQueryGrid !== 'undefined' && window.searchQueryGrid) ? window.filteredSubsGrid : subs; let monthlyTotal = 0; const items = []; - for (let i = 0; i < subs.length; i++) { - const sub = subs[i]; + // Show/hide search section + if (subs.length > 0 && searchSectionGrid) { + searchSectionGrid.classList.remove("hidden"); + } else if (searchSectionGrid) { + searchSectionGrid.classList.add("hidden"); + } + + // Show search results info + if (window.searchQueryGrid && searchResultsInfoGrid) { + searchResultsInfoGrid.classList.remove("hidden"); + if (displaySubs.length === 0) { + searchResultsInfoGrid.textContent = `No subscriptions found for "${window.searchQueryGrid}"`; + searchResultsInfoGrid.classList.add("text-red-500", "dark:text-red-400"); + searchResultsInfoGrid.classList.remove("text-slate-500", "dark:text-slate-400"); + } else { + searchResultsInfoGrid.textContent = `Found ${displaySubs.length} of ${subs.length} subscriptions`; + searchResultsInfoGrid.classList.remove("text-red-500", "dark:text-red-400"); + searchResultsInfoGrid.classList.add("text-slate-500", "dark:text-slate-400"); + } + } else if (searchResultsInfoGrid) { + searchResultsInfoGrid.classList.add("hidden"); + } + + for (let i = 0; i < displaySubs.length; i++) { + const sub = displaySubs[i]; const monthlyCost = toMonthly(sub); monthlyTotal += monthlyCost; @@ -185,7 +213,11 @@ function renderGrid() { yearlyDisplay.innerText = formatCurrency(monthlyTotal * 12); if (items.length === 0) { - gridEl.innerHTML = '
Add subscriptions to see visualization
'; + if (window.searchQueryGrid) { + gridEl.innerHTML = '

No subscriptions found

Try a different search term

'; + } else { + gridEl.innerHTML = '
Add subscriptions to see visualization
'; + } return; } diff --git a/logo.svg b/logo.svg new file mode 100644 index 0000000..eb8c441 --- /dev/null +++ b/logo.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + $ + + + + + + + + + + diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..b024899 --- /dev/null +++ b/manifest.json @@ -0,0 +1,59 @@ +{ + "name": "SubGrid - Subscription Cost Visualizer", + "short_name": "SubGrid", + "description": "Visualize your subscription costs with an interactive grid. Track subscriptions, view costs proportionally, and manage your monthly spending.", + "start_url": "/", + "display": "standalone", + "background_color": "#F8F9FB", + "theme_color": "#6366F1", + "orientation": "portrait-primary", + "scope": "/", + "icons": [ + { + "src": "/icons/icon-192x192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any maskable" + }, + { + "src": "/icons/icon-512x512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any maskable" + } + ], + "categories": ["finance", "productivity", "utilities"], + "screenshots": [ + { + "src": "/screenshots/desktop.png", + "sizes": "1280x720", + "type": "image/png", + "form_factor": "wide" + }, + { + "src": "/screenshots/mobile.png", + "sizes": "750x1334", + "type": "image/png", + "form_factor": "narrow" + } + ], + "shortcuts": [ + { + "name": "Add Subscription", + "short_name": "Add", + "description": "Add a new subscription", + "url": "/?action=add", + "icons": [{ "src": "/icons/icon-192x192.png", "sizes": "192x192" }] + } + ], + "share_target": { + "action": "/", + "method": "GET", + "enctype": "application/x-www-form-urlencoded", + "params": { + "title": "name", + "text": "description", + "url": "link" + } + } +} diff --git a/offline.html b/offline.html new file mode 100644 index 0000000..29c3264 --- /dev/null +++ b/offline.html @@ -0,0 +1,167 @@ + + + + + + Offline - SubGrid + + + + + + + + + + + + + + + + + + + + +
+
+ +
+
+ +
+
+ + +

You're Offline

+ + +

+ It looks like you've lost your internet connection. Don't worry, your subscription data is safely stored on your device! +

+ + +
+
+ + What you can do offline: +
+
    +
  • + + View your saved subscriptions +
  • +
  • + + Add, edit, or delete subscriptions +
  • +
  • + + Generate visualizations from cached data +
  • +
  • + + Update exchange rates (requires internet) +
  • +
+
+ + +
+ + + + + +
+ + +
+

Checking connection status...

+
+
+
+ + + + + + diff --git a/screenshots/README.md b/screenshots/README.md new file mode 100644 index 0000000..efb5a7f --- /dev/null +++ b/screenshots/README.md @@ -0,0 +1,14 @@ +# Screenshots Placeholder + +This directory should contain screenshots for the PWA manifest: + +- `desktop.png` - 1280x720 desktop screenshot +- `mobile.png` - 750x1334 mobile screenshot + +These screenshots will be shown in app stores and install prompts on supported platforms. + +To generate these: +1. Run the app locally +2. Add some sample subscriptions +3. Take screenshots of the grid view +4. Resize and save them here diff --git a/styles.css b/styles.css index fc1d4d9..0108e3a 100644 --- a/styles.css +++ b/styles.css @@ -1,10 +1,92 @@ +/* ============================================ + CSS VARIABLES FOR THEMING + ============================================ */ +:root { + /* Light mode colors */ + --bg-primary: #F8F9FB; + --bg-secondary: #FFFFFF; + --bg-tertiary: #F1F5F9; + + --text-primary: #0f172a; + --text-secondary: #475569; + --text-tertiary: #94a3b8; + + --border-primary: #e2e8f0; + --border-secondary: #cbd5e1; + + --accent-indigo: #6366F1; + --accent-indigo-hover: #4f46e5; + + --shadow-sm: rgba(0, 0, 0, 0.05); + --shadow-md: rgba(0, 0, 0, 0.1); + --shadow-lg: rgba(0, 0, 0, 0.15); + + --scrollbar-thumb: #cbd5e1; + --scrollbar-thumb-hover: #94a3b8; +} + +/* Dark mode colors */ +.dark { + --bg-primary: #0f172a; + --bg-secondary: #1e293b; + --bg-tertiary: #334155; + + --text-primary: #f1f5f9; + --text-secondary: #cbd5e1; + --text-tertiary: #64748b; + + --border-primary: #334155; + --border-secondary: #475569; + + --accent-indigo: #818cf8; + --accent-indigo-hover: #a5b4fc; + + --shadow-sm: rgba(0, 0, 0, 0.3); + --shadow-md: rgba(0, 0, 0, 0.4); + --shadow-lg: rgba(0, 0, 0, 0.5); + + --scrollbar-thumb: #475569; + --scrollbar-thumb-hover: #64748b; +} + +/* Smooth theme transitions */ +* { + transition-property: background-color, border-color, color, fill, stroke; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + transition-duration: 150ms; +} + +/* Exclude animations and transforms from theme transition */ +*:not(.step-panel):not(.treemap-cell):not(.treemap-cell-inner):not(.beeswarm-dot):not(.circlepack-bubble) { + transition-property: background-color, border-color, color, fill, stroke; +} + +/* Slide-in animation for notifications */ +@keyframes slide-in { + from { + opacity: 0; + transform: translateX(100%); + } + to { + opacity: 1; + transform: translateX(0); + } +} + +.animate-slide-in { + animation: slide-in 0.3s ease-out; +} + /* custom scrollbar - webkit only but whatever */ ::-webkit-scrollbar { width: 6px } ::-webkit-scrollbar-track { background: transparent } ::-webkit-scrollbar-thumb { - background-color: #cbd5e1; + background-color: var(--scrollbar-thumb); border-radius: 20px; } +::-webkit-scrollbar-thumb:hover { + background-color: var(--scrollbar-thumb-hover); +} /* hide number input spinners */ input[type="number"]::-webkit-inner-spin-button, @@ -119,3 +201,38 @@ input[type="number"]::-webkit-outer-spin-button { .circlepack-bubble.active .circlepack-tooltip { opacity: 1; } + +/* Theme toggle buttons */ +.active-theme { + border-color: #6366F1 !important; + background-color: #EEF2FF !important; +} + +.dark .active-theme { + border-color: #818cf8 !important; + background-color: #312e81 !important; +} + +.active-theme .iconify { + color: #6366F1 !important; +} + +.dark .active-theme .iconify { + color: #a5b4fc !important; +} + +.active-theme span:last-child { + color: #4f46e5 !important; +} + +.dark .active-theme span:last-child { + color: #c7d2fe !important; +} + +.inactive-theme { + opacity: 0.6; +} + +.inactive-theme:hover { + opacity: 1; +} diff --git a/sw.js b/sw.js new file mode 100644 index 0000000..f7a4aa0 --- /dev/null +++ b/sw.js @@ -0,0 +1,222 @@ +const CACHE_VERSION = 'subgrid-v1.3.4'; +const CACHE_NAME = `${CACHE_VERSION}`; +const OFFLINE_URL = '/offline.html'; + +// Assets to cache immediately on install +const PRECACHE_ASSETS = [ + '/', + '/index.html', + '/offline.html', + '/styles.css', + '/manifest.json', + '/js/app.js', + '/js/storage.js', + '/js/rates.js', + '/js/treemap.js', + '/js/beeswarm.js', + '/js/circlepack.js', + '/js/modals.js', + '/js/presets.js', + '/js/bank-import.js', + '/js/theme.js', + '/js/geolocation.js', + '/js/clear-data.js', + '/js/sweetalert-config.js', + '/icons/favicon-16x16.png', + '/icons/favicon-32x32.png', + '/icons/icon-192x192.png', + '/icons/icon-512x512.png', + '/icons/apple-touch-icon.png' +]; + +// CDN resources to cache on first use +const CDN_RESOURCES = [ + 'https://cdn.tailwindcss.com', + 'https://code.iconify.design/3/3.1.1/iconify.min.js', + 'https://cdn.jsdelivr.net/npm/modern-screenshot@4.6.7/dist/index.js', + 'https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&display=swap', + 'https://cdnjs.cloudflare.com/ajax/libs/sweetalert2/11.23.0/sweetalert2.min.css', + 'https://cdnjs.cloudflare.com/ajax/libs/sweetalert2/11.23.0/sweetalert2.min.js' +]; + +// Install event - cache core assets +self.addEventListener('install', (event) => { + event.waitUntil( + caches.open(CACHE_NAME).then((cache) => { + // Cache offline page first (critical) + return cache.add(OFFLINE_URL).then(() => { + // Then cache other assets, but don't fail if some are missing + return Promise.allSettled( + PRECACHE_ASSETS.map(url => + cache.add(url).catch(err => { + console.warn(`[ServiceWorker] Failed to cache ${url}:`, err); + }) + ) + ); + }); + }).then(() => { + return self.skipWaiting(); + }) + ); +}); + +// Activate event - clean up old caches +self.addEventListener('activate', (event) => { + event.waitUntil( + caches.keys().then((cacheNames) => { + return Promise.all( + cacheNames.map((cacheName) => { + if (cacheName !== CACHE_NAME) { + return caches.delete(cacheName); + } + }) + ); + }).then(() => { + return self.clients.claim(); + }) + ); +}); + +// Fetch event - serve from cache, fallback to network +self.addEventListener('fetch', (event) => { + const { request } = event; + const url = new URL(request.url); + + // Skip non-GET requests + if (request.method !== 'GET') { + return; + } + + // Handle API calls (exchange rates) - Network first, then cache + if (url.origin === 'https://open.er-api.com') { + event.respondWith( + networkFirstStrategy(request, CACHE_NAME) + ); + return; + } + + // Handle geolocation APIs - Network first with long cache + if (url.origin === 'https://ipapi.co' || url.origin === 'https://restcountries.com') { + event.respondWith( + networkFirstStrategy(request, CACHE_NAME) + ); + return; + } + + // Handle CDN resources - Cache first, then network + if (CDN_RESOURCES.some(cdn => url.href.startsWith(cdn))) { + event.respondWith( + cacheFirstStrategy(request, CACHE_NAME) + ); + return; + } + + // Handle navigation requests (HTML pages) + if (request.mode === 'navigate') { + event.respondWith( + cacheFirstStrategy(request, CACHE_NAME) + .catch(() => { + // If both cache and network fail, show offline page + return caches.match(OFFLINE_URL); + }) + ); + return; + } + + // Default: Cache first for all other requests + event.respondWith( + cacheFirstStrategy(request, CACHE_NAME) + ); +}); + +// Cache-first strategy: Try cache, fallback to network, then cache response +async function cacheFirstStrategy(request, cacheName) { + const cache = await caches.open(cacheName); + const cached = await cache.match(request); + + if (cached) { + return cached; + } + + try { + const response = await fetch(request); + + // Cache successful responses + if (response.ok) { + cache.put(request, response.clone()); + } + + return response; + } catch (error) { + console.error('[ServiceWorker] Fetch failed:', error); + throw error; + } +} + +// Network-first strategy: Try network, fallback to cache +async function networkFirstStrategy(request, cacheName) { + const cache = await caches.open(cacheName); + + try { + const response = await fetch(request); + + // Cache successful responses + if (response.ok) { + cache.put(request, response.clone()); + } + + return response; + } catch (error) { + const cached = await cache.match(request); + + if (cached) { + return cached; + } + + throw error; + } +} + +// Handle messages from clients +self.addEventListener('message', (event) => { + if (event.data && event.data.type === 'SKIP_WAITING') { + self.skipWaiting(); + } + + if (event.data && event.data.type === 'CACHE_URLS') { + event.waitUntil( + caches.open(CACHE_NAME).then((cache) => { + return cache.addAll(event.data.urls); + }) + ); + } + + if (event.data && event.data.type === 'CLEAR_CACHE') { + event.waitUntil( + caches.keys().then((cacheNames) => { + return Promise.all( + cacheNames.map((cacheName) => caches.delete(cacheName)) + ); + }) + ); + } +}); + +// Periodic background sync (if supported) +self.addEventListener('sync', (event) => { + if (event.tag === 'sync-rates') { + event.waitUntil(syncExchangeRates()); + } +}); + +async function syncExchangeRates() { + try { + const response = await fetch('https://open.er-api.com/v6/latest/USD'); + if (response.ok) { + const cache = await caches.open(CACHE_NAME); + cache.put('https://open.er-api.com/v6/latest/USD', response); + } + } catch (error) { + console.error('[ServiceWorker] Failed to sync rates:', error); + } +}