macbetter

Charts

Fitness

Three rings, the hours that filled them, and how a workout went.

Move
486/600 KCAL
Exercise
38/30 MIN
Stand
10/12 HRS
Activity rings, today Move 486 of 600 kcal, 81%. Exercise 38 of 30 min, 127%. Stand 10 of 12 hrs, 83%
import { ActivityRings, ChartFrame } from "@/components/charts"

/** Today's rings. Exercise is past its goal, so its ring laps itself. */
const today = {
  move: { value: 486, goal: 600 },
  exercise: { value: 38, goal: 30 },
  stand: { value: 10, goal: 12 },
}

const rows = [
  { key: "move", label: "Move", unit: "KCAL", color: "var(--mb-chart-move)" },
  { key: "exercise", label: "Exercise", unit: "MIN", color: "var(--mb-chart-exercise)" },
  { key: "stand", label: "Stand", unit: "HRS", color: "var(--mb-chart-stand)" },
] as const

export default function ActivityRingsSummary() {
  return (
    <ChartFrame
      title="Activity rings, today"
      summary={rows
        .map(({ key, label, unit }) => `${label} ${today[key].value} of ${today[key].goal} ${unit.toLowerCase()}, ${Math.round((today[key].value / today[key].goal) * 100)}%`)
        .join(". ")}
      style={{ display: "flex", flexWrap: "wrap", alignItems: "center", justifyContent: "center", gap: 28 }}
    >
      <ActivityRings rings={today} size={168} />
      <dl style={{ display: "grid", gap: 10, margin: 0 }}>
        {rows.map(({ key, label, unit, color }) => (
          <div key={key}>
            <dt style={{ font: "var(--mb-text-subheadline)", fontWeight: 600 }}>{label}</dt>
            <dd style={{ margin: 0, color, font: "var(--mb-text-title-2)", fontFamily: "var(--mb-font-rounded)", fontVariantNumeric: "tabular-nums" }}>
              {today[key].value}/{today[key].goal}
              <span style={{ font: "var(--mb-text-subheadline)", fontFamily: "var(--mb-font-rounded)", fontWeight: 600 }}> {unit}</span>
            </dd>
          </div>
        ))}
      </dl>
    </ChartFrame>
  )
}

Activity rings

Move is the outer ring, Exercise the middle, Stand the inner. Each starts at twelve o'clock and runs clockwise with round caps over a dim track of its own color. Past its goal, a ring laps itself, and the end of the lap casts a shadow on the ring beneath it so you can see where it is.

ActivityRings draws them with ECharts gauge series. It's decorative: put the numbers beside it in text, as the example does, and wrap both in a ChartFrame with a summary.

import { ActivityRings } from "@/components/charts"

<ActivityRings rings={{ move: { value: 486, goal: 600 }, exercise: { value: 38, goal: 30 }, stand: { value: 10, goal: 12 } }} size={168} />

For several sets in one canvas, like a week, use ringSeries(theme, rings, { center, size }) in your own option.

Ring colors come from --mb-chart-move, --mb-chart-exercise and --mb-chart-stand. In the Ember theme they follow its pink, green and cyan; in the System theme they're close to Apple Watch's.

Hours of the day

Each ring's day as 24 small bars. Missed Stand hours during the day are gray, so a gap reads as a gap rather than nothing. Only the last strip shows the hours.

Move 486/600 KCAL
Exercise 38/30 MIN
Stand 10/12 HRS
Activity by hour, today Most of today's Move and Exercise came from walks at 8 AM and 5 PM. You stood in 10 of 12 hours.
Activity by hour, today
HourMove (kcal)Exercise (min)Stood
12 AM00No
1 AM00No
2 AM00No
3 AM00No
4 AM00No
5 AM00No
6 AM80No
7 AM469Yes
8 AM6211Yes
9 AM180Yes
10 AM120No
11 AM242Yes
12 PM384Yes
1 PM160Yes
2 PM140No
3 PM120Yes
4 PM585Yes
5 PM847Yes
6 PM410Yes
7 PM220No
8 PM150Yes
9 PM90No
10 PM40No
11 PM00No
"use client"

import * as React from "react"
import { baseOption, categoryAxis, ChartFrame, EChart, type ChartColor, type ChartTheme } from "@/components/charts"

