Visualizing 24 Hours as an SVG Donut Clock — Midnight-Crossing Blocks and All
How much of your day is work? Sleep? Commute? Everybody has a rough idea; few people have seen it drawn. This tool turns a schedule into a 24-hour donut chart where each category is a colored arc, sleep crossing midnight renders correctly, and you can edit blocks live and see the pie update.
A pie chart of "hours per category" is fine but loses information — you don't see when each activity happens. A 24-hour clock face shows both allocation AND timing. Sleep from 23:00 to 7:00 should be a single arc that wraps around the top of the clock, not two disconnected slices.
🔗 Live demo: https://sen.ltd/portfolio/lifework-clock/
📦 GitHub: https://github.com/sen-ltd/lifework-clock
Features:
- SVG donut chart with 24-hour layout
- 9 categories (Sleep, Work, Commute, Exercise, Meals, Hobby, Family, Self-care, Other)
- Midnight-crossing blocks
- Weekday / weekend schedules
- 3 presets (office worker, remote dev, student)
- Validation (overlaps, gaps, total hours)
- PNG / JSON export
- Japanese / English UI
- Zero dependencies, 50 tests
The angle math
24 hours = 360°, so 1 hour = 15°. Midnight is at the top (12 o'clock position on the clock face), which in SVG coordinates means angle 0 points up (-y direction):
export function hourToAngle(hour) {
return (hour / 24) * 360;
}
export function polarToCartesian(cx, cy, r, angleDegrees) {
// -90° rotation so that 0° is at the top
const rad = ((angleDegrees - 90) * Math.PI) / 180;
return {
x: cx + r * Math.cos(rad),
y: cy + r * Math.sin(rad),
};
}
The -90 offset maps 0° to the top of the clock. Without it, 0° would be at the right (east), which is the standard math convention but wrong for a clock face.
Arc path generation
SVG arc syntax takes some getting used to. For an arc from startAngle to endAngle on a circle of radius r centered at (cx, cy):
export function describeArc(cx, cy, r, startAngle, endAngle) {
const start = polarToCartesian(cx, cy, r, startAngle);
const end = polarToCartesian(cx, cy, r, endAngle);
const largeArc = endAngle - startAngle > 180 ? 1 : 0;
return `M ${cx},${cy} L ${start.x},${start.y} A ${r},${r} 0 ${largeArc},1 ${end.x},${end.y} Z`;
}
The largeArc flag tells SVG whether to take the long way around (1) or the short way (0). For arcs under 180°, short; for arcs over 180°, long. Without this, a 300° arc would be drawn as a 60° arc in the wrong direction.
Midnight-crossing blocks
A block with start = 23, end = 7 crosses midnight. The cleanest representation is to keep it as a single block and handle the wraparound at render time:
export function blockDuration(block) {
if (block.start === block.end) return 0;
if (block.end > block.start) return block.end - block.start;
// Wraps through midnight
return (24 - block.start) + block.end;
}
For rendering, split into two arcs: 23→24 and 0→7. For stats, just use the duration.
Validation
A good schedule totals exactly 24 hours with no overlaps:
export function validateSchedule(blocks) {
const totalHours = blocks.reduce((sum, b) => sum + blockDuration(b), 0);
const overlaps = detectOverlaps(blocks);
const gaps = getUncovered(blocks);
return {
valid: totalHours === 24 && overlaps.length === 0,
errors: [
...overlaps.map(([a, b]) => `Overlap: ${a.label} and ${b.label}`),
...gaps.map(([s, e]) => `Gap: ${s}-${e}`),
],
totalHours,
};
}
A red warning banner appears if validation fails. Presets always validate clean (they're tested).
Presets
Three starting points:
- Office worker: sleep 23-7, commute 7-8, work 9-18, ...
- Remote dev: sleep 0-8, work 9-19, exercise 19-20, ...
- Student: sleep 0-7, study 9-16, hobby 20-22, ...
All preset schedules are validated by tests — so if you load a preset, you always get a valid starting point to customize from.
Series
This is entry #66 in my 100+ public portfolio series.
- 📦 Repo: https://github.com/sen-ltd/lifework-clock
- 🌐 Live: https://sen.ltd/portfolio/lifework-clock/
- 🏢 Company: https://sen.ltd/

Top comments (0)