add weekends left calculator
This commit is contained in:
parent
427ba433db
commit
dc791ae47c
5 changed files with 477 additions and 0 deletions
|
|
@ -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/)  
|
||||
|
|
|
|||
12
content/lib/weekends_left.md
Normal file
12
content/lib/weekends_left.md
Normal 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() }}
|
||||
168
static/css/weekends-left.css
Normal file
168
static/css/weekends-left.css
Normal 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
255
static/js/weekendsLeft.js
Normal 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);
|
||||
})();
|
||||
41
templates/shortcodes/weekends_left.html
Normal file
41
templates/shortcodes/weekends_left.html
Normal 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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue