baby-free.openapps.xyz

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.
Owned app · rev c84ed8821371
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.
← All files
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
c84ed88213 · 5 hours ago
Bounded source sync
1ed137d95d · 16 hours ago
Bounded source sync
851999f793 · 17 hours ago
Bounded source sync
9623bc231c · 17 hours ago