/** Today, hour by hour. Stand is whether you stood for a minute in that hour. */
const move = [0, 0, 0, 0, 0, 0, 8, 46, 62, 18, 12, 24, 38, 16, 14, 12, 58, 84, 41, 22, 15, 9, 4, 0]
const exercise = [0, 0, 0, 0, 0, 0, 0, 9, 11, 0, 0, 2, 4, 0, 0, 0, 5, 7, 0, 0, 0, 0, 0, 0]
const stood = [0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 0, 1, 1, 1, 0, 1, 1, 1, 1, 0, 1, 0, 0, 0]
const hours = Array.from({ length: 24 }, (_, h) => `${h % 12 || 12} ${h < 12 ? "AM" : "PM"}`)
const labels = hours.map((label, h) => (h % 6 === 0 ? (h % 12 === 0 ? label : String(h % 12)) : ""))

function Strip({ values, color, max, axis, missed }: { values: readonly number[]; color: ChartColor; max: number; axis?: boolean; missed?: boolean }) {
  const option = React.useCallback(
    (theme: ChartTheme) => ({
      ...baseOption(theme),
      grid: { left: 0, right: 0, top: 2, bottom: axis ? 20 : 2 },
      xAxis: categoryAxis(theme, labels, {
        axisLabel: { show: axis, color: theme.colors.axis, fontSize: theme.fontSize, interval: (i: number) => labels[i] !== "", align: "left" },
        splitLine: { show: true, interval: (i: number) => i > 0 && i % 6 === 0, lineStyle: { color: theme.colors.grid, type: [2, 3] } },
      }),
      yAxis: { type: "value", show: false, max },
      series: [
        {
          id: "hours",
          type: "bar",
          barWidth: "56%",
          // A missed stand hour during the day still gets a gray bar, so the gaps read as gaps.
          data: values.map((v, h) => ({
            value: missed && v === 0 && h >= 7 && h <= 21 ? max : v,
            itemStyle: { color: missed && v === 0 ? theme.colors.fill : theme.colors[color] },
          })),
          itemStyle: { borderRadius: 2 },
          emphasis: { disabled: true },
        },
      ],
    }),
    [values, color, max, axis, missed]
  )
  return <EChart option={option} height={axis ? 62 : 42} />
}

const rows = [
  { label: "Move", total: "486/600", unit: "KCAL", color: "move", values: move, max: 90 },
  { label: "Exercise", total: "38/30", unit: "MIN", color: "exercise", values: exercise, max: 12 },
  { label: "Stand", total: "10/12", unit: "HRS", color: "stand", values: stood, max: 1 },
] as const

export default function HourlyActivity() {
  return (
    <ChartFrame
      title="Activity by hour, today"
      summary="Most of today's Move and Exercise came from walks at 8 AM and 5 PM. You stood in 10 of 12 hours."
      table={{
        columns: ["Hour", "Move (kcal)", "Exercise (min)", "Stood"],
        rows: hours.map((hour, h) => [hour, move[h]!, exercise[h]!, stood[h] ? "Yes" : "No"]),
      }}
      style={{ width: "100%", maxWidth: 520, gap: 14 }}
    >
      {rows.map((row, i) => (
        <div key={row.label} style={{ display: "grid", gap: 4 }}>
          <div style={{ font: "var(--mb-text-subheadline)", fontWeight: 600 }}>
            {row.label}{" "}
            <span style={{ color: `var(--mb-chart-${row.color})`, fontFamily: "var(--mb-font-rounded)", fontVariantNumeric: "tabular-nums" }}>
              {row.total} {row.unit}
            </span>
          </div>
          <Strip values={row.values} color={row.color} max={row.max} axis={i === rows.length - 1} missed={row.label === "Stand"} />
        </div>
      ))}
    </ChartFrame>
  )
}

Heart rate zones

A workout's heart rate as a line colored by zone, and the time spent in each zone as capsules. The line's color comes from a vertical gradient with hard stops at each zone's lower bound, so it changes exactly where the heart rate crosses one.

Average heart rate
144BPM
Outdoor Run · 42 min
Heart rate zones, outdoor run A 42-minute run averaging 144 BPM. Most of it, 24:00, was in Zone 3.
Heart rate zones, outdoor run
ZoneHeart rateTime
Zone 1<125 BPM5:00
Zone 2125–137 BPM4:30
Zone 3138–150 BPM24:00
Zone 4151–163 BPM8:30
Zone 5164+ BPM0:30
"use client"

