VOOZH about

URL: https://dev.to/sendotltd/visualizing-24-hours-as-an-svg-donut-clock-midnight-crossing-blocks-and-all-2l1a

⇱ Visualizing 24 Hours as an SVG Donut Clock — Midnight-Crossing Blocks and All - DEV Community


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

👁 Screenshot

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.