DEV Community

SEN LLC
SEN LLC

Posted on

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

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),
  };
}
Enter fullscreen mode Exit fullscreen mode

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`;
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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,
  };
}
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)