import * as React from "react"
import { baseOption, ChartFrame, ChartHeader, EChart, echarts, type ChartColor, type ChartTheme } from "@/components/charts"

/** Heart rate zones for this runner, with Apple Watch's colors. */
const zones = [
  { name: "Zone 1", range: "<125 BPM", from: 0, color: "cyan" },
  { name: "Zone 2", range: "125–137 BPM", from: 125, color: "green" },
  { name: "Zone 3", range: "138–150 BPM", from: 138, color: "yellow" },
  { name: "Zone 4", range: "151–163 BPM", from: 151, color: "orange" },
  { name: "Zone 5", range: "164+ BPM", from: 164, color: "pink" },
] as const satisfies { name: string; range: string; from: number; color: ChartColor }[]

/** A 42-minute outdoor run, every 30 seconds: warm-up, tempo, two hard intervals, cool-down. */
const heartRate = Array.from({ length: 85 }, (_, i) => {
  const t = i / 2
  const base = t < 6 ? 108 + t * 6 : t < 36 ? 146 + Math.sin(t * 0.8) * 4 : 146 - (t - 36) * 5
  const interval = (t > 20 && t < 24) || (t > 28 && t < 32) ? 18 : 0
  return Math.round(base + interval + Math.sin(i * 1.3) * 2)
})
const zoneOf = (bpm: number) => zones.findLastIndex((z) => bpm >= z.from)
const minutes = zones.map((_, z) => heartRate.filter((bpm) => zoneOf(bpm) === z).length / 2)
const clock = (m: number) => `${Math.floor(m)}:${String(Math.round((m % 1) * 60)).padStart(2, "0")}`
const average = Math.round(heartRate.reduce((a, b) => a + b, 0) / heartRate.length)

export default function WorkoutZones() {
  const line = React.useCallback((theme: ChartTheme) => {
    const low = Math.min(...heartRate)
    const high = Math.max(...heartRate)
    // Hard color stops at each zone boundary, mapped onto the line's own height.
    const stops = zones.flatMap((zone, z) => {
      const start = Math.min(Math.max((zone.from - low) / (high - low), 0), 1)
      const end = z < zones.length - 1 ? Math.min(Math.max((zones[z + 1]!.from - low) / (high - low), 0), 1) : 1
      const color = theme.colors[zone.color]
      return [
        { offset: start, color },
        { offset: end, color },
      ]
    })
    return {
      ...baseOption(theme),
      grid: { left: 0, right: 34, top: 8, bottom: 22 },
      xAxis: {
        type: "category",
        data: heartRate.map((_, i) => i / 2),
        boundaryGap: false,
        axisLine: { show: false },
        axisTick: { show: false },
        axisLabel: { color: theme.colors.axis, fontSize: theme.fontSize, interval: 19, formatter: (m: string) => `${m} min` },
      },
      yAxis: {
        type: "value",
        position: "right",
        min: 100,
        max: 180,
        interval: 40,
        axisLabel: { color: theme.colors.axis, fontSize: theme.fontSize },
        splitLine: { lineStyle: { color: theme.colors.grid } },
      },
      series: [
        {
          id: "heart-rate",
          type: "line",
          data: heartRate,
          smooth: 0.3,
          showSymbol: false,
          lineStyle: { width: 2.5, cap: "round", color: new echarts.graphic.LinearGradient(0, 1, 0, 0, stops) },
        },
      ],
    }
  }, [])

  const bars = React.useCallback(
    (theme: ChartTheme) => ({
      ...baseOption(theme),
      grid: { left: 0, right: 40, top: 0, bottom: 0 },
      xAxis: { type: "value", show: false, max: Math.max(...minutes) * 1.05 },
      yAxis: {
        type: "category",
        inverse: true,
        data: zones.map((z) => z.name),
        axisLine: { show: false },
        axisTick: { show: false },
        axisLabel: {
          margin: 12,
          formatter: (name: string) => `{zone|${name}}\n{range|${zones.find((z) => z.name === name)!.range}}`,
          rich: {
            zone: { color: theme.colors.label, fontSize: theme.fontSize + 2, fontWeight: 600, lineHeight: theme.fontSize + 6 },
            range: { color: theme.colors.axis, fontSize: theme.fontSize, lineHeight: theme.fontSize + 4 },
          },
        },
      },
      series: [
        {
          id: "minutes",
          type: "bar",
          barWidth: 10,
          data: minutes.map((m, z) => ({ value: m, itemStyle: { color: theme.colors[zones[z]!.color] } })),
          itemStyle: { borderRadius: 99 },
          showBackground: true,
          backgroundStyle: { color: theme.colors.fill4, borderRadius: 99 },
          label: {
            show: true,
            position: "right",
            distance: 8,
            color: theme.colors.label,
            fontSize: theme.fontSize + 1,
            fontWeight: 600,
            formatter: ({ value }: { value: number }) => clock(value),
          },
          emphasis: { disabled: true },
        },
      ],
    }),
    []
  )

  return (
    <ChartFrame
      title="Heart rate zones, outdoor run"
      summary={`A 42-minute run averaging ${average} BPM. Most of it, ${clock(Math.max(...minutes))}, was in ${zones[minutes.indexOf(Math.max(...minutes))]!.name}.`}
      table={{ columns: ["Zone", "Heart rate", "Time"], rows: zones.map((z, i) => [z.name, z.range, clock(minutes[i]!)]) }}
      style={{ width: "100%", maxWidth: 520 }}
    >
      <ChartHeader eyebrow="Average heart rate" value={average} unit="BPM" detail="Outdoor Run · 42 min" />
      <EChart option={line} height={150} />
      <EChart option={bars} height={190} />
    </ChartFrame>
  )
}

