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/Report.tsx
// A printable summary for the pediatrician: daily totals over a date range, growth, medicine,
// temperatures and notes. Opens in its own tab; Print or Save as PDF from there.
import { useEffect, useState } from "react";
import { useUnits } from "./lib/prefs";
import { loadRange, useGrowth } from "./lib/family";
import { explain } from "./lib/bounded";
import { Icon, localDayStart, addDays, ageText, clockTime } from "./ui";
import { totals, parseData, fmtShort, fmtVol, fmtWeight, fmtLength, fmtTemp, type BabyEvent } from "../functions/lib/baby";
import type { FamilyCtx } from "./App";
export function Report({ ctx }: { ctx: FamilyCtx }) {
const [u] = useUnits();
const q = new URLSearchParams(location.search);
const baby = ctx.babies.find((b) => b.id === q.get("baby")) ?? ctx.baby;
const today = localDayStart(Date.now() / 1000);
const parse = (s: string | null, fallback: number) => {
const t = s && /^\d{4}-\d{2}-\d{2}$/.test(s) ? new Date(`${s}T00:00:00`).getTime() / 1000 : NaN;
return Number.isFinite(t) ? localDayStart(t) : fallback;
};
const from = parse(q.get("from"), addDays(today, -13));
const to = Math.max(from, parse(q.get("to"), today));
const end = addDays(to, 1);
const [events, setEvents] = useState<BabyEvent[] | null>(null);
const [error, setError] = useState<string | null>(null);
const growth = useGrowth(ctx.h, baby?.id ?? null);
useEffect(() => {
if (!baby) return;
loadRange(ctx.h, baby.id, addDays(from, -1), end).then(setEvents).catch((err) => setError(explain(err)));
}, [baby?.id, from, end]);
if (!baby) return <div className="report"><p>No baby in this log yet.</p></div>;
const now = Math.floor(Date.now() / 1000);
const days: number[] = [];
for (let d = from; d < end; d = addDays(d, 1)) days.push(d);
const inRange = (events || []).filter((e) => e.start >= from && e.start < end);
// Days before the first entry were not logged, so they are left out rather than shown as zeros.
const first = inRange.length ? localDayStart(Math.min(...inRange.map((e) => e.start))) : end;
const rows = events ? days.filter((d) => d >= first).map((d) => ({ d, t: totals(events, d, addDays(d, 1), now) })) : [];
// Averages use whole logged days: not today, and not the day logging began (events reach one day before the range).
const began = events?.length ? localDayStart(Math.min(...events.map((e) => e.start))) : end;
const done = rows.filter((r) => r.d > began && r.d < today);
const avgOf = (f: (t: (typeof rows)[0]["t"]) => number) => (done.length ? done.reduce((a, r) => a + f(r.t), 0) / done.length : 0);
const dur = (sec: number) => (sec < 60 ? "0m" : fmtShort(sec));
const list = (type: string) => inRange.filter((e) => e.type === type).sort((a, b) => a.start - b.start);
const date = (s: number) => new Date(s * 1000).toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric" });
const shortDate = (s: number) => new Date(s * 1000).toLocaleDateString(undefined, { month: "short", day: "numeric" });
const measures = (growth || []).filter((e) => e.start < end);
return (
<div className="report">
<div className="report-tools no-print">
<a href="/app/family" className="btn btn-line btn-sm">Back</a>
<button type="button" className="btn btn-sm" onClick={() => print()}><Icon name="print" /> Print or save as PDF</button>
</div>
<header className="report-head">
<p className="eyebrow">Baby log summary</p>
<h1>{baby.name}</h1>
<p>{[baby.birth ? `Born ${new Date(`${baby.birth}T00:00:00`).toLocaleDateString(undefined, { month: "long", day: "numeric", year: "numeric" })}` : "", baby.birth ? ageText(baby.birth) : ""].filter(Boolean).join(", ")}</p>
<p>{date(from)} to {date(to)} · {days.length} days · printed {new Date().toLocaleDateString()}</p>
</header>
{error && <p className="notice notice-error">{error}</p>}
{!events ? <p className="loading"><span className="spinner" /> Loading...</p> : !inRange.length ? (
<p className="report-empty">Nothing was logged for {baby.name} between {date(from)} and {date(to)}.</p>
) : (
<>
{first > from && <p className="report-note">The log starts {date(first)}; earlier days in this range have no entries.</p>}
<section>
<h2>Daily averages{done.length ? `, ${done.length} full ${done.length === 1 ? "day" : "days"}` : ""}</h2>
{done.length ? (
<dl className="report-stats">
<div><dt>Feeds</dt><dd>{avgOf((t) => t.feeds).toFixed(1)}</dd></div>
<div><dt>Breastfeeding</dt><dd>{dur(avgOf((t) => t.breastSec))}</dd></div>
<div><dt>Bottle</dt><dd>{fmtVol(avgOf((t) => t.bottleMl), u.vol)}</dd></div>
<div><dt>Sleep</dt><dd>{dur(avgOf((t) => t.sleepSec))}</dd></div>
<div><dt>Wet diapers</dt><dd>{avgOf((t) => t.wet).toFixed(1)}</dd></div>
<div><dt>Dirty diapers</dt><dd>{avgOf((t) => t.dirty).toFixed(1)}</dd></div>
</dl>
) : <p>Averages need one full day of entries.</p>}
</section>
<section>
<h2>Day by day</h2>
<table className="report-table">
<thead><tr><th>Day</th><th>Feeds</th><th>Breastfeeding</th><th>Bottle</th><th>Sleep</th><th>Wet</th><th>Dirty</th><th>Pumped</th></tr></thead>
<tbody>
{rows.map(({ d, t }) => (
<tr key={d}><td>{shortDate(d)}</td><td>{t.feeds}</td><td>{dur(t.breastSec)}</td><td>{fmtVol(t.bottleMl, u.vol)}</td><td>{dur(t.sleepSec)}</td><td>{t.wet}</td><td>{t.dirty}</td><td>{t.pumpMl ? fmtVol(t.pumpMl, u.vol) : ""}</td></tr>
))}
</tbody>
</table>
</section>
<section>
<h2>Growth</h2>
{measures.length ? (
<table className="report-table">
<thead><tr><th>Date</th><th>Weight</th><th>Length</th><th>Head</th></tr></thead>
<tbody>{measures.map((e) => { const d = parseData(e.data); return <tr key={e.id}><td>{date(e.start)}</td><td>{d.kg ? fmtWeight(d.kg, u.weight) : ""}</td><td>{d.cm ? fmtLength(d.cm, u.length) : ""}</td><td>{d.head ? fmtLength(d.head, u.length) : ""}</td></tr>; })}</tbody>
</table>
) : <p>No measurements logged.</p>}
</section>
<ListSection title="Medicine" events={list("medicine")} render={(d) => `${d.name || "Medicine"}${d.dose ? `, ${d.dose}` : ""}`} date={date} />
<ListSection title="Temperatures" events={list("temp")} render={(d) => (d.c != null ? fmtTemp(d.c, u.temp) : "")} date={date} />
<ListSection title="Solids" events={list("solids")} render={(d) => `${d.food || "Food"}${d.amount ? `, ${d.amount}` : ""}`} date={date} />
<ListSection title="Notes" events={[...list("note"), ...inRange.filter((e) => e.type !== "note" && parseData(e.data).note)].sort((a, b) => a.start - b.start)} render={(d) => d.text || d.note || ""} date={date} />
</>
)}
<footer className="report-foot">Logged by the family with Baby (baby-free.openapps.xyz). A parent's log, not a medical record; times and amounts are as entered.</footer>
</div>
);
}
function ListSection({ title, events, render, date }: { title: string; events: BabyEvent[]; render: (d: ReturnType<typeof parseData>) => string; date: (s: number) => string }) {
if (!events.length) return null;
return (
<section>
<h2>{title}</h2>
<ul className="report-list">{events.map((e) => <li key={e.id}><span>{date(e.start)}, {clockTime(e.start)}</span> {render(parseData(e.data))}</li>)}</ul>
</section>
);
}
Change history
Quality pass: undo on timers, sleep by hour chart, diaper color, fixes
Bounded source sync
Bounded source sync
Bounded source sync