diff --git a/content/lib/_index.md b/content/lib/_index.md index e255d32..5ea827f 100644 --- a/content/lib/_index.md +++ b/content/lib/_index.md @@ -6,6 +6,7 @@ title = "lib" [visual thinking](@/lib/visual_thinking.md) [be methodical](@/lib/be_methodical.md) [start your own revolution](@/lib/start_your_own_revolution.md) +[weekends left](@/lib/weekends_left.md) [digital gardening](https://git.posixlycorrect.com/fabian/digital-gardeners) [indieweb](https://https://indieweb.org/) [wow](https://www.cameronsworld.net/) diff --git a/content/lib/weekends_left.md b/content/lib/weekends_left.md new file mode 100644 index 0000000..e1c0cef --- /dev/null +++ b/content/lib/weekends_left.md @@ -0,0 +1,12 @@ ++++ +title = "Weekends Left" +description = "Calculate how many weekends remain in your life" +date = 2026-04-13 + +[extra] +stylesheets = ["css/weekends-left.css"] ++++ + +How many weekends do you have left? Enter your birth year and expected death year to see how many weekends you've used and how many remain. + +{{ weekends_left() }} diff --git a/static/css/weekends-left.css b/static/css/weekends-left.css new file mode 100644 index 0000000..5779e08 --- /dev/null +++ b/static/css/weekends-left.css @@ -0,0 +1,168 @@ +.weekends-left-calculator { + max-width: 800px; + margin: 0 auto; +} + +.weekends-form { + margin-bottom: 2rem; +} + +.form-row { + display: flex; + gap: 1.5rem; + flex-wrap: wrap; +} + +.form-group { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.form-group label { + font-weight: 500; +} + +.form-group input[type="number"] { + padding: 0.5rem; + font-size: 1rem; + border: 1px solid var(--border-color, #ccc); + border-radius: 4px; + background-color: var(--bg-0, #fff); + color: var(--text-color, #000); + width: 120px; +} + +.weekends-results { + margin-top: 1.5rem; +} + +.hero-stat { + margin-bottom: 1.5rem; +} + +.hero-value { + font-size: 4rem; + font-weight: 700; + color: var(--primary-color, #007bff); + line-height: 1; +} + +.hero-label { + display: block; + font-size: 1rem; + color: var(--text-color, #666); + opacity: 0.8; + margin-top: 0.25rem; +} + +.weekends-stat { + font-size: 1.25rem; + margin-bottom: 0.5rem; +} + +.waking-hours-stat { + font-size: 0.9rem; + color: var(--text-color, #666); + opacity: 0.6; + margin-bottom: 1.5rem; +} + +.grid-container { + margin-top: 2rem; + position: relative; +} + +.grid-legend { + display: flex; + gap: 1.5rem; + margin-bottom: 0.75rem; + font-size: 0.85rem; + color: var(--text-color, #666); +} + +.legend-item { + display: flex; + align-items: center; + gap: 0.4rem; +} + +.legend-dot { + width: 10px; + height: 10px; + background-color: var(--primary-color, #007bff); + border-radius: 2px; +} + +.legend-dot.used { + opacity: 0.3; +} + +.weekends-grid { + display: flex; + flex-wrap: wrap; + gap: 2px; +} + +.grid-cell { + width: 6px; + height: 6px; + background-color: var(--primary-color, #007bff); + border-radius: 1px; +} + +.grid-cell.used { + opacity: 0.3; +} + +.grid-cell.current { + outline: 2px solid var(--text-color, #000); + outline-offset: 1px; +} + +.grid-overflow { + display: flex; + align-items: center; + padding: 0.25rem 0.5rem; + font-size: 0.8rem; + color: var(--text-color, #666); + opacity: 0.7; +} + +.grid-milestones { + font-size: 0.8rem; + color: var(--text-color, #666); + opacity: 0.6; + margin-top: 0.5rem; +} + +.download-btn { + margin-top: 1.5rem; + padding: 0.5rem 1rem; + background: var(--primary-color, #007bff); + color: #fff; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 0.9rem; +} + +.download-btn:hover { + opacity: 0.9; +} + +@media (max-width: 600px) { + .form-row { + flex-direction: column; + gap: 1rem; + } + + .hero-value { + font-size: 3rem; + } + + .grid-cell { + width: 5px; + height: 5px; + } +} diff --git a/static/js/weekendsLeft.js b/static/js/weekendsLeft.js new file mode 100644 index 0000000..2d335a1 --- /dev/null +++ b/static/js/weekendsLeft.js @@ -0,0 +1,255 @@ +(function() { + 'use strict'; + + var form = document.getElementById('weekends-form'); + var birthYearInput = document.getElementById('birth-year'); + var deathYearInput = document.getElementById('death-year'); + var resultsDiv = document.getElementById('weekends-results'); + var percentageRemainingEl = document.getElementById('percentage-remaining'); + var usedCountEl = document.getElementById('used-count'); + var totalCountEl = document.getElementById('total-count'); + var wakingHoursEl = document.getElementById('waking-hours'); + var gridEl = document.getElementById('weekends-grid'); + var downloadBtn = document.getElementById('download-btn'); + + var lastCalculation = null; + + function countWeekends(startDate, endDate) { + var count = 0; + var current = new Date(startDate); + current.setHours(0, 0, 0, 0); + + while (current <= endDate) { + if (current.getDay() === 6) { + count++; + } + current.setDate(current.getDate() + 1); + } + return count; + } + + function formatNumber(num) { + return num.toLocaleString(); + } + + function renderGrid(usedCount, remainingCount, birthYear) { + gridEl.innerHTML = ''; + + var existingMilestones = document.querySelector('.grid-milestones'); + if (existingMilestones) { + existingMilestones.remove(); + } + + var total = usedCount + remainingCount; + var maxCells = 5000; + var scaled = total > maxCells; + + var currentCellIndex; + var cellCount; + + if (scaled) { + var scaleFactor = maxCells / total; + var usedToRender = Math.round(usedCount * scaleFactor); + var remainingToRender = maxCells - usedToRender; + currentCellIndex = usedToRender > 0 ? usedToRender - 1 : 0; + cellCount = maxCells; + + for (var i = 0; i < usedToRender; i++) { + var cell = document.createElement('div'); + cell.className = 'grid-cell used'; + if (i === currentCellIndex) { + cell.className += ' current'; + } + gridEl.appendChild(cell); + } + + for (var j = 0; j < remainingToRender; j++) { + var cell = document.createElement('div'); + cell.className = 'grid-cell'; + gridEl.appendChild(cell); + } + + var overflow = document.createElement('div'); + overflow.className = 'grid-overflow'; + overflow.textContent = '(' + formatNumber(total) + ' total weekends)'; + gridEl.appendChild(overflow); + } else { + currentCellIndex = usedCount > 0 ? usedCount - 1 : 0; + cellCount = total; + + for (var i = 0; i < usedCount; i++) { + var cell = document.createElement('div'); + cell.className = 'grid-cell used'; + if (i === currentCellIndex) { + cell.className += ' current'; + } + gridEl.appendChild(cell); + } + + for (var j = 0; j < remainingCount; j++) { + var cell = document.createElement('div'); + cell.className = 'grid-cell'; + gridEl.appendChild(cell); + } + } + + var milestones = [20, 40, 60, 80]; + var milestoneText = milestones.map(function(age) { + return 'Age ' + age + ': ' + (birthYear + age); + }).join(' · '); + + var milestonesEl = document.createElement('div'); + milestonesEl.className = 'grid-milestones'; + milestonesEl.textContent = milestoneText; + gridEl.parentNode.appendChild(milestonesEl); + } + + function generateImage() { + if (!lastCalculation) return; + + var fontUrl = '/fonts/JetBrainsMonoNLNerdFont-Regular.ttf'; + var font = new FontFace('JetBrainsMono', 'url(' + fontUrl + ')'); + + font.load().then(function(loadedFont) { + document.fonts.add(loadedFont); + renderImage(); + }).catch(function() { + renderImage(); + }); + + function renderImage() { + var data = lastCalculation; + var canvasWidth = 800; + var cellSize = 6; + var cellGap = 2; + var padding = 30; + var headerHeight = 100; + var footerHeight = 60; + + var total = data.usedCount + data.remainingCount; + var maxCells = 5000; + var scaled = total > maxCells; + var cellCount = scaled ? maxCells : total; + + var cellsPerRow = Math.floor((canvasWidth - 2 * padding) / (cellSize + cellGap)); + var rows = Math.ceil(cellCount / cellsPerRow); + var gridHeight = rows * (cellSize + cellGap); + var canvasHeight = headerHeight + gridHeight + footerHeight + padding; + + var canvas = document.createElement('canvas'); + canvas.width = canvasWidth; + canvas.height = canvasHeight; + var ctx = canvas.getContext('2d'); + + var fontFamily = 'JetBrainsMono, monospace'; + var bgColor = '#131a21'; + var textColor = getComputedStyle(document.documentElement).getPropertyValue('--text-color').trim() || '#333333'; + var primaryColor = getComputedStyle(document.documentElement).getPropertyValue('--primary-color').trim() || '#007bff'; + + ctx.fillStyle = bgColor; + ctx.fillRect(0, 0, canvasWidth, canvasHeight); + + ctx.fillStyle = primaryColor; + ctx.font = 'bold 36px ' + fontFamily; + ctx.fillText(data.percentageRemaining + '% weekends remaining', padding, padding + 30); + + ctx.fillStyle = textColor; + ctx.font = '14px ' + fontFamily; + var summary = formatNumber(data.usedCount) + ' / ' + formatNumber(data.totalCount) + + ' weekends · ' + formatNumber(data.wakingHours) + ' waking hours remaining (assuming 8h sleep on saturdays)'; + ctx.fillText(summary, padding, padding + 55); + + var scaleFactor = scaled ? maxCells / total : 1; + var usedToRender = scaled ? Math.round(data.usedCount * scaleFactor) : data.usedCount; + var currentCellIndex = usedToRender > 0 ? usedToRender - 1 : 0; + + for (var i = 0; i < cellCount; i++) { + var col = i % cellsPerRow; + var row = Math.floor(i / cellsPerRow); + var x = padding + col * (cellSize + cellGap); + var y = headerHeight + row * (cellSize + cellGap); + + var isUsed = i < usedToRender; + ctx.fillStyle = primaryColor; + ctx.globalAlpha = isUsed ? 0.3 : 1; + ctx.beginPath(); + ctx.roundRect(x, y, cellSize, cellSize, 1); + ctx.fill(); + ctx.globalAlpha = 1; + + if (i === currentCellIndex) { + ctx.strokeStyle = textColor; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.roundRect(x - 2, y - 2, cellSize + 4, cellSize + 4, 2); + ctx.stroke(); + } + } + + ctx.fillStyle = textColor; + ctx.font = '20px ' + fontFamily; + var subtitle = 'posixlycorrect.com/lib/weekends-left'; + ctx.fillText(subtitle, padding, canvasHeight - 20); + + var link = document.createElement('a'); + link.download = 'weekends-left.png'; + link.href = canvas.toDataURL('image/png'); + link.click(); + } + } + + function calculate() { + var birthYear = parseInt(birthYearInput.value, 10); + var deathYear = parseInt(deathYearInput.value, 10); + + if (!birthYear || !deathYear) { + resultsDiv.style.display = 'none'; + return; + } + + if (deathYear <= birthYear) { + resultsDiv.style.display = 'none'; + return; + } + + var birthDate = new Date(birthYear, 0, 1); + var deathDate = new Date(deathYear, 11, 31); + var today = new Date(); + today.setHours(0, 0, 0, 0); + + var usedWeekends = countWeekends(birthDate, today); + var remainingWeekends = countWeekends(today, deathDate); + var totalWeekends = usedWeekends + remainingWeekends; + + var percentage = Math.round((usedWeekends / totalWeekends) * 100); + var wakingHours = remainingWeekends * 40; + + var percentageRemaining = 100 - percentage; + + lastCalculation = { + usedCount: usedWeekends, + remainingCount: remainingWeekends, + totalCount: totalWeekends, + percentage: percentage, + percentageRemaining: percentageRemaining, + wakingHours: wakingHours, + birthDate: birthDate + }; + + percentageRemainingEl.textContent = percentageRemaining + '%'; + usedCountEl.textContent = formatNumber(usedWeekends); + totalCountEl.textContent = formatNumber(totalWeekends); + wakingHoursEl.textContent = formatNumber(wakingHours) + ' waking hours remaining, assuming 8h of sleep on saturdays'; + + renderGrid(usedWeekends, remainingWeekends, birthYear); + resultsDiv.style.display = 'block'; + } + + birthYearInput.addEventListener('input', calculate); + deathYearInput.addEventListener('input', calculate); + form.addEventListener('submit', function(e) { + e.preventDefault(); + calculate(); + }); + downloadBtn.addEventListener('click', generateImage); +})(); diff --git a/templates/shortcodes/weekends_left.html b/templates/shortcodes/weekends_left.html new file mode 100644 index 0000000..6962aea --- /dev/null +++ b/templates/shortcodes/weekends_left.html @@ -0,0 +1,41 @@ +