This week

Seven sets of rings in one canvas, with today's letter filled. The table gives each day's three numbers.

Activity rings this week You closed all three rings on 2 of 7 days.
Activity rings this week
DayMove (kcal)Exercise (min)Stand (hr)
Sunday412 of 60022 of 309 of 12
Monday655 of 60041 of 3012 of 12
Tuesday590 of 60031 of 3011 of 12
Wednesday802 of 60064 of 3013 of 12
Thursday534 of 60028 of 3012 of 12
Friday611 of 60035 of 3010 of 12
Today486 of 60038 of 3010 of 12
"use client"

import * as React from "react"
import { baseOption, ChartFrame, EChart, ringSeries, type ChartTheme, type Rings } from "@/components/charts"

/** Sunday, November 8 to today. */
const week: { day: string; name: string; rings: Rings }[] = [
  { day: "S", name: "Sunday", rings: { move: { value: 412, goal: 600 }, exercise: { value: 22, goal: 30 }, stand: { value: 9, goal: 12 } } },
  { day: "M", name: "Monday", rings: { move: { value: 655, goal: 600 }, exercise: { value: 41, goal: 30 }, stand: { value: 12, goal: 12 } } },
  { day: "T", name: "Tuesday", rings: { move: { value: 590, goal: 600 }, exercise: { value: 31, goal: 30 }, stand: { value: 11, goal: 12 } } },
  { day: "W", name: "Wednesday", rings: { move: { value: 802, goal: 600 }, exercise: { value: 64, goal: 30 }, stand: { value: 13, goal: 12 } } },
  { day: "T", name: "Thursday", rings: { move: { value: 534, goal: 600 }, exercise: { value: 28, goal: 30 }, stand: { value: 12, goal: 12 } } },
  { day: "F", name: "Friday", rings: { move: { value: 611, goal: 600 }, exercise: { value: 35, goal: 30 }, stand: { value: 10, goal: 12 } } },
  { day: "S", name: "Today", rings: { move: { value: 486, goal: 600 }, exercise: { value: 38, goal: 30 }, stand: { value: 10, goal: 12 } } },
]

const closed = (r: Rings) => [r.move, r.exercise, r.stand].filter((ring) => ring.value >= ring.goal).length

