Forked from noTron-Trebor/subscriptions_tracker.html
Created
December 28, 2025 14:53
-
-
Save krazy-glue/776f8b63bf9c6d2334fb2d4fa6c7b5ca to your computer and use it in GitHub Desktop.
Local Subscription Visualizer
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Subscription Tracker</title> | |
| <style> | |
| * { | |
| margin: 0; | |
| padding: 0; | |
| box-sizing: border-box; | |
| } | |
| body { | |
| font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; | |
| background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); | |
| min-height: 100vh; | |
| padding: 20px; | |
| } | |
| .container { | |
| max-width: 1400px; | |
| margin: 0 auto; | |
| display: grid; | |
| grid-template-columns: 350px 1fr; | |
| gap: 30px; | |
| align-items: start; | |
| } | |
| .input-section { | |
| background: white; | |
| padding: 30px; | |
| border-radius: 20px; | |
| box-shadow: 0 10px 40px rgba(0,0,0,0.1); | |
| } | |
| h1 { | |
| color: #333; | |
| margin-bottom: 10px; | |
| font-size: 24px; | |
| display: flex; | |
| align-items: center; | |
| gap: 10px; | |
| } | |
| h1 .emoji { | |
| font-size: 32px; | |
| } | |
| .subtitle { | |
| color: #666; | |
| margin-bottom: 25px; | |
| font-size: 13px; | |
| } | |
| .subscription-form { | |
| margin-bottom: 20px; | |
| } | |
| .form-group { | |
| margin-bottom: 15px; | |
| } | |
| label { | |
| display: block; | |
| margin-bottom: 5px; | |
| color: #555; | |
| font-size: 13px; | |
| font-weight: 500; | |
| } | |
| input, select { | |
| width: 100%; | |
| padding: 12px; | |
| border: 2px solid #e0e0e0; | |
| border-radius: 10px; | |
| font-size: 14px; | |
| transition: border-color 0.3s; | |
| } | |
| input:focus, select:focus { | |
| outline: none; | |
| border-color: #667eea; | |
| } | |
| .color-input { | |
| display: flex; | |
| gap: 10px; | |
| } | |
| input[type="color"] { | |
| width: 60px; | |
| height: 45px; | |
| border: none; | |
| cursor: pointer; | |
| } | |
| button { | |
| width: 100%; | |
| padding: 14px; | |
| background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); | |
| color: white; | |
| border: none; | |
| border-radius: 10px; | |
| font-size: 15px; | |
| font-weight: 600; | |
| cursor: pointer; | |
| transition: transform 0.2s, box-shadow 0.2s; | |
| } | |
| button:hover { | |
| transform: translateY(-2px); | |
| box-shadow: 0 5px 20px rgba(102, 126, 234, 0.4); | |
| } | |
| .subscription-list { | |
| margin-top: 20px; | |
| max-height: 300px; | |
| overflow-y: auto; | |
| } | |
| .subscription-item { | |
| background: #f8f9fa; | |
| padding: 12px; | |
| border-radius: 8px; | |
| margin-bottom: 8px; | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| } | |
| .subscription-info { | |
| flex: 1; | |
| } | |
| .subscription-name { | |
| font-weight: 600; | |
| color: #333; | |
| font-size: 14px; | |
| } | |
| .subscription-price { | |
| color: #667eea; | |
| font-weight: 700; | |
| font-size: 14px; | |
| } | |
| .delete-btn { | |
| width: auto; | |
| padding: 6px 12px; | |
| background: #ff4757; | |
| font-size: 12px; | |
| } | |
| .delete-btn:hover { | |
| background: #ff3838; | |
| } | |
| .visualization-section { | |
| background: white; | |
| padding: 30px; | |
| border-radius: 20px; | |
| box-shadow: 0 10px 40px rgba(0,0,0,0.1); | |
| min-height: 600px; | |
| } | |
| .treemap-container { | |
| width: 100%; | |
| min-height: 500px; | |
| height: auto; | |
| position: relative; | |
| margin-bottom: 30px; | |
| background: #f8f9fa; | |
| border-radius: 15px; | |
| overflow: visible; | |
| } | |
| .treemap-box { | |
| position: absolute; | |
| display: flex; | |
| flex-direction: column; | |
| justify-content: space-between; | |
| padding: 20px; | |
| border-radius: 15px; | |
| color: #333; | |
| transition: all 0.3s; | |
| cursor: pointer; | |
| overflow: hidden; | |
| } | |
| .treemap-box:hover { | |
| transform: scale(1.02); | |
| box-shadow: 0 8px 25px rgba(0,0,0,0.15); | |
| z-index: 10; | |
| } | |
| .box-header { | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: flex-start; | |
| } | |
| .box-icon { | |
| font-size: 40px; | |
| line-height: 1; | |
| } | |
| .box-percentage { | |
| background: rgba(255,255,255,0.9); | |
| padding: 6px 12px; | |
| border-radius: 20px; | |
| font-weight: 700; | |
| font-size: 13px; | |
| } | |
| .box-content { | |
| margin-top: auto; | |
| } | |
| .box-name { | |
| font-weight: 700; | |
| font-size: 16px; | |
| margin-bottom: 5px; | |
| } | |
| .box-price { | |
| font-size: 32px; | |
| font-weight: 800; | |
| margin-bottom: 2px; | |
| } | |
| .box-yearly { | |
| font-size: 14px; | |
| opacity: 0.7; | |
| } | |
| .totals { | |
| display: flex; | |
| justify-content: space-between; | |
| gap: 20px; | |
| } | |
| .total-box { | |
| flex: 1; | |
| padding: 25px; | |
| border-radius: 15px; | |
| background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%); | |
| } | |
| .total-label { | |
| font-size: 12px; | |
| text-transform: uppercase; | |
| letter-spacing: 1px; | |
| color: #999; | |
| font-weight: 600; | |
| margin-bottom: 8px; | |
| } | |
| .total-amount { | |
| font-size: 40px; | |
| font-weight: 800; | |
| color: #333; | |
| } | |
| .yearly-amount { | |
| color: #667eea; | |
| } | |
| @media (max-width: 1024px) { | |
| .container { | |
| grid-template-columns: 1fr; | |
| } | |
| } | |
| .emoji-modal { | |
| display: none; | |
| position: fixed; | |
| top: 0; | |
| left: 0; | |
| width: 100%; | |
| height: 100%; | |
| background: rgba(0,0,0,0.7); | |
| z-index: 1000; | |
| justify-content: center; | |
| align-items: center; | |
| } | |
| .emoji-modal-content { | |
| background: white; | |
| padding: 30px; | |
| border-radius: 20px; | |
| max-width: 600px; | |
| width: 90%; | |
| max-height: 80vh; | |
| overflow-y: auto; | |
| } | |
| .emoji-modal h2 { | |
| margin-bottom: 20px; | |
| color: #333; | |
| } | |
| .emoji-grid { | |
| display: grid; | |
| grid-template-columns: repeat(auto-fill, minmax(60px, 1fr)); | |
| gap: 10px; | |
| margin-bottom: 20px; | |
| } | |
| .emoji-option { | |
| font-size: 32px; | |
| padding: 10px; | |
| text-align: center; | |
| cursor: pointer; | |
| border-radius: 10px; | |
| transition: all 0.2s; | |
| background: #f8f9fa; | |
| } | |
| .emoji-option:hover { | |
| background: #667eea; | |
| transform: scale(1.1); | |
| } | |
| .close-modal { | |
| width: 100%; | |
| padding: 12px; | |
| background: #666; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="container"> | |
| <div class="input-section"> | |
| <h1><span class="emoji">๐ฐ</span>Subscription Tracker</h1> | |
| <p class="subtitle">Track and visualize your monthly expenses</p> | |
| <form class="subscription-form" id="subscriptionForm"> | |
| <div class="form-group"> | |
| <label for="name">Subscription Name</label> | |
| <input type="text" id="name" placeholder="e.g., Netflix" required> | |
| </div> | |
| <div class="form-group"> | |
| <label for="price">Monthly Cost ($)</label> | |
| <input type="number" id="price" placeholder="0.00" step="0.01" min="0" required> | |
| </div> | |
| <div class="form-group"> | |
| <label for="icon">Icon (emoji)</label> | |
| <div style="display: flex; gap: 8px;"> | |
| <input type="text" id="icon" placeholder="๐ฌ" maxlength="2" style="flex: 1;"> | |
| <button type="button" id="emojiSearchBtn" style="width: auto; padding: 12px 20px; background: #4CAF50;"> | |
| ๐ | |
| </button> | |
| </div> | |
| </div> | |
| <div class="form-group"> | |
| <label for="color">Color</label> | |
| <div class="color-input"> | |
| <input type="color" id="color" value="#ff6b9d"> | |
| <input type="text" id="colorHex" value="#ff6b9d" readonly style="flex: 1;"> | |
| </div> | |
| </div> | |
| <button type="submit">Add Subscription</button> | |
| <div style="display: flex; gap: 8px; margin-top: 15px;"> | |
| <button type="button" id="saveDataBtn" style="background: #28a745; font-size: 13px; padding: 10px;"> | |
| ๐พ Save Data | |
| </button> | |
| <button type="button" id="loadDataBtn" style="background: #17a2b8; font-size: 13px; padding: 10px;"> | |
| ๐ Load Data | |
| </button> | |
| </div> | |
| <div style="margin-top: 10px;"> | |
| <button type="button" id="saveImageBtn" style="background: #ff6b6b; font-size: 13px; padding: 10px;"> | |
| ๐ธ Save as Image | |
| </button> | |
| </div> | |
| <input type="file" id="fileInput" accept=".json" style="display: none;"> | |
| </form> | |
| <div class="subscription-list" id="subscriptionList"></div> | |
| </div> | |
| <div class="visualization-section"> | |
| <div class="treemap-container" id="treemap"></div> | |
| <div class="totals"> | |
| <div class="total-box"> | |
| <div class="total-label">Total / Month</div> | |
| <div class="total-amount" id="monthlyTotal">$0.00</div> | |
| </div> | |
| <div class="total-box"> | |
| <div class="total-label">Yearly Projection</div> | |
| <div class="total-amount yearly-amount" id="yearlyTotal">$0.00</div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <script> | |
| let subscriptions = []; | |
| const colorInput = document.getElementById('color'); | |
| const colorHex = document.getElementById('colorHex'); | |
| colorInput.addEventListener('input', (e) => { | |
| colorHex.value = e.target.value; | |
| }); | |
| document.getElementById('subscriptionForm').addEventListener('submit', (e) => { | |
| e.preventDefault(); | |
| const name = document.getElementById('name').value; | |
| const price = parseFloat(document.getElementById('price').value); | |
| const icon = document.getElementById('icon').value || '๐ฆ'; | |
| const color = document.getElementById('color').value; | |
| subscriptions.push({ name, price, icon, color }); | |
| updateVisualization(); | |
| updateList(); | |
| e.target.reset(); | |
| document.getElementById('color').value = '#' + Math.floor(Math.random()*16777215).toString(16); | |
| colorHex.value = document.getElementById('color').value; | |
| }); | |
| function deleteSubscription(index) { | |
| subscriptions.splice(index, 1); | |
| updateVisualization(); | |
| updateList(); | |
| } | |
| function updateList() { | |
| const listContainer = document.getElementById('subscriptionList'); | |
| listContainer.innerHTML = ''; | |
| subscriptions.forEach((sub, index) => { | |
| const item = document.createElement('div'); | |
| item.className = 'subscription-item'; | |
| item.innerHTML = ` | |
| <div class="subscription-info"> | |
| <div class="subscription-name">${sub.icon} ${sub.name}</div> | |
| <div class="subscription-price">$${sub.price.toFixed(2)}/mo</div> | |
| </div> | |
| <button class="delete-btn" onclick="deleteSubscription(${index})">Delete</button> | |
| `; | |
| listContainer.appendChild(item); | |
| }); | |
| } | |
| function updateVisualization() { | |
| const treemapContainer = document.getElementById('treemap'); | |
| const monthlyTotalEl = document.getElementById('monthlyTotal'); | |
| const yearlyTotalEl = document.getElementById('yearlyTotal'); | |
| const total = subscriptions.reduce((sum, sub) => sum + sub.price, 0); | |
| monthlyTotalEl.textContent = `$${total.toFixed(2)}`; | |
| yearlyTotalEl.textContent = `$${(total * 12).toFixed(2)}`; | |
| if (subscriptions.length === 0) { | |
| treemapContainer.innerHTML = ` | |
| <div style="padding: 40px; color: #5a67d8; font-size: 15px; line-height: 1.8;"> | |
| <p style="font-size: 18px; font-weight: 600; margin-bottom: 20px; color: #4c51bf;">Add subscriptions to see visualization and totals</p> | |
| <p style="margin-bottom: 15px; color: #555;">Consider tracking different categories separately for better insights. Use the <strong>๐พ Save Data</strong> button to save each category, then start fresh for the next one:</p> | |
| <ul style="margin-left: 20px; margin-bottom: 20px;"> | |
| <li><strong>Video Streaming:</strong> Netflix, Hulu, Disney+, HBO Max, Apple TV+, YouTube Premium, Amazon Prime Video, Paramount+, Peacock</li> | |
| <li><strong>Music & Podcasts:</strong> Spotify, Apple Music, YouTube Music, Tidal, Amazon Music, Audible, podcast subscriptions</li> | |
| <li><strong>Fitness & Wellness:</strong> Gym membership, yoga studio, Peloton, fitness apps, meditation apps, wellness coaching</li> | |
| <li><strong>Gaming:</strong> Xbox Game Pass, PlayStation Plus, Nintendo Online, Steam, Epic Games, gaming subscriptions</li> | |
| <li><strong>Productivity & Work:</strong> Microsoft 365, Google Workspace, Adobe Creative Cloud, Dropbox, Notion, Slack, Zoom</li> | |
| <li><strong>Food & Dining:</strong> Meal delivery services, DoorDash Pass, Uber Eats, restaurant memberships, coffee subscriptions</li> | |
| <li><strong>Transportation:</strong> Uber/Lyft subscriptions, public transit passes, parking, car washes, toll passes</li> | |
| <li><strong>News & Media:</strong> New York Times, Washington Post, Medium, Substack subscriptions, magazine subscriptions</li> | |
| <li><strong>Shopping & Retail:</strong> Amazon Prime, Costco membership, subscription boxes, beauty boxes, clothing rentals</li> | |
| <li><strong>Education & Learning:</strong> Online courses, language learning apps, skill development platforms, audiobook services</li> | |
| <li><strong>Cloud Storage & Backup:</strong> iCloud, Google Drive, Dropbox, OneDrive, backup services</li> | |
| <li><strong>Security & Privacy:</strong> VPN services, password managers, antivirus software, identity protection</li> | |
| </ul> | |
| <p style="font-style: italic; color: #666; font-size: 14px; margin-top: 15px;">๐ก <strong>Pro tip:</strong> Keep each visualization to 8-12 items for the best display. For comprehensive expense tracking with many subscriptions, save separate categories and review them individually!</p> | |
| </div> | |
| `; | |
| return; | |
| } | |
| treemapContainer.innerHTML = ''; | |
| const sortedSubs = [...subscriptions].sort((a, b) => b.price - a.price); | |
| const containerWidth = treemapContainer.offsetWidth; | |
| const containerHeight = Math.max(500, treemapContainer.offsetHeight); | |
| const boxes = sortedSubs.map(sub => ({ | |
| ...sub, | |
| value: sub.price, | |
| percentage: (sub.price / total * 100).toFixed(0) | |
| })); | |
| layoutTreemap(boxes, 0, 0, containerWidth, containerHeight, treemapContainer); | |
| } | |
| function layoutTreemap(items, x, y, width, height, container) { | |
| if (items.length === 0) return; | |
| const gap = 10; | |
| const availableWidth = width - gap; | |
| const availableHeight = height - gap; | |
| layoutSimpleTreemap(items, x + gap/2, y + gap/2, availableWidth, availableHeight, gap, container); | |
| } | |
| function layoutSimpleTreemap(items, x, y, width, height, gap, container) { | |
| if (items.length === 0) return; | |
| const total = items.reduce((sum, item) => sum + item.value, 0); | |
| if (items.length === 1) { | |
| createBox(items[0], x, y, width - gap, height - gap, container); | |
| return; | |
| } | |
| const totalArea = width * height; | |
| const avgArea = totalArea / items.length; | |
| const idealBoxSize = Math.sqrt(avgArea); | |
| const cols = Math.max(1, Math.floor(width / idealBoxSize)); | |
| const rows = Math.ceil(items.length / cols); | |
| const rowHeight = (height - (rows - 1) * gap) / rows; | |
| const neededHeight = rows * rowHeight + (rows - 1) * gap; | |
| if (neededHeight > height) { | |
| container.style.height = `${neededHeight + gap}px`; | |
| } | |
| let currentIndex = 0; | |
| for (let row = 0; row < rows; row++) { | |
| const itemsInRow = Math.min(cols, items.length - currentIndex); | |
| const rowItems = items.slice(currentIndex, currentIndex + itemsInRow); | |
| const rowTotal = rowItems.reduce((sum, it) => sum + it.value, 0); | |
| let currentX = x; | |
| const currentY = y + row * (rowHeight + gap); | |
| rowItems.forEach((item, colIndex) => { | |
| const boxWidth = ((width - (itemsInRow - 1) * gap) * (item.value / rowTotal)) - gap; | |
| const boxHeight = rowHeight - gap; | |
| createBox(item, currentX, currentY, boxWidth, boxHeight, container); | |
| currentX += boxWidth + gap; | |
| }); | |
| currentIndex += itemsInRow; | |
| } | |
| } | |
| function createBox(item, x, y, width, height, container) { | |
| const box = document.createElement('div'); | |
| box.className = 'treemap-box'; | |
| box.style.left = `${x}px`; | |
| box.style.top = `${y}px`; | |
| box.style.width = `${width}px`; | |
| box.style.height = `${height}px`; | |
| const baseColor = item.color; | |
| box.style.background = ` | |
| linear-gradient(135deg, | |
| ${lightenColor(baseColor, 30)} 0%, | |
| ${baseColor} 50%, | |
| ${darkenColor(baseColor, 20)} 100%) | |
| `; | |
| const minDimension = Math.min(width, height); | |
| const iconSize = Math.max(24, Math.min(60, minDimension * 0.2)); | |
| const nameSize = Math.max(12, Math.min(18, minDimension * 0.08)); | |
| const priceSize = Math.max(18, Math.min(36, minDimension * 0.15)); | |
| box.innerHTML = ` | |
| <div class="box-header"> | |
| <div class="box-icon" style="font-size: ${iconSize}px;">${item.icon}</div> | |
| <div class="box-percentage">${item.percentage}%</div> | |
| </div> | |
| <div class="box-content"> | |
| <div class="box-name" style="font-size: ${nameSize}px;">${item.name}</div> | |
| <div class="box-price" style="font-size: ${priceSize}px;">$${item.price.toFixed(2)}</div> | |
| <div class="box-yearly">~$${(item.price * 12).toFixed(2)}/yr</div> | |
| </div> | |
| `; | |
| container.appendChild(box); | |
| } | |
| function lightenColor(color, percent) { | |
| const num = parseInt(color.replace("#",""), 16); | |
| const amt = Math.round(2.55 * percent); | |
| const R = Math.min(255, (num >> 16) + amt); | |
| const G = Math.min(255, (num >> 8 & 0x00FF) + amt); | |
| const B = Math.min(255, (num & 0x0000FF) + amt); | |
| return "#" + (0x1000000 + R * 0x10000 + G * 0x100 + B).toString(16).slice(1); | |
| } | |
| function darkenColor(color, percent) { | |
| const num = parseInt(color.replace("#",""), 16); | |
| const amt = Math.round(2.55 * percent); | |
| const R = Math.max(0, (num >> 16) - amt); | |
| const G = Math.max(0, (num >> 8 & 0x00FF) - amt); | |
| const B = Math.max(0, (num & 0x0000FF) - amt); | |
| return "#" + (0x1000000 + R * 0x10000 + G * 0x100 + B).toString(16).slice(1); | |
| } | |
| document.getElementById('saveDataBtn').addEventListener('click', () => { | |
| const data = JSON.stringify(subscriptions, null, 2); | |
| const blob = new Blob([data], { type: 'application/json' }); | |
| const url = URL.createObjectURL(blob); | |
| const a = document.createElement('a'); | |
| a.href = url; | |
| a.download = `subscriptions_${new Date().toISOString().split('T')[0]}.json`; | |
| a.click(); | |
| URL.revokeObjectURL(url); | |
| }); | |
| const fileInput = document.getElementById('fileInput'); | |
| document.getElementById('loadDataBtn').addEventListener('click', () => { | |
| fileInput.click(); | |
| }); | |
| fileInput.addEventListener('change', (e) => { | |
| const file = e.target.files[0]; | |
| if (file) { | |
| const reader = new FileReader(); | |
| reader.onload = (event) => { | |
| try { | |
| const data = JSON.parse(event.target.result); | |
| subscriptions = data; | |
| updateVisualization(); | |
| updateList(); | |
| } catch (error) { | |
| alert('Error loading file. Please make sure it\'s a valid subscription data file.'); | |
| } | |
| }; | |
| reader.readAsText(file); | |
| } | |
| fileInput.value = ''; | |
| }); | |
| document.getElementById('saveImageBtn').addEventListener('click', async () => { | |
| const treemapContainer = document.getElementById('treemap'); | |
| if (subscriptions.length === 0) { | |
| alert('Add some subscriptions first!'); | |
| return; | |
| } | |
| const canvas = document.createElement('canvas'); | |
| const ctx = canvas.getContext('2d'); | |
| const rect = treemapContainer.getBoundingClientRect(); | |
| canvas.width = rect.width; | |
| canvas.height = rect.height + 120; | |
| ctx.fillStyle = '#f8f9fa'; | |
| ctx.fillRect(0, 0, canvas.width, canvas.height); | |
| const boxes = treemapContainer.querySelectorAll('.treemap-box'); | |
| boxes.forEach((box, index) => { | |
| const boxRect = box.getBoundingClientRect(); | |
| const containerRect = treemapContainer.getBoundingClientRect(); | |
| const x = boxRect.left - containerRect.left; | |
| const y = boxRect.top - containerRect.top; | |
| const width = boxRect.width; | |
| const height = boxRect.height; | |
| const sub = subscriptions.find(s => s.name === box.querySelector('.box-name').textContent); | |
| const baseColor = sub ? sub.color : '#cccccc'; | |
| ctx.fillStyle = baseColor; | |
| ctx.fillRect(x, y, width, height); | |
| ctx.strokeStyle = baseColor; | |
| ctx.lineWidth = 1; | |
| ctx.strokeRect(x, y, width, height); | |
| const icon = box.querySelector('.box-icon').textContent; | |
| const percentage = box.querySelector('.box-percentage').textContent; | |
| const name = box.querySelector('.box-name').textContent; | |
| const price = box.querySelector('.box-price').textContent; | |
| const yearly = box.querySelector('.box-yearly').textContent; | |
| ctx.fillStyle = '#333'; | |
| ctx.font = `${Math.max(24, Math.min(60, height * 0.2))}px Arial`; | |
| ctx.fillText(icon, x + 20, y + 50); | |
| ctx.fillStyle = 'rgba(255, 255, 255, 0.9)'; | |
| ctx.font = 'bold 13px Arial'; | |
| const percentageWidth = ctx.measureText(percentage).width + 24; | |
| ctx.fillRect(x + width - percentageWidth - 10, y + 10, percentageWidth, 30); | |
| ctx.fillStyle = '#333'; | |
| ctx.fillText(percentage, x + width - percentageWidth + 2, y + 30); | |
| ctx.fillStyle = '#333'; | |
| ctx.font = `bold ${Math.max(12, Math.min(18, height * 0.08))}px Arial`; | |
| ctx.fillText(name, x + 20, y + height - 70); | |
| ctx.font = `bold ${Math.max(18, Math.min(36, height * 0.15))}px Arial`; | |
| ctx.fillText(price, x + 20, y + height - 35); | |
| ctx.fillStyle = 'rgba(51, 51, 51, 0.7)'; | |
| ctx.font = `${Math.max(12, Math.min(14, height * 0.07))}px Arial`; | |
| ctx.fillText(yearly, x + 20, y + height - 15); | |
| }); | |
| const monthlyTotal = subscriptions.reduce((sum, sub) => sum + sub.price, 0); | |
| const yearlyTotal = monthlyTotal * 12; | |
| const totalBoxY = rect.height; | |
| ctx.fillStyle = '#e9ecef'; | |
| const box1Width = canvas.width / 2 - 20; | |
| ctx.fillRect(10, totalBoxY + 10, box1Width, 100); | |
| ctx.fillRect(canvas.width / 2 + 10, totalBoxY + 10, box1Width, 100); | |
| ctx.fillStyle = '#999'; | |
| ctx.font = 'bold 11px Arial'; | |
| ctx.fillText('TOTAL / MONTH', 30, totalBoxY + 35); | |
| ctx.fillText('YEARLY PROJECTION', canvas.width / 2 + 30, totalBoxY + 35); | |
| ctx.fillStyle = '#333'; | |
| ctx.font = 'bold 36px Arial'; | |
| ctx.fillText(`$${monthlyTotal.toFixed(2)}`, 30, totalBoxY + 75); | |
| ctx.fillStyle = '#667eea'; | |
| ctx.fillText(`$${yearlyTotal.toFixed(2)}`, canvas.width / 2 + 30, totalBoxY + 75); | |
| canvas.toBlob((blob) => { | |
| const url = URL.createObjectURL(blob); | |
| const a = document.createElement('a'); | |
| a.href = url; | |
| a.download = `subscription-tracker_${new Date().toISOString().split('T')[0]}.png`; | |
| a.click(); | |
| URL.revokeObjectURL(url); | |
| }); | |
| }); | |
| const emojiSearchBtn = document.getElementById('emojiSearchBtn'); | |
| const nameInput = document.getElementById('name'); | |
| const iconInput = document.getElementById('icon'); | |
| const modal = document.createElement('div'); | |
| modal.className = 'emoji-modal'; | |
| modal.innerHTML = ` | |
| <div class="emoji-modal-content"> | |
| <h2>Select an Emoji</h2> | |
| <input type="text" id="emojiSearchInput" placeholder="Search emojis..." style="width: 100%; margin-bottom: 15px; padding: 12px; border: 2px solid #e0e0e0; border-radius: 10px;"> | |
| <div id="emojiGrid" class="emoji-grid"></div> | |
| <div style="margin-top: 15px; text-align: center;"> | |
| <a id="emojiDbLink" href="#" target="_blank" style="color: #667eea; text-decoration: none; font-size: 14px;">๐ Search more on EmojiDB</a> | |
| </div> | |
| <button class="close-modal" onclick="this.closest('.emoji-modal').style.display='none'">Close</button> | |
| </div> | |
| `; | |
| document.body.appendChild(modal); | |
| const emojiDatabase = { | |
| 'entertainment': ['๐บ', '๐ฌ', '๐ฎ', '๐ต', '๐ง', '๐ป', '๐ญ', '๐ช', '๐จ', '๐ค', '๐ธ', '๐น', '๐บ', '๐ท', '๐ฅ'], | |
| 'streaming': ['๐บ', '๐ฌ', '๐ฅ', '๐น', '๐๏ธ', '๐ฝ๏ธ', '๐ฟ', '๐ญ'], | |
| 'music': ['๐ต', '๐ถ', '๐ง', '๐ค', '๐ธ', '๐น', '๐บ', '๐ท', '๐ฅ', '๐ผ', '๐ป'], | |
| 'fitness': ['๐ช', '๐๏ธ', '๐คธ', '๐ง', '๐', '๐ด', 'โน๏ธ', '๐คพ', '๐', '๐ง', 'โฝ', '๐', '๐พ', '๐', '๐ฅ'], | |
| 'food': ['๐', '๐', '๐', '๐ฎ', '๐ฏ', '๐ฅ', '๐', '๐', '๐ฑ', '๐', '๐ฒ', '๐ฅ', '๐ณ', '๐ฅ', 'โ', '๐ต'], | |
| 'technology': ['๐ป', '๐ฅ๏ธ', 'โจ๏ธ', '๐ฑ๏ธ', '๐จ๏ธ', '๐ฑ', 'โ๏ธ', '๐', '๐ก', '๐', '๐', '๐พ', '๐ฟ', '๐'], | |
| 'business': ['๐ผ', '๐', '๐', '๐', '๐ฐ', '๐ต', '๐ณ', '๐ข', '๐ฆ', '๐', '๐', '๐', '๐', '๐๏ธ'], | |
| 'education': ['๐', '๐', '๐', 'โ๏ธ', '๐', '๐', '๐', '๐', '๐', '๐', '๐', '๐ซ', '๐๏ธ', 'โ๏ธ'], | |
| 'communication': ['๐ง', '๐จ', '๐ฉ', '๐', '๐ฎ', '๐ฌ', '๐ญ', '๐ช', '๐ซ', '๐ฌ', '๐ญ', '๐จ๏ธ', '๐ฏ๏ธ', '๐', 'โ๏ธ'], | |
| 'shopping': ['๐', '๐๏ธ', '๐ณ', '๐ฐ', '๐ต', '๐ด', '๐ถ', '๐ท', '๐ช', '๐ฌ', '๐', '๐'], | |
| 'travel': ['โ๏ธ', '๐', '๐', '๐', '๐', '๐', '๐๏ธ', '๐', '๐', '๐', '๐', '๐ด', '๐ฒ', '๐ต', '๐๏ธ', '๐บ๏ธ', '๐งณ'], | |
| 'tools': ['๐ง', '๐จ', 'โ๏ธ', '๐ ๏ธ', 'โ๏ธ', '๐ฉ', 'โ๏ธ', '๐๏ธ', 'โ๏ธ', '๐ฌ', '๐ญ', '๐ก', '๐งฐ'], | |
| 'security': ['๐', '๐', '๐', '๐', '๐๏ธ', '๐ก๏ธ', '๐จ', 'โ ๏ธ', '๐ฑ'], | |
| 'media': ['๐ท', '๐ธ', '๐น', '๐ฅ', '๐ฝ๏ธ', '๐๏ธ', '๐ป', '๐บ', '๐ผ', '๐ฟ', '๐', '๐พ', '๐ฝ'], | |
| 'gaming': ['๐ฎ', '๐น๏ธ', '๐ฏ', '๐ฒ', '๐', '๐ฐ', '๐งฉ', 'โ ๏ธ', 'โฅ๏ธ', 'โฆ๏ธ', 'โฃ๏ธ'], | |
| 'social': ['๐ฅ', '๐ค', '๐ฌ', '๐ฃ๏ธ', '๐', '๐ค', '๐ช', '๐', '๐', '๐', '๐คณ', '๐ฑ'], | |
| 'news': ['๐ฐ', '๐ก', '๐ป', '๐บ', '๐๏ธ', '๐ข', '๐ฃ', '๐', '๐'], | |
| 'design': ['๐จ', '๐๏ธ', '๐๏ธ', 'โ๏ธ', '๐', '๐', '๐ผ๏ธ', '๐ญ', '๐', 'โจ'], | |
| 'cloud': ['โ๏ธ', 'โ ', '๐ค๏ธ', '๐ฅ๏ธ', '๐ฆ๏ธ', '๐ง๏ธ', '๐พ', '๐ฟ', '๐', '๐๏ธ'], | |
| 'general': ['๐ฆ', '๐', '๐', '๐', '๐', '๐ท๏ธ', '๐ก', '๐', 'โญ', 'โจ', '๐ฏ', '๐ช', '๐ก', '๐ข'] | |
| }; | |
| const allEmojis = []; | |
| Object.keys(emojiDatabase).forEach(category => { | |
| emojiDatabase[category].forEach(emoji => { | |
| if (!allEmojis.find(e => e.emoji === emoji)) { | |
| allEmojis.push({ emoji, category }); | |
| } | |
| }); | |
| }); | |
| function displayEmojis(emojis) { | |
| const emojiGrid = document.getElementById('emojiGrid'); | |
| emojiGrid.innerHTML = ''; | |
| if (emojis.length === 0) { | |
| emojiGrid.innerHTML = '<p style="text-align:center; color:#666; grid-column: 1/-1;">No emojis found</p>'; | |
| return; | |
| } | |
| emojis.forEach(({ emoji }) => { | |
| const option = document.createElement('div'); | |
| option.className = 'emoji-option'; | |
| option.textContent = emoji; | |
| option.onclick = () => { | |
| iconInput.value = emoji; | |
| modal.style.display = 'none'; | |
| }; | |
| emojiGrid.appendChild(option); | |
| }); | |
| } | |
| function findRelevantEmojis(searchTerm) { | |
| const term = searchTerm.toLowerCase().trim(); | |
| if (!term) return allEmojis.slice(0, 50); | |
| const categoryMatch = Object.keys(emojiDatabase).find(cat => | |
| cat.includes(term) || term.includes(cat) | |
| ); | |
| if (categoryMatch) { | |
| return emojiDatabase[categoryMatch].map(emoji => ({ emoji, category: categoryMatch })); | |
| } | |
| const keywords = { | |
| 'netflix': ['streaming', 'entertainment'], | |
| 'spotify': ['music'], | |
| 'apple': ['technology'], | |
| 'amazon': ['shopping'], | |
| 'prime': ['streaming', 'shopping'], | |
| 'youtube': ['streaming', 'entertainment'], | |
| 'gym': ['fitness'], | |
| 'hulu': ['streaming', 'entertainment'], | |
| 'disney': ['streaming', 'entertainment'], | |
| 'google': ['technology', 'cloud'], | |
| 'microsoft': ['technology', 'cloud'], | |
| 'adobe': ['design'], | |
| 'figma': ['design'], | |
| 'slack': ['communication', 'business'], | |
| 'notion': ['business', 'education'], | |
| 'dropbox': ['cloud'], | |
| 'vpn': ['security'], | |
| 'news': ['news'], | |
| 'podcast': ['media', 'music'] | |
| }; | |
| for (let [keyword, categories] of Object.entries(keywords)) { | |
| if (term.includes(keyword) || keyword.includes(term)) { | |
| let results = []; | |
| categories.forEach(cat => { | |
| if (emojiDatabase[cat]) { | |
| results.push(...emojiDatabase[cat].map(emoji => ({ emoji, category: cat }))); | |
| } | |
| }); | |
| return results; | |
| } | |
| } | |
| return emojiDatabase['general'].map(emoji => ({ emoji, category: 'general' })); | |
| } | |
| emojiSearchBtn.addEventListener('click', () => { | |
| const searchTerm = nameInput.value.split(' ')[0] || 'general'; | |
| const emojiDbLink = document.getElementById('emojiDbLink'); | |
| emojiDbLink.href = `https://emojidb.org/${searchTerm.toLowerCase()}-emojis`; | |
| const relevantEmojis = findRelevantEmojis(searchTerm); | |
| displayEmojis(relevantEmojis); | |
| modal.style.display = 'flex'; | |
| }); | |
| document.getElementById('emojiSearchInput').addEventListener('input', (e) => { | |
| const searchValue = e.target.value; | |
| const relevantEmojis = findRelevantEmojis(searchValue); | |
| displayEmojis(relevantEmojis); | |
| }); | |
| modal.addEventListener('click', (e) => { | |
| if (e.target === modal) { | |
| modal.style.display = 'none'; | |
| } | |
| }); | |
| updateVisualization(); | |
| window.addEventListener('resize', () => { | |
| if (subscriptions.length > 0) { | |
| updateVisualization(); | |
| } | |
| }); | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment