add weekends left calculator

This commit is contained in:
Fabian Montero 2026-04-13 17:00:56 -06:00
parent 427ba433db
commit dc791ae47c
Signed by: fabian
GPG key ID: 3EDA9AE3937CCDE3
5 changed files with 477 additions and 0 deletions

View file

@ -6,6 +6,7 @@ title = "lib"
[visual thinking](@/lib/visual_thinking.md)   [visual thinking](@/lib/visual_thinking.md)  
[be methodical](@/lib/be_methodical.md)   [be methodical](@/lib/be_methodical.md)  
[start your own revolution](@/lib/start_your_own_revolution.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)   [digital gardening](https://git.posixlycorrect.com/fabian/digital-gardeners)  
[indieweb](https://https://indieweb.org/)   [indieweb](https://https://indieweb.org/)  
[wow](https://www.cameronsworld.net/)   [wow](https://www.cameronsworld.net/)  

View file

@ -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() }}

View file

@ -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;
}
}

255
static/js/weekendsLeft.js Normal file
View file

@ -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);
})();

View file

@ -0,0 +1,41 @@
<div class="weekends-left-calculator">
<form id="weekends-form" class="weekends-form">
<div class="form-row">
<div class="form-group">
<label for="birth-year">Birth year:</label>
<input type="number" id="birth-year" min="1900" max="2100" placeholder="1998">
</div>
<div class="form-group">
<label for="death-year">Death year:</label>
<input type="number" id="death-year" min="1900" max="2200" placeholder="2088">
</div>
</div>
</form>
<div id="weekends-results" class="weekends-results" style="display: none;">
<div class="hero-stat">
<span class="hero-value" id="percentage-remaining">0%</span>
<span class="hero-label">of weekends remaining</span>
</div>
<div class="weekends-stat">
<span id="used-count">0</span> / <span id="total-count">0</span> weekends
</div>
<div class="waking-hours-stat" id="waking-hours">0 waking hours remaining</div>
<div class="grid-container">
<div class="grid-legend">
<span class="legend-item"><span class="legend-dot used"></span> used</span>
<span class="legend-item"><span class="legend-dot"></span> remaining</span>
</div>
<div id="weekends-grid" class="weekends-grid"></div>
</div>
<button type="button" id="download-btn" class="download-btn">Download as image</button>
</div>
<noscript>
<p>This calculator requires JavaScript to function.</p>
</noscript>
</div>
<script src="{{ get_url(path='js/weekendsLeft.js') | safe }}"></script>