Skip to content

Instantly share code, notes, and snippets.

@sbamin
Last active December 30, 2025 04:42
Show Gist options
  • Select an option

  • Save sbamin/6f11a4ffd036412992394383b67f110a to your computer and use it in GitHub Desktop.

Select an option

Save sbamin/6f11a4ffd036412992394383b67f110a to your computer and use it in GitHub Desktop.
Life in Weeks chart by Tim Urban - customizable, preview: https://gistpreview.github.io/?6f11a4ffd036412992394383b67f110a/life_in_weeks.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Life in Weeks Chart</title>
<style>
:root {
--bg: #ffffff;
--text: #111827;
--muted: #6b7280;
--stroke: #c7cdd6;
--fill-on: #4682b4; /* steelblue */
--fill-off: #ffffff;
}
body {
margin: 0;
background: var(--bg);
color: var(--text);
font: 14px/1.4 system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif;
}
header {
padding: 16px 18px 8px;
}
h1 {
font-size: 16px;
margin: 0 0 4px;
font-weight: 600;
}
#subtitle {
color: var(--muted);
margin: 0;
}
.controls {
display: flex;
align-items: center;
gap: 10px;
margin-top: 10px;
color: var(--muted);
user-select: none;
}
.config {
display: grid;
grid-template-columns: 120px 1fr;
gap: 8px 12px;
margin-top: 12px;
max-width: 860px;
color: var(--muted);
}
.config label {
font-size: 12px;
font-weight: 600;
align-self: center;
}
.config input[type="number"],
.config textarea {
font: 12px/1.3 system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif;
color: var(--text);
border: 1px solid #d1d5db;
border-radius: 8px;
padding: 8px 10px;
background: #ffffff;
}
.config textarea {
min-height: 92px;
resize: vertical;
}
.config .row {
grid-column: 2;
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
}
button {
appearance: none;
border: 1px solid #d1d5db;
background: #ffffff;
color: var(--text);
padding: 7px 10px;
border-radius: 8px;
font: 12px/1.2 system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif;
cursor: pointer;
}
button:hover {
background: #f9fafb;
}
.hint {
font-size: 12px;
color: var(--muted);
}
.switch {
position: relative;
display: inline-block;
width: 44px;
height: 24px;
}
.switch input {
opacity: 0;
width: 0;
height: 0;
}
.slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #e5e7eb;
border: 1px solid #d1d5db;
transition: 120ms;
border-radius: 999px;
}
.slider:before {
position: absolute;
content: "";
height: 18px;
width: 18px;
left: 3px;
bottom: 2px;
background-color: white;
transition: 120ms;
border-radius: 999px;
border: 1px solid #d1d5db;
}
input:checked + .slider:before {
transform: translateX(18px);
}
.control-label {
font-size: 12px;
font-weight: 600;
color: var(--muted);
}
main {
padding: 8px 18px 18px;
}
.chart-wrap {
overflow-x: auto;
border-top: 1px solid #eef2f7;
padding-top: 12px;
}
svg {
display: block;
max-width: 100%;
height: auto;
}
.tile {
stroke: var(--stroke);
stroke-width: 0.6;
shape-rendering: crispEdges;
}
/*
Tile fill is set inline in JS so we can:
- color by life-phase (year ranges)
- optionally dim future weeks vs. weeks up to today's week
*/
.axis {
fill: var(--muted);
font-size: 10px;
}
.axis-title {
fill: var(--muted);
font-size: 12px;
font-weight: 600;
}
.phase-label {
font-size: 14px;
font-weight: 600;
}
.note {
color: var(--muted);
font-size: 12px;
margin-top: 10px;
}
</style>
</head>
<body>
<header>
<h1>The life in weeks</h1>
<p id="subtitle">Loading…</p>
<div class="controls">
<span class="control-label">X-axis</span>
<span>Year</span>
<label class="switch" title="Toggle x-axis labels between year and age">
<input id="xAxisToggle" type="checkbox" aria-label="Toggle x-axis labels between year and age" />
<span class="slider"></span>
</label>
<span>Age</span>
</div>
<div class="config" aria-label="Chart options">
<label for="startYearInput">Start year</label>
<input id="startYearInput" type="number" inputmode="numeric" />
<label for="endYearInput">End year</label>
<input id="endYearInput" type="number" inputmode="numeric" />
<label for="phasesInput">Life phases</label>
<textarea id="phasesInput" spellcheck="false" placeholder="One per line: 1985-1999|childhood|#9ecae1"></textarea>
<div class="row">
<button id="applyConfig" type="button">Apply</button>
<button id="resetConfig" type="button">Reset</button>
<span class="hint">Format: <code>start-end|name|#rrggbb</code> (color optional)</span>
</div>
</div>
</header>
<main>
<div class="chart-wrap">
<svg id="chart" role="img" aria-label="Year by week grid"></svg>
</div>
<p class="note">
Each square represents one week in a 100-year lifespan, colored by life phase.
Weeks after today are dimmed.
<br />
Credit: Arjun Raj <a href="https://x.com/arjunrajlab/status/2005738901118636158">@arjunrajlab</a>, Tim Urban <a href="https://waitbutwhy.com/2014/05/life-weeks.html">Your Life in Weeks - Wait But Why</a>
<br />
Vibe coded with GitHub Copilot in VSCode and ChatGPT 5.2.
</p>
</main>
<script>
(function () {
const defaultConfig = {
startYear: 1985,
endYear: 2085,
weeksPerYear: 52,
phases: [
{ name: "👶 🍼 🛝 🏏", start: 1985, end: 1999, color: "#9ecae1" },
{ name: "🏫 📚", start: 2000, end: 2004, color: "#a1d99b" },
{ name: "🧑‍🏫", start: 2005, end: 2015, color: "#fdae6b" },
{ name: "🧑‍🔬 🎯 ⏱️", start: 2016, end: 2050, color: "#bcbddc" },
{ name: "🧘 🧑‍🍳 🏖️", start: 2051, end: 2070, color: "#fee08b" },
{ name: "🧑‍🦯 ⚰️", start: 2071, end: 2085, color: "#d9d9d9" }
],
otherPhase: { name: "other", color: "#e5e7eb" }
};
function toInt(value) {
const n = Number.parseInt(String(value), 10);
return Number.isFinite(n) ? n : null;
}
function clampInt(n, min, max) {
return Math.max(min, Math.min(max, n));
}
function parsePhases(phasesParam) {
if (!phasesParam || !String(phasesParam).trim()) return null;
const items = String(phasesParam)
.split(/\s*(?:;|\n)\s*/)
.map(s => s.trim())
.filter(Boolean);
const parsed = [];
for (const item of items) {
const parts = item.split("|");
if (parts.length < 2) continue;
const range = parts[0].trim();
const name = parts[1].trim();
const color = (parts[2] || "").trim();
const m = range.match(/^(\d{4})\s*-\s*(\d{4})$/);
if (!m) continue;
const start = toInt(m[1]);
const end = toInt(m[2]);
if (start == null || end == null) continue;
if (!name) continue;
parsed.push({
name,
start,
end,
color: color || "#d1d5db"
});
}
return parsed.length ? parsed : null;
}
function phasesToText(phases) {
return (phases || [])
.map(p => {
const name = String(p.name ?? "").trim();
const start = toInt(p.start);
const end = toInt(p.end);
const color = String(p.color ?? "").trim();
if (!name || start == null || end == null) return null;
return `${start}-${end}|${name}${color ? `|${color}` : ""}`;
})
.filter(Boolean)
.join("\n");
}
function buildConfig() {
const url = new URL(window.location.href);
const startFromUrl = toInt(url.searchParams.get("start"));
const endFromUrl = toInt(url.searchParams.get("end"));
const phasesFromUrl = parsePhases(url.searchParams.get("phases"));
const userCfg = (typeof window.LIFE_WEEKS_CONFIG === "object" && window.LIFE_WEEKS_CONFIG) ? window.LIFE_WEEKS_CONFIG : {};
const cfg = {
...defaultConfig,
...userCfg
};
if (startFromUrl != null) cfg.startYear = startFromUrl;
if (endFromUrl != null) cfg.endYear = endFromUrl;
if (phasesFromUrl) cfg.phases = phasesFromUrl;
// Basic validation
cfg.weeksPerYear = cfg.weeksPerYear || 52;
cfg.startYear = toInt(cfg.startYear) ?? defaultConfig.startYear;
cfg.endYear = toInt(cfg.endYear) ?? defaultConfig.endYear;
if (cfg.endYear < cfg.startYear) {
const tmp = cfg.startYear;
cfg.startYear = cfg.endYear;
cfg.endYear = tmp;
}
cfg.weeksPerYear = clampInt(toInt(cfg.weeksPerYear) ?? 52, 1, 52);
cfg.otherPhase = cfg.otherPhase || defaultConfig.otherPhase;
cfg.phases = Array.isArray(cfg.phases) ? cfg.phases : defaultConfig.phases;
// Normalize phases
cfg.phases = cfg.phases
.map(p => ({
name: String(p.name ?? "").trim(),
start: toInt(p.start),
end: toInt(p.end),
color: String(p.color ?? "#d1d5db").trim()
}))
.filter(p => p.name && p.start != null && p.end != null);
return cfg;
}
let stateConfig = buildConfig();
const svg = document.getElementById("chart");
const xAxisToggle = document.getElementById("xAxisToggle");
const startYearInput = document.getElementById("startYearInput");
const endYearInput = document.getElementById("endYearInput");
const phasesInput = document.getElementById("phasesInput");
const applyConfigBtn = document.getElementById("applyConfig");
const resetConfigBtn = document.getElementById("resetConfig");
const tile = 10;
const gap = 1;
// Extra bottom margin prevents clipping of rotated tick labels + axis title.
const margin = { top: 34, right: 10, bottom: 80, left: 44 };
function pad2(n) { return String(n).padStart(2, "0"); }
function formatYMD(d) {
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
}
// ISO week number (1..53). We cap to 52 for this visualization.
function getISOWeek(dateLocal) {
const d = new Date(Date.UTC(dateLocal.getFullYear(), dateLocal.getMonth(), dateLocal.getDate()));
const day = d.getUTCDay() || 7; // 1..7 (Mon..Sun)
d.setUTCDate(d.getUTCDate() + 4 - day); // shift to Thursday
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
const weekNo = Math.ceil((((d - yearStart) / 86400000) + 1) / 7);
return weekNo;
}
function isFilled(year, week, currentYear, currentWeek) {
if (Number.isNaN(currentYear) || Number.isNaN(currentWeek)) return false;
if (currentYear < startYear) return false;
if (currentYear > endYear) return true;
if (year < currentYear) return true;
if (year > currentYear) return false;
return week <= currentWeek;
}
function getPhaseForYear(year) {
for (const p of phases) {
if (year >= p.start && year <= p.end) return p;
}
return otherPhase;
}
const today = new Date();
const currentYear = today.getFullYear();
const ns = "http://www.w3.org/2000/svg";
let setXAxisModeFn = null;
function syncFormFromConfig(cfg) {
if (startYearInput) startYearInput.value = String(cfg.startYear);
if (endYearInput) endYearInput.value = String(cfg.endYear);
if (phasesInput) phasesInput.value = phasesToText(cfg.phases);
}
function configFromForm() {
const start = toInt(startYearInput?.value);
const end = toInt(endYearInput?.value);
const phasesText = phasesInput?.value ?? "";
const parsedPhases = parsePhases(phasesText);
const cfg = {
...stateConfig,
startYear: start ?? stateConfig.startYear,
endYear: end ?? stateConfig.endYear,
phases: parsedPhases ?? stateConfig.phases
};
cfg.startYear = toInt(cfg.startYear) ?? defaultConfig.startYear;
cfg.endYear = toInt(cfg.endYear) ?? defaultConfig.endYear;
if (cfg.endYear < cfg.startYear) {
const tmp = cfg.startYear;
cfg.startYear = cfg.endYear;
cfg.endYear = tmp;
}
cfg.weeksPerYear = clampInt(toInt(cfg.weeksPerYear) ?? defaultConfig.weeksPerYear, 1, 52);
cfg.otherPhase = cfg.otherPhase || defaultConfig.otherPhase;
cfg.phases = Array.isArray(cfg.phases) ? cfg.phases : defaultConfig.phases;
return cfg;
}
function render(cfg) {
const startYear = cfg.startYear;
const endYear = cfg.endYear;
const weeksPerYear = cfg.weeksPerYear;
const phases = cfg.phases;
const otherPhase = cfg.otherPhase;
const nYears = endYear - startYear + 1;
function isFilled(year, week, currentYear, currentWeek) {
if (Number.isNaN(currentYear) || Number.isNaN(currentWeek)) return false;
if (currentYear < startYear) return false;
if (currentYear > endYear) return true;
if (year < currentYear) return true;
if (year > currentYear) return false;
return week <= currentWeek;
}
function getPhaseForYear(year) {
for (const p of phases) {
if (year >= p.start && year <= p.end) return p;
}
return otherPhase;
}
const today = new Date();
const currentYear = today.getFullYear();
const currentWeek = Math.min(getISOWeek(today), weeksPerYear);
document.getElementById("subtitle").textContent = `Today: ${formatYMD(today)} | Week: ${currentWeek}/52`;
// Clear SVG then rebuild
svg.replaceChildren();
const plotW = nYears * tile + (nYears - 1) * gap;
const plotH = weeksPerYear * tile + (weeksPerYear - 1) * gap;
const width = margin.left + plotW + margin.right;
const height = margin.top + plotH + margin.bottom;
svg.setAttribute("viewBox", `0 0 ${width} ${height}`);
svg.setAttribute("width", String(width));
svg.setAttribute("height", String(height));
// X axis title
const xTitle = document.createElementNS(ns, "text");
xTitle.setAttribute("class", "axis-title");
xTitle.setAttribute("x", String(margin.left + plotW / 2));
xTitle.setAttribute("y", String(margin.top + plotH + 62));
xTitle.setAttribute("text-anchor", "middle");
xTitle.textContent = "Year";
svg.appendChild(xTitle);
// Week axis label
const yTitle = document.createElementNS(ns, "text");
yTitle.setAttribute("class", "axis-title");
yTitle.setAttribute("x", "12");
yTitle.setAttribute("y", String(margin.top + plotH / 2));
yTitle.setAttribute("text-anchor", "middle");
yTitle.setAttribute("transform", `rotate(-90 12 ${margin.top + plotH / 2})`);
yTitle.textContent = "Week";
svg.appendChild(yTitle);
// Week ticks
const weekTicks = [1, 13, 26, 39, 52].filter(w => w <= weeksPerYear);
for (const w of weekTicks) {
const t = document.createElementNS(ns, "text");
t.setAttribute("class", "axis");
t.setAttribute("x", String(margin.left - 8));
t.setAttribute("y", String(margin.top + (w - 1) * (tile + gap) + tile * 0.75));
t.setAttribute("text-anchor", "end");
t.textContent = String(w);
svg.appendChild(t);
}
// X tick labels
const xTickEls = [];
for (let y = startYear; y <= endYear; y += 5) {
const col = y - startYear;
const x = margin.left + col * (tile + gap) + tile / 2;
const yPos = margin.top + plotH + 14;
const t = document.createElementNS(ns, "text");
t.setAttribute("class", "axis");
t.setAttribute("x", String(x));
t.setAttribute("y", String(yPos));
t.setAttribute("text-anchor", "middle");
t.setAttribute("transform", `rotate(90 ${x} ${yPos})`);
t.setAttribute("data-year", String(y));
t.textContent = String(y);
svg.appendChild(t);
xTickEls.push(t);
}
function setXAxisMode(mode) {
const isAge = mode === "age";
xTitle.textContent = isAge ? "Age (years)" : "Year";
for (const el of xTickEls) {
const y = Number(el.getAttribute("data-year"));
const age = y - startYear;
el.textContent = isAge ? String(age) : String(y);
}
}
setXAxisModeFn = setXAxisMode;
setXAxisMode(xAxisToggle && xAxisToggle.checked ? "age" : "year");
// Phase labels
for (const phase of phases) {
const a = Math.max(phase.start, startYear);
const b = Math.min(phase.end, endYear);
if (a > b) continue;
const leftX = margin.left + (a - startYear) * (tile + gap);
const rightX = margin.left + (b - startYear) * (tile + gap) + tile;
const cx = (leftX + rightX) / 2;
const cy = 22;
const label = document.createElementNS(ns, "text");
label.setAttribute("class", "phase-label");
label.setAttribute("x", String(cx));
label.setAttribute("y", String(cy));
label.setAttribute("text-anchor", "middle");
label.setAttribute("fill", phase.color);
label.textContent = phase.name;
svg.appendChild(label);
}
// Tiles
const tilesGroup = document.createElementNS(ns, "g");
svg.appendChild(tilesGroup);
for (let year = startYear; year <= endYear; year++) {
const col = year - startYear;
const x = margin.left + col * (tile + gap);
const phase = getPhaseForYear(year);
for (let week = 1; week <= weeksPerYear; week++) {
const row = week - 1;
const y = margin.top + row * (tile + gap);
const filled = isFilled(year, week, currentYear, currentWeek);
const rect = document.createElementNS(ns, "rect");
rect.setAttribute("class", "tile");
rect.setAttribute("x", String(x));
rect.setAttribute("y", String(y));
rect.setAttribute("width", String(tile));
rect.setAttribute("height", String(tile));
rect.setAttribute("fill", phase.color);
rect.setAttribute("fill-opacity", filled ? "1" : "0.18");
rect.setAttribute("data-phase", phase.name);
tilesGroup.appendChild(rect);
}
}
// Border
const border = document.createElementNS(ns, "rect");
border.setAttribute("x", String(margin.left - 0.5));
border.setAttribute("y", String(margin.top - 0.5));
border.setAttribute("width", String(plotW + 1));
border.setAttribute("height", String(plotH + 1));
border.setAttribute("fill", "none");
border.setAttribute("stroke", "#e5e7eb");
svg.appendChild(border);
}
// Hook up toggle once (no duplicate listeners on re-render)
if (xAxisToggle && !xAxisToggle.dataset.bound) {
xAxisToggle.dataset.bound = "1";
xAxisToggle.addEventListener("change", () => {
if (setXAxisModeFn) setXAxisModeFn(xAxisToggle.checked ? "age" : "year");
});
}
// Hook up Apply/Reset once
if (applyConfigBtn && !applyConfigBtn.dataset.bound) {
applyConfigBtn.dataset.bound = "1";
applyConfigBtn.addEventListener("click", () => {
stateConfig = configFromForm();
syncFormFromConfig(stateConfig);
render(stateConfig);
});
}
if (resetConfigBtn && !resetConfigBtn.dataset.bound) {
resetConfigBtn.dataset.bound = "1";
resetConfigBtn.addEventListener("click", () => {
stateConfig = { ...defaultConfig };
syncFormFromConfig(stateConfig);
render(stateConfig);
});
}
// Initial render
syncFormFromConfig(stateConfig);
render(stateConfig);
})();
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment