Source
This app’s code and revision history are public. Download a copy or explore the files below.
Public
The app’s code and revision history are public.
Run it yourself
Take the whole tree and run it on your own machine. The download needs no account. Cloning needs a sign-in.
Download .zip
bounded clone 381bd6356d4b794ac2a7c1b8
A copy you download is not bounded. The guarantees this app runs under - declared egress, proven invariants, writes that fail closed - live in the runtime serving this page, not in the files. What you get is auditable source you can read and run, with no runtime guarantees attached.
src/Charts.tsx
// Charts: when the baby slept and fed hour by hour, sleep, feeds and diapers per day over 7 or 30 days,
// bottle amounts, and growth over time. Plain SVG and HTML, sized to its box. Every total is also in the table.
import { useEffect, useLayoutEffect, useRef, useState, type ReactNode } from "react";
import { useUnits } from "./lib/prefs";
import { loadRange, useGrowth } from "./lib/family";
import { explain } from "./lib/bounded";
import { Choice, useNow, localDayStart, addDays } from "./ui";
import { totals, parseData, fromMl, KG_PER_LB, CM_PER_IN, fmtShort, fmtVol, fmtWeight, fmtLength, type BabyEvent, type DayTotals } from "../functions/lib/baby";
import type { FamilyCtx } from "./App";
interface Day { start: number; t: DayTotals }
export function Charts({ ctx }: { ctx: FamilyCtx }) {
const [u] = useUnits();
const now = useNow(60_000);
const [range, setRange] = useState<"7" | "30">("7");
const [month, setMonth] = useState<{ key: string; events: BabyEvent[] } | null>(null);
const [error, setError] = useState<string | null>(null);
const today = localDayStart(now);
const babyId = ctx.baby?.id ?? "";
const n = Number(range);
const first = addDays(today, -(n - 1));
const key = `${babyId}:${first}`;
const growth = useGrowth(ctx.h, ctx.baby?.id ?? null);
useEffect(() => {
if (range !== "30" || !babyId || month?.key === key) return;
let cancelled = false;
setError(null);
loadRange(ctx.h, babyId, addDays(first, -1), addDays(today, 1))
.then((events) => { if (!cancelled) setMonth({ key, events }); })
.catch((err) => { if (!cancelled) setError(explain(err)); });
return () => { cancelled = true; };
}, [range, key]);
if (!ctx.baby) return null;
const source = range === "7" ? ctx.events.filter((e) => e.baby === babyId) : month?.key === key ? month.events : null;
const days: Day[] | null = source ? Array.from({ length: n }, (_, i) => {
const start = addDays(first, i);
return { start, t: totals(source, start, addDays(start, 1), now) };
}) : null;
const label = (d: Day, i: number) => n === 7
? new Date(d.start * 1000).toLocaleDateString(undefined, { weekday: "short" })
: i % 5 === 0 || i === n - 1 ? String(new Date(d.start * 1000).getDate()) : "";
const full = (d: Day) => new Date(d.start * 1000).toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric" });
// Averages use whole logged days: not today (not over yet) and not the day logging began (it started part way).
const firstLogged = source?.length ? localDayStart(Math.min(...source.map((e) => e.start))) : today;
const done = (days || []).slice(0, -1).filter((d) => d.start > firstLogged);
const avg = (f: (t: DayTotals) => number) => (done.length ? done.reduce((a, d) => a + f(d.t), 0) / done.length : 0);
const note = (text: string) => (done.length ? `Average ${text} a day` : "Averages start after a full day");
const vol = (ml: number) => Number(fromMl(ml, u.vol).toFixed(u.vol === "oz" ? 1 : 0));
const hasBottle = days?.some((d) => d.t.bottles > 0);
return (
<div className="charts wrap-app enter">
<div className="charts-head">
<h1 className="page-title small-title">Charts</h1>
<Choice label="Range" value={range} onChange={setRange} options={[{ value: "7", label: "7 days" }, { value: "30", label: "30 days" }]} />
</div>
{error && <p className="notice notice-error">{error}</p>}
{!days || !source ? (
<div className="chart-grid" aria-busy="true" aria-label={`Loading ${n} days`}>
<span className="skeleton span-2" style={{ height: 260, borderRadius: 18 }} />
<span className="skeleton" style={{ height: 240, borderRadius: 18 }} />
<span className="skeleton" style={{ height: 240, borderRadius: 18 }} />
</div>
) : (
<>
<div className="chart-grid">
<ChartCard title="Sleep and feeds by hour" note={n === 7 ? "Last 7 days" : "Last 30 days"} wide>
<Pattern days={days.map((d) => d.start)} events={source} now={now} />
</ChartCard>
<ChartCard title="Sleep per day" note={note(fmtShort(avg((t) => t.sleepSec)))}>
<Bars days={days} label={label} full={full} series={[{ name: "Sleep", color: "c1" }]} min={2}
values={(d) => [d.t.sleepSec / 3600]} format={(v) => fmtShort(v * 3600)} axis={(v) => `${v}h`} />
</ChartCard>
<ChartCard title="Feeds per day" note={note(avg((t) => t.feeds).toFixed(1))}>
<Bars days={days} label={label} full={full} series={[{ name: "Breast", color: "c1" }, { name: "Bottle", color: "c2" }]}
values={(d) => [d.t.breastFeeds, d.t.bottles]} format={(v) => String(Math.round(v))} axis={(v) => String(v)} />
</ChartCard>
<ChartCard title="Diapers per day" note={note(avg((t) => t.diapers).toFixed(1))}>
<Bars days={days} label={label} full={full} series={[{ name: "Wet only", color: "c1" }, { name: "Dirty or both", color: "c2" }]}
values={(d) => [d.t.diapers - d.t.dirty, d.t.dirty]} format={(v) => String(Math.round(v))} axis={(v) => String(v)} />
</ChartCard>
{hasBottle && (
<ChartCard title={`Bottle per day (${u.vol})`} note={note(fmtVol(avg((t) => t.bottleMl), u.vol))}>
<Bars days={days} label={label} full={full} series={[{ name: "Bottle", color: "c1" }]}
values={(d) => [vol(d.t.bottleMl)]} format={(v) => `${v} ${u.vol}`} axis={(v) => String(v)} />
</ChartCard>
)}
</div>
<details className="table-view">
<summary>Show these days as a table</summary>
<div className="table-wrap">
<table className="table">
<thead><tr><th>Day</th><th>Sleep</th><th>Feeds</th><th>Breast</th><th>Bottle</th><th>Diapers</th><th>Wet</th><th>Dirty</th></tr></thead>
<tbody>
{[...days].reverse().map((d) => (
<tr key={d.start}><td>{full(d)}</td><td>{d.t.sleepSec < 60 ? "0m" : fmtShort(d.t.sleepSec)}</td><td>{d.t.feeds}</td><td>{d.t.breastSec < 60 ? "0m" : fmtShort(d.t.breastSec)}</td><td>{fmtVol(d.t.bottleMl, u.vol)}</td><td>{d.t.diapers}</td><td>{d.t.wet}</td><td>{d.t.dirty}</td></tr>
))}
</tbody>
</table>
</div>
</details>
</>
)}
<h2 className="section-title">Growth</h2>
{!growth ? <span className="skeleton" style={{ height: 200, borderRadius: 18 }} /> : growth.length === 0 ? (
<div className="empty-state">
<h3>No measurements yet</h3>
<p>Log weight, length or head size with Growth on the Today screen, for example after a checkup.</p>
</div>
) : (
<div className="chart-grid">
<GrowthChart title="Weight" events={growth} pick={(d) => d.kg} toUnit={(kg) => (u.weight === "lb" ? kg / KG_PER_LB : kg)} unit={u.weight} format={(kg) => fmtWeight(kg, u.weight)} />
<GrowthChart title="Length" events={growth} pick={(d) => d.cm} toUnit={(cm) => (u.length === "in" ? cm / CM_PER_IN : cm)} unit={u.length} format={(cm) => fmtLength(cm, u.length)} />
<GrowthChart title="Head" events={growth} pick={(d) => d.head} toUnit={(cm) => (u.length === "in" ? cm / CM_PER_IN : cm)} unit={u.length} format={(cm) => fmtLength(cm, u.length)} />
</div>
)}
<p className="muted small fine">These charts show what your family logged. They are not growth percentiles or medical advice; your doctor can tell you how your baby is doing.</p>
</div>
);
}
function ChartCard({ title, note, children, wide }: { title: string; note?: string; children: ReactNode; wide?: boolean }) {
return (
<section className={`chart card${wide ? " span-2" : ""}`}>
<div className="chart-head"><h3>{title}</h3>{note && <span>{note}</span>}</div>
{children}
</section>
);
}
function useWidth<T extends HTMLElement>(): [React.RefObject<T>, number] {
const ref = useRef<T>(null);
const [w, setW] = useState(0);
useLayoutEffect(() => {
if (!ref.current) return;
const ro = new ResizeObserver(([e]) => setW(Math.floor(e.contentRect.width)));
ro.observe(ref.current);
setW(Math.floor(ref.current.getBoundingClientRect().width));
return () => ro.disconnect();
}, []);
return [ref, w];
}
function niceMax(v: number): number {
if (v <= 0) return 1;
const p = 10 ** Math.floor(Math.log10(v));
for (const m of [1, 2, 2.5, 5, 10]) if (m * p >= v) return m * p;
return 10 * p;
}
type Tip = { x: number; y: number; lines: [string, string, string?][] ; title: string } | null;
function Tooltip({ tip }: { tip: Tip }) {
if (!tip) return null;
return (
<div className="tip" style={{ left: tip.x, top: tip.y }} role="status">
<span className="tip-title">{tip.title}</span>
{tip.lines.map(([name, value, color]) => (
<span key={name} className="tip-row">{color && <i className={`key ${color}`} />}<strong>{value}</strong> {name}</span>
))}
</div>
);
}
/** Each day as a row across 24 hours: sleep as blocks, feeds as ticks, so the rhythm shows at a glance. */
function Pattern({ days, events, now }: { days: number[]; events: BabyEvent[]; now: number }) {
const sleeps = events.filter((e) => e.type === "sleep");
const feeds = events.filter((e) => e.type === "breast" || e.type === "bottle");
return (
<div className={`pattern ${days.length > 7 ? "dense" : ""}`} role="img"
aria-label={`When the baby slept and fed over the last ${days.length} days. Daily totals are in the table below the charts.`}>
{[...days].reverse().map((d) => {
const end = addDays(d, 1);
const span = end - d;
const pct = (t: number) => `${((Math.min(Math.max(t, d), end) - d) / span) * 100}%`;
return (
<div key={d} className="pattern-row">
<span className="pattern-day">{new Date(d * 1000).toLocaleDateString(undefined, days.length > 7 ? { month: "short", day: "numeric" } : { weekday: "short" })}</span>
<div className="pattern-track">
{sleeps.filter((e) => e.start < end && (e.end ?? now) > d).map((e) => (
<i key={e.id} className="pattern-sleep" style={{ left: pct(e.start), width: `max(2px, calc(${pct(Math.min(e.end ?? now, now))} - ${pct(e.start)}))` }} />
))}
{feeds.filter((e) => e.start >= d && e.start < end).map((e) => <i key={e.id} className="pattern-feed" style={{ left: pct(e.start) }} />)}
</div>
</div>
);
})}
<div className="pattern-foot">
<div className="legend"><span><i className="swatch c1" />Asleep</span><span><i className="swatch c2" />Feed</span></div>
<div className="pattern-hours" aria-hidden="true"><span>12a</span><span>6a</span><span>12p</span><span>6p</span><span>12a</span></div>
</div>
</div>
);
}
/** Columns per day, stacked when there is more than one series. `min` keeps the scale from collapsing on small values. */
function Bars({ days, values, series, format, axis, label, full, min = 1 }: {
days: Day[]; values: (d: Day) => number[]; series: { name: string; color: string }[];
format: (v: number) => string; axis: (v: number) => string; label: (d: Day, i: number) => string; full: (d: Day) => string; min?: number;
}) {
const [ref, width] = useWidth<HTMLDivElement>();
const [tip, setTip] = useState<Tip>(null);
const H = 170, top = 10, bottom = 24, left = 34;
const plotW = Math.max(0, width - left - 4);
const plotH = H - top - bottom;
const rows = days.map(values);
const max = niceMax(Math.max(min, ...rows.map((r) => r.reduce((a, b) => a + b, 0))));
const band = days.length ? plotW / days.length : 0;
const bw = Math.max(3, Math.min(24, band * 0.62));
const y = (v: number) => top + plotH - (v / max) * plotH;
const ticks = [0, max / 2, max];
return (
<div className="chart-box" ref={ref} onPointerLeave={() => setTip(null)}>
{series.length > 1 && (
<div className="legend">{series.map((s) => <span key={s.name}><i className={`swatch ${s.color}`} />{s.name}</span>)}</div>
)}
{width > 0 && (
<svg width={width} height={H} role="img" aria-label={`${series.map((s) => s.name).join(" and ")} per day`}>
{ticks.map((t) => (
<g key={t}>
<line className="grid" x1={left} x2={width - 4} y1={y(t)} y2={y(t)} />
<text className="axis" x={left - 6} y={y(t) + 4} textAnchor="end">{axis(Number(t.toFixed(1)))}</text>
</g>
))}
{days.map((d, i) => {
const cx = left + band * i + band / 2;
let acc = 0;
const segs = rows[i].map((v, s) => {
if (v <= 0) return null;
const y1 = y(acc + v);
const y0 = y(acc);
acc += v;
const isTop = rows[i].slice(s + 1).every((x) => x <= 0);
const h = Math.max(0, y0 - y1 - (s > 0 ? 2 : 0));
const yTop = y1;
const r = isTop ? Math.min(4, h, bw / 2) : 0;
const x0 = cx - bw / 2;
const path = `M${x0},${yTop + h} V${yTop + r} Q${x0},${yTop} ${x0 + r},${yTop} H${x0 + bw - r} Q${x0 + bw},${yTop} ${x0 + bw},${yTop + r} V${yTop + h} Z`;
return <path key={s} className={`bar ${series[s].color}`} d={path} />;
});
return (
<g key={d.start}>
{segs}
<text className="axis" x={cx} y={H - 6} textAnchor="middle">{label(d, i)}</text>
<rect className="hit" x={left + band * i} y={top} width={band} height={plotH} tabIndex={0}
aria-label={`${full(d)}: ${series.map((s, k) => `${s.name} ${format(rows[i][k])}`).join(", ")}`}
onPointerEnter={() => setTip({ x: Math.min(Math.max(cx, 70), width - 70), y: top, title: full(d), lines: series.map((s, k) => [s.name, format(rows[i][k]), s.color]) })}
onFocus={() => setTip({ x: Math.min(Math.max(cx, 70), width - 70), y: top, title: full(d), lines: series.map((s, k) => [s.name, format(rows[i][k]), s.color]) })}
onBlur={() => setTip(null)} />
</g>
);
})}
</svg>
)}
<Tooltip tip={tip} />
</div>
);
}
function GrowthChart({ title, events, pick, toUnit, unit, format }: {
title: string; events: BabyEvent[]; pick: (d: ReturnType<typeof parseData>) => number | undefined; toUnit: (v: number) => number; unit: string; format: (v: number) => string;
}) {
const [ref, width] = useWidth<HTMLDivElement>();
const [tip, setTip] = useState<Tip>(null);
const points = events.map((e) => ({ t: e.start, v: pick(parseData(e.data)) })).filter((p): p is { t: number; v: number } => typeof p.v === "number" && p.v > 0);
if (!points.length) return null;
const H = 170, top = 12, bottom = 24, left = 40, right = 12;
const t0 = points[0].t, t1 = points[points.length - 1].t;
const span = Math.max(t1 - t0, 86400 * 7);
const vals = points.map((p) => toUnit(p.v));
const lo = Math.min(...vals), hi = Math.max(...vals);
const pad = Math.max((hi - lo) * 0.15, hi * 0.05, 0.5);
const vmin = Math.max(0, Math.floor(lo - pad)), vmax = Math.ceil(hi + pad);
const x = (t: number) => left + ((t - t0) / span) * (width - left - right);
const y = (v: number) => top + (1 - (v - vmin) / (vmax - vmin || 1)) * (H - top - bottom);
const line = points.map((p, i) => `${i ? "L" : "M"}${x(p.t).toFixed(1)},${y(toUnit(p.v)).toFixed(1)}`).join(" ");
const date = (t: number) => new Date(t * 1000).toLocaleDateString(undefined, { month: "short", day: "numeric" });
const last = points[points.length - 1];
return (
<ChartCard title={`${title} (${unit})`} note={`Latest ${format(last.v)}, ${date(last.t)}`}>
<div className="chart-box" ref={ref} onPointerLeave={() => setTip(null)}>
{width > 0 && (
<svg width={width} height={H} role="img" aria-label={`${title} over time`}>
{[vmin, (vmin + vmax) / 2, vmax].map((v) => (
<g key={v}>
<line className="grid" x1={left} x2={width - right} y1={y(v)} y2={y(v)} />
<text className="axis" x={left - 6} y={y(v) + 4} textAnchor="end">{Number(v.toFixed(1))}</text>
</g>
))}
<text className="axis" x={left} y={H - 6}>{date(t0)}</text>
{x(t1) - x(t0) > 90 && <text className="axis" x={x(t1)} y={H - 6} textAnchor="end">{date(t1)}</text>}
{points.length > 1 && <path className="line c1" d={line} />}
{points.map((p) => (
<g key={p.t}>
<circle className="dot c1" cx={x(p.t)} cy={y(toUnit(p.v))} r={4.5} />
<circle className="hit" cx={x(p.t)} cy={y(toUnit(p.v))} r={14} tabIndex={0} aria-label={`${date(p.t)}: ${format(p.v)}`}
onPointerEnter={() => setTip({ x: Math.min(Math.max(x(p.t), 70), width - 70), y: Math.max(0, y(toUnit(p.v)) - 64), title: date(p.t), lines: [[title.toLowerCase(), format(p.v)]] })}
onFocus={() => setTip({ x: Math.min(Math.max(x(p.t), 70), width - 70), y: Math.max(0, y(toUnit(p.v)) - 64), title: date(p.t), lines: [[title.toLowerCase(), format(p.v)]] })}
onBlur={() => setTip(null)} />
</g>
))}
</svg>
)}
<Tooltip tip={tip} />
</div>
</ChartCard>
);
}
Change history
Quality pass: undo on timers, sleep by hour chart, diaper color, fixes
Bounded source sync
Bounded source sync
Bounded source sync