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/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
c84ed88213 · 6 hours ago
Bounded source sync
1ed137d95d · 17 hours ago
Bounded source sync
851999f793 · 18 hours ago
Bounded source sync
9623bc231c · 18 hours ago