export default function WeeklyRings() {
  const option = React.useCallback(
    (theme: ChartTheme) => ({
      ...baseOption(theme),
      series: week.flatMap((d, i) =>
        ringSeries(theme, d.rings, { id: `day-${i}`, size: 38, center: [`${((i + 0.5) / week.length) * 100}%`, "50%"] })
      ),
    }),
    []
  )

  return (
    <ChartFrame
      title="Activity rings this week"
      summary={`You closed all three rings on ${week.filter((d) => closed(d.rings) === 3).length} of 7 days.`}
      table={{
        columns: ["Day", "Move (kcal)", "Exercise (min)", "Stand (hr)"],
        rows: week.map((d) => [d.name, `${d.rings.move.value} of ${d.rings.move.goal}`, `${d.rings.exercise.value} of ${d.rings.exercise.goal}`, `${d.rings.stand.value} of ${d.rings.stand.goal}`]),
      }}
      style={{ width: "100%", maxWidth: 420, gap: 6 }}
    >
      <EChart option={option} height={44} />
      <div aria-hidden="true" style={{ display: "grid", gridTemplateColumns: "repeat(7, 1fr)", justifyItems: "center" }}>
        {week.map((d, i) => (
          <span
            key={i}
            style={{
              display: "grid",
              placeItems: "center",
              width: 22,
              height: 22,
              borderRadius: "50%",
              font: "var(--mb-text-footnote)",
              fontWeight: 600,
              color: i === week.length - 1 ? "var(--mb-bg)" : "var(--mb-label-secondary)",
              background: i === week.length - 1 ? "var(--mb-label)" : "transparent",
            }}
          >
            {d.day}
          </span>
        ))}
      </div>
    </ChartFrame>
  )
}

Fitness compares your last 90 days with the last year. A filled arrow means a metric is going up; an outlined arrow means it's going down, and the words beside it say so too.

  • Move512 KCAL/DAY
    Up from 468
  • Exercise36 MIN/DAY
    Up from 29
  • Stand11 HR/DAY
    Down from 12
  • Distance4.2 MI/DAY
    Up from 3.6
  • Walking Pace17′42″ /MI
    Faster than 18′05″
import { ArrowDownIcon, ArrowUpIcon } from "lucide-react"

/** The last 90 days against the last 365, as Fitness compares them. */
const trends = [
  { label: "Move", color: "var(--mb-chart-move)", now: "512", before: "468", unit: "KCAL/DAY", up: true },
  { label: "Exercise", color: "var(--mb-chart-exercise)", now: "36", before: "29", unit: "MIN/DAY", up: true },
  { label: "Stand", color: "var(--mb-chart-stand)", now: "11", before: "12", unit: "HR/DAY", up: false },
  { label: "Distance", color: "var(--mb-blue)", now: "4.2", before: "3.6", unit: "MI/DAY", up: true },
  { label: "Walking Pace", color: "var(--mb-teal)", now: "17′42″", before: "18′05″", unit: "/MI", up: true, note: "Faster than 18′05″" },
]

export default function FitnessTrends() {
  return (
    <ul aria-label="Trends, last 90 days compared with the last year" style={{ display: "grid", gap: 14, width: "100%", maxWidth: 420, margin: 0, padding: 0, listStyle: "none" }}>
      {trends.map((t) => {
        const Arrow = t.up ? ArrowUpIcon : ArrowDownIcon
        return (
          <li key={t.label} style={{ display: "flex", alignItems: "center", gap: 12 }}>
            <span
              aria-hidden="true"
              style={{
                display: "grid",
                placeItems: "center",
                flex: "none",
                width: 30,
                height: 30,
                borderRadius: "50%",
                // Going down is drawn as an outline, so it doesn't rely on color alone.
                background: t.up ? t.color : "transparent",
                boxShadow: t.up ? "none" : `inset 0 0 0 2px ${t.color}`,
                color: t.up ? "var(--mb-bg)" : t.color,
              }}
            >
              <Arrow style={{ width: 18, height: 18 }} strokeWidth={3} />
            </span>
            <div style={{ display: "grid", gap: 1, minWidth: 0 }}>
              <span style={{ color: t.color, font: "var(--mb-text-subheadline)", fontWeight: 600 }}>{t.label}</span>
              <span style={{ font: "var(--mb-text-title-3)", fontFamily: "var(--mb-font-rounded)", fontVariantNumeric: "tabular-nums" }}>
                {t.now}
                <span style={{ color: "var(--mb-label-secondary)", font: "var(--mb-text-footnote)", fontWeight: 600 }}> {t.unit}</span>
              </span>
            </div>
            <span style={{ marginInlineStart: "auto", color: "var(--mb-label-secondary)", font: "var(--mb-text-footnote)", textAlign: "end" }}>
              {"note" in t ? t.note : `${t.up ? "Up" : "Down"} from ${t.before}`}
            </span>
          </li>
        )
      })}
    </ul>
  )
}

Accessibility

  • Rings, strips and zones are named figures with summaries, like "Move 486 of 600 kcal, 81%", and tables of every value.
  • The ring colors are always paired with the ring's name and numbers in text.
  • Trend arrows are hidden from screen readers; each row says "Up from 468" or "Down from 12".