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/LogSheets.tsx
// The sheets for logging and editing each kind of entry. Big buttons for the common case
// (start a feed, a wet diaper, a bottle of the usual size), and a time field for anything earlier.
import { useState, type ReactNode } from "react";
import { addEvent, updateEvent, deleteEvent, restoreEvent, startFeed, startSleep, stopTimer, reopenTimer } from "./lib/family";
import { explain } from "./lib/bounded";
import { useUnits } from "./lib/prefs";
import { Sheet, useSheet, Choice, Stepper, Icon, toast, toLocalInput, fromLocalInput, clockTime } from "./ui";
import {
TYPE_LABEL, DIAPER_COLORS, parseData, sideTotals, lastSide, isRunning, describe, toMl, fromMl, toC, KG_PER_LB, CM_PER_IN,
type BabyEvent, type DiaperColor, type EventData, type EventType, type Segment, type Side, type Units,
} from "../functions/lib/baby";
import type { FamilyCtx } from "./App";
const nowSec = () => Math.floor(Date.now() / 1000);
/** Shows what was saved, with Undo for new entries. */
function saved(ctx: FamilyCtx, id: string, text: string, isNew: boolean) {
toast(`Saved: ${text}`, isNew ? () => void deleteEvent(ctx.h, id).catch(() => null) : undefined);
}
function WhenField({ label = "When", value, onChange }: { label?: string; value: number; onChange: (v: number) => void }) {
return (
<label className="field when">
<span>{label}</span>
<div className="when-row">
<input className="input" type="datetime-local" value={toLocalInput(value)} max={toLocalInput(nowSec() + 60)}
onChange={(e) => { const v = fromLocalInput(e.target.value); if (Number.isFinite(v)) onChange(v); }} />
<button type="button" className="btn btn-line btn-sm" onClick={() => onChange(nowSec())}>Now</button>
</div>
</label>
);
}
function NoteField({ value, onChange }: { value: string; onChange: (v: string) => void }) {
return (
<label className="field"><span>Note <em>optional</em></span>
<input className="input" value={value} maxLength={300} onChange={(e) => onChange(e.target.value)} placeholder="Anything to remember" />
</label>
);
}
function Foot({ busy, onSave, onDelete }: { busy: boolean; onSave: () => void; onDelete?: () => void }) {
return (
<>
{onDelete && <button type="button" className="btn btn-quiet btn-danger" onClick={onDelete} disabled={busy}>Delete</button>}
<span className="grow" />
<button type="button" className="btn btn-lg btn-save" onClick={onSave} aria-busy={busy || undefined}>Save</button>
</>
);
}
interface Props { ctx: FamilyCtx; type: EventType; event?: BabyEvent; onClose: () => void }
/** Opens the right sheet for a kind of entry, new or existing. */
export function LogSheet(props: Props) {
const { type } = props;
if (type === "breast") return <BreastSheet {...props} />;
if (type === "sleep") return <SleepSheet {...props} />;
if (type === "diaper") return <DiaperSheet {...props} />;
return <DetailSheet {...props} />;
}
/** Saving and deleting, shared by every sheet. */
function useSave({ ctx, event, onClose }: Props, u: Units) {
const sheet = useSheet(onClose);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const run = async (fn: () => Promise<void>) => {
setBusy(true);
setError(null);
try {
await fn();
sheet.close();
} catch (err) {
setError(explain(err));
setBusy(false);
}
};
const save = (type: EventType, start: number, data: EventData, end?: number | null) => run(async () => {
if (!ctx.baby) throw new Error("Add a baby first, under Family.");
if (end != null && end < start) throw new Error("The end is before the start.");
if (start > nowSec() + 120) throw new Error("That time is in the future.");
const text = describe({ id: "", baby: ctx.baby.id, type, start, end, data: JSON.stringify(data), by: "" }, u, nowSec());
if (event) {
await updateEvent(ctx.h, event.id, { start, end, data });
saved(ctx, event.id, text, false);
} else {
const id = await addEvent(ctx.h, { baby: ctx.baby.id, type, start, end, data });
saved(ctx, id, text, true);
}
});
// Deleting asks nothing: the toast's Undo brings the entry back.
const remove = event ? () => run(async () => {
await deleteEvent(ctx.h, event.id);
toast(`Deleted: ${TYPE_LABEL[event.type].toLowerCase()}`, () => void restoreEvent(ctx.h, event.id).catch(() => null));
}) : undefined;
return { sheet, busy, error, setError, save, remove, run };
}
const Err = ({ error }: { error: string | null }) => (error ? <p className="notice notice-error" role="alert">{error}</p> : null);
function Divider({ children }: { children: ReactNode }) {
return <p className="divider"><span>{children}</span></p>;
}
// ---------- Breastfeeding ----------
function BreastSheet(props: Props) {
const { ctx, event } = props;
const [u] = useUnits();
const { sheet, busy, error, save, remove, run } = useSave(props, u);
const d = event ? parseData(event.data) : {};
const running = event ? isRunning(event) : false;
const totals = sideTotals(d.seg, nowSec());
const [start, setStart] = useState(event?.start ?? nowSec() - 15 * 60);
const [left, setLeft] = useState(Math.round(totals.L / 60) || (event ? 0 : 10));
const [right, setRight] = useState(Math.round(totals.R / 60) || (event ? 0 : 5));
const prev = ctx.events.filter((e) => e.type === "breast" && e.baby === ctx.baby?.id && !isRunning(e)).sort((a, b) => b.start - a.start)[0];
const prevSide = prev ? lastSide(parseData(prev.data).seg) : null;
const suggest: Side = prevSide === "L" ? "R" : "L";
const begin = (side: Side) => run(async () => {
if (!ctx.baby) throw new Error("Add a baby first, under Family.");
const id = await startFeed(ctx.h, ctx.baby.id, side);
toast(`Feeding on the ${side === "L" ? "left" : "right"}. The timer keeps going if you close the app.`, () => void deleteEvent(ctx.h, id).catch(() => null));
});
const saveManual = () => {
if (running && event) {
// A running feed: only its start moves.
const seg = [...(d.seg || [])] as Segment[];
if (seg[0]) seg[0] = [seg[0][0], start, seg[0][2]];
return save("breast", start, { ...d, seg }, undefined);
}
if (!left && !right) return save("breast", start, d, start);
const unchanged = event && start === event.start && left === Math.round(totals.L / 60) && right === Math.round(totals.R / 60);
if (unchanged) return save("breast", start, d, event!.end ?? start);
const seg: Segment[] = [];
let t = start;
for (const [side, mins] of [["L", left], ["R", right]] as [Side, number][]) {
if (!mins) continue;
seg.push([side, t, t + Math.round(mins * 60)]);
t += Math.round(mins * 60);
}
return save("breast", start, { ...d, seg }, t);
};
if (!event) {
return (
<Sheet title="Breastfeed" sheet={sheet}>
<p className="muted">{prevSide ? `The last feed ended on the ${prevSide === "L" ? "left" : "right"}, so start on the ${suggest === "L" ? "left" : "right"}.` : "Pick the side to start on. The timer runs on every phone in the family."}</p>
<div className="big-pair">
{(["L", "R"] as Side[]).map((s) => (
<button key={s} type="button" className={`big-btn ${s === suggest ? "primary" : ""}`} onClick={() => begin(s)} disabled={busy}>
<span className="big-btn-title">Start {s === "L" ? "left" : "right"}</span>
{s === suggest && prevSide && <span className="big-btn-sub">Suggested</span>}
</button>
))}
</div>
<Divider>Or log a feed that already happened</Divider>
<WhenField label="Started" value={start} onChange={setStart} />
<div className="two">
<Stepper label="Left" value={left} onChange={setLeft} step={1} unit="min" max={180} />
<Stepper label="Right" value={right} onChange={setRight} step={1} unit="min" max={180} />
</div>
<Err error={error} />
<button type="button" className="btn btn-line btn-lg btn-block" onClick={saveManual} disabled={busy}>Save past feed</button>
</Sheet>
);
}
return (
<Sheet title={running ? "Feed in progress" : "Breastfeed"} sheet={sheet} footer={<Foot busy={busy} onSave={saveManual} onDelete={remove} />}>
<WhenField label="Started" value={start} onChange={setStart} />
{running ? <p className="muted small">The timer keeps running. Change the start if you began before tapping Start.</p> : (
<div className="two">
<Stepper label="Left" value={left} onChange={setLeft} step={1} unit="min" max={180} />
<Stepper label="Right" value={right} onChange={setRight} step={1} unit="min" max={180} />
</div>
)}
<Err error={error} />
</Sheet>
);
}
// ---------- Sleep ----------
function SleepSheet(props: Props) {
const { ctx, event } = props;
const [u] = useUnits();
const { sheet, busy, error, save, remove, run } = useSave(props, u);
const running = event ? isRunning(event) : false;
const asleep = !event && ctx.baby ? ctx.events.find((e) => e.type === "sleep" && e.baby === ctx.baby!.id && isRunning(e)) : undefined;
const [start, setStart] = useState(event?.start ?? nowSec() - 60 * 60);
const [end, setEnd] = useState(event?.end ?? nowSec());
const [note, setNote] = useState(event ? parseData(event.data).note || "" : "");
const begin = () => run(async () => {
if (!ctx.baby) throw new Error("Add a baby first, under Family.");
const id = await startSleep(ctx.h, ctx.baby.id);
toast("Sleep timer started. Tap Woke up when they wake.", () => void deleteEvent(ctx.h, id).catch(() => null));
});
const wake = (e: BabyEvent) => run(async () => {
await stopTimer(ctx.h, e, parseData(e.data));
toast("Saved the sleep.", () => void reopenTimer(ctx.h, e).catch(() => null));
});
const data = (): EventData => (note.trim() ? { note: note.trim() } : {});
if (asleep) {
return (
<Sheet title="Sleep" sheet={sheet}>
<p className="muted">Asleep since {clockTime(asleep.start)}.</p>
<button type="button" className="big-btn primary block" onClick={() => wake(asleep)} disabled={busy}><span className="big-btn-title">Woke up now</span></button>
<Err error={error} />
</Sheet>
);
}
if (!event) {
return (
<Sheet title="Sleep" sheet={sheet}>
<button type="button" className="big-btn primary block" onClick={begin} disabled={busy}>
<span className="big-btn-title">Fell asleep now</span>
<span className="big-btn-sub">Starts a timer everyone in the family sees</span>
</button>
<Divider>Or log a sleep that already happened</Divider>
<WhenField label="Fell asleep" value={start} onChange={setStart} />
<WhenField label="Woke up" value={end} onChange={setEnd} />
<Err error={error} />
<button type="button" className="btn btn-line btn-lg btn-block" onClick={() => save("sleep", start, data(), end)} disabled={busy}>Save past sleep</button>
</Sheet>
);
}
return (
<Sheet title={running ? "Sleep in progress" : "Sleep"} sheet={sheet} footer={<Foot busy={busy} onSave={() => save("sleep", start, data(), running ? undefined : end)} onDelete={remove} />}>
<WhenField label="Fell asleep" value={start} onChange={setStart} />
{!running && <WhenField label="Woke up" value={end} onChange={setEnd} />}
<NoteField value={note} onChange={setNote} />
<Err error={error} />
</Sheet>
);
}
// ---------- Diapers ----------
/** Optional color for a dirty diaper; tap the picked one again to clear it. */
function ColorPick({ value, onChange }: { value?: DiaperColor; onChange: (c: DiaperColor | undefined) => void }) {
return (
<div className="field"><span className="label">Color <em>optional, for dirty diapers</em></span>
<div className="presets" role="group" aria-label="Diaper color">
{DIAPER_COLORS.map((c) => (
<button key={c} type="button" className="preset" aria-pressed={value === c} onClick={() => onChange(value === c ? undefined : c)}>
<i className={`dot-color c-${c}`} aria-hidden="true" />{c === "pale" ? "Pale or white" : c[0].toUpperCase() + c.slice(1)}
</button>
))}
</div>
</div>
);
}
function DiaperSheet(props: Props) {
const { event } = props;
const [u] = useUnits();
const { sheet, busy, error, save, remove } = useSave(props, u);
const d = event ? parseData(event.data) : {};
const [at, setAt] = useState(event?.start ?? nowSec());
const [kind, setKind] = useState<"wet" | "dirty" | "both">(d.kind || "wet");
const [color, setColor] = useState<DiaperColor | undefined>(d.color);
const [note, setNote] = useState(d.note || "");
const [more, setMore] = useState(Boolean(event));
const data = (k: typeof kind): EventData => ({ kind: k, ...(color && k !== "wet" ? { color } : {}), ...(note.trim() ? { note: note.trim() } : {}) });
if (!event) {
return (
<Sheet title="Diaper" sheet={sheet}>
{more ? <><WhenField value={at} onChange={setAt} /><ColorPick value={color} onChange={setColor} /><NoteField value={note} onChange={setNote} /><p className="muted">Then tap one to save.</p></> : (
<p className="muted">Tap one to save it for now. <button type="button" className="link-btn" onClick={() => setMore(true)}>Change the time or add details</button></p>
)}
<div className="big-trio">
{(["wet", "dirty", "both"] as const).map((k) => (
<button key={k} type="button" className="big-btn" onClick={() => save("diaper", at, data(k))} disabled={busy}>
<span className="big-btn-title">{k === "wet" ? "Wet" : k === "dirty" ? "Dirty" : "Both"}</span>
</button>
))}
</div>
<Err error={error} />
</Sheet>
);
}
return (
<Sheet title="Diaper" sheet={sheet} footer={<Foot busy={busy} onSave={() => save("diaper", at, data(kind))} onDelete={remove} />}>
<WhenField value={at} onChange={setAt} />
<Choice label="Kind" value={kind} onChange={setKind} options={[{ value: "wet", label: "Wet" }, { value: "dirty", label: "Dirty" }, { value: "both", label: "Both" }]} />
{kind !== "wet" && <ColorPick value={color} onChange={setColor} />}
<NoteField value={note} onChange={setNote} />
<Err error={error} />
</Sheet>
);
}
// ---------- Bottle, pump, solids, growth, medicine, temperature, note ----------
function DetailSheet(props: Props) {
const { ctx, type, event } = props;
const [u] = useUnits();
const { sheet, busy, error, setError, save, remove } = useSave(props, u);
const d = event ? parseData(event.data) : {};
const mine = ctx.events.filter((e) => e.baby === ctx.baby?.id).sort((a, b) => b.start - a.start);
const lastOf = (t: EventType) => mine.find((e) => e.type === t);
const oz = u.vol === "oz";
const volStep = oz ? 0.5 : 10;
const volDefault = (ml: number | undefined, fallbackMl: number) => Number(fromMl(ml ?? fallbackMl, u.vol).toFixed(oz ? 1 : 0));
const lastBottle = parseData(lastOf("bottle")?.data);
const [at, setAt] = useState(event?.start ?? nowSec());
const [note, setNote] = useState(d.note || "");
const [milk, setMilk] = useState<"formula" | "breast">(d.milk || lastBottle.milk || "formula");
const [amount, setAmount] = useState(volDefault(event ? d.ml : lastBottle.ml, 90));
const [pl, setPl] = useState(volDefault(d.l, event ? 0 : 60));
const [pr, setPr] = useState(volDefault(d.r, event ? 0 : 60));
const [food, setFood] = useState(d.food || "");
const [foodAmount, setFoodAmount] = useState(d.amount || "");
const [kg, setKg] = useState(d.kg ? (u.weight === "kg" ? String(d.kg) : "") : "");
const [lb, setLb] = useState(d.kg && u.weight === "lb" ? String(Math.floor(d.kg / KG_PER_LB)) : "");
const [lbOz, setLbOz] = useState(d.kg && u.weight === "lb" ? String(Number(((d.kg / KG_PER_LB % 1) * 16).toFixed(1))) : "");
const lenIn = (cm?: number) => (cm ? String(u.length === "in" ? Number((cm / CM_PER_IN).toFixed(1)) : cm) : "");
const [len, setLen] = useState(lenIn(d.cm));
const [head, setHead] = useState(lenIn(d.head));
const [medName, setMedName] = useState(d.name || "");
const [dose, setDose] = useState(d.dose || "");
const [temp, setTemp] = useState(d.c != null ? Number((u.temp === "f" ? d.c * 9 / 5 + 32 : d.c).toFixed(1)) : u.temp === "f" ? 98.6 : 37.0);
const [text, setText] = useState(d.text || "");
const withNote = (x: EventData): EventData => (note.trim() ? { ...x, note: note.trim() } : x);
const pastMeds = [...new Set(ctx.events.filter((e) => e.type === "medicine").map((e) => parseData(e.data).name).filter(Boolean))] as string[];
const onSave = () => {
const parse = (s: string) => Number(s.replace(",", "."));
switch (type) {
case "bottle": {
const ml = Math.round(toMl(amount, u.vol));
if (!ml) return setError("Enter the amount.");
return save(type, at, withNote({ milk, ml }));
}
case "pump":
if (!pl && !pr) return setError("Enter the amount pumped.");
return save(type, at, withNote({ l: Math.round(toMl(pl, u.vol)), r: Math.round(toMl(pr, u.vol)) }));
case "solids":
return save(type, at, withNote({ food: food.trim().slice(0, 120) || "Food", ...(foodAmount.trim() ? { amount: foodAmount.trim().slice(0, 80) } : {}) }));
case "growth": {
const out: EventData = {};
const w = u.weight === "kg" ? parse(kg) : (parse(lb || "0") + parse(lbOz || "0") / 16) * KG_PER_LB;
if (w > 0) out.kg = Number(w.toFixed(3));
const toCm = (s: string) => (u.length === "in" ? parse(s) * CM_PER_IN : parse(s));
if (len && toCm(len) > 0) out.cm = Number(toCm(len).toFixed(1));
if (head && toCm(head) > 0) out.head = Number(toCm(head).toFixed(1));
if (!out.kg && !out.cm && !out.head) return setError("Enter a weight, length or head size.");
return save(type, at, withNote(out));
}
case "medicine":
return save(type, at, withNote({ name: medName.trim().slice(0, 80) || "Medicine", ...(dose.trim() ? { dose: dose.trim().slice(0, 80) } : {}) }));
case "temp":
return save(type, at, withNote({ c: Number(toC(temp, u.temp).toFixed(1)) }));
case "note":
return save(type, at, { text: text.trim().slice(0, 1000) || "Note" });
}
};
const presets = oz ? [2, 3, 4, 5, 6] : [60, 90, 120, 150, 180];
return (
<Sheet title={TYPE_LABEL[type]} sheet={sheet} footer={<Foot busy={busy} onSave={onSave} onDelete={remove} />}>
{type === "bottle" && (
<>
<Choice label="Milk" value={milk} onChange={setMilk} options={[{ value: "formula", label: "Formula" }, { value: "breast", label: "Breast milk" }]} />
<Stepper label="Amount" value={amount} onChange={setAmount} step={volStep} unit={u.vol} max={oz ? 20 : 600} digits={oz ? 1 : 0} />
<div className="presets" role="group" aria-label="Common amounts">
{presets.map((p) => <button key={p} type="button" className="preset" aria-pressed={amount === p} onClick={() => setAmount(p)}>{p} {u.vol}</button>)}
</div>
</>
)}
{type === "pump" && (
<div className="two">
<Stepper label="Left" value={pl} onChange={setPl} step={volStep} unit={u.vol} max={oz ? 20 : 600} digits={oz ? 1 : 0} />
<Stepper label="Right" value={pr} onChange={setPr} step={volStep} unit={u.vol} max={oz ? 20 : 600} digits={oz ? 1 : 0} />
</div>
)}
{type === "solids" && (
<>
<label className="field"><span>Food</span><input className="input" value={food} onChange={(e) => setFood(e.target.value)} maxLength={120} placeholder="Banana, oatmeal, peas" autoFocus={!event} /></label>
<label className="field"><span>How much <em>optional</em></span><input className="input" value={foodAmount} onChange={(e) => setFoodAmount(e.target.value)} maxLength={80} placeholder="3 spoons, half a jar" /></label>
</>
)}
{type === "growth" && (
<>
{u.weight === "kg" ? (
<label className="field"><span>Weight</span><div className="unit-input"><input className="input" inputMode="decimal" value={kg} onChange={(e) => setKg(e.target.value)} placeholder="4.2" /><span>kg</span></div></label>
) : (
<div className="field"><span className="label">Weight</span>
<div className="two">
<div className="unit-input"><input className="input" inputMode="numeric" aria-label="Pounds" value={lb} onChange={(e) => setLb(e.target.value)} placeholder="9" /><span>lb</span></div>
<div className="unit-input"><input className="input" inputMode="decimal" aria-label="Ounces" value={lbOz} onChange={(e) => setLbOz(e.target.value)} placeholder="4" /><span>oz</span></div>
</div>
</div>
)}
<div className="two">
<label className="field"><span>Length</span><div className="unit-input"><input className="input" inputMode="decimal" value={len} onChange={(e) => setLen(e.target.value)} placeholder={u.length === "in" ? "21.5" : "55"} /><span>{u.length}</span></div></label>
<label className="field"><span>Head</span><div className="unit-input"><input className="input" inputMode="decimal" value={head} onChange={(e) => setHead(e.target.value)} placeholder={u.length === "in" ? "15" : "38"} /><span>{u.length}</span></div></label>
</div>
</>
)}
{type === "medicine" && (
<>
<label className="field"><span>Medicine</span><input className="input" list="med-names" value={medName} onChange={(e) => setMedName(e.target.value)} maxLength={80} placeholder="Vitamin D, acetaminophen" autoFocus={!event} /></label>
<datalist id="med-names">{pastMeds.map((n) => <option key={n} value={n} />)}</datalist>
<label className="field"><span>Dose <em>optional</em></span><input className="input" value={dose} onChange={(e) => setDose(e.target.value)} maxLength={80} placeholder="1 drop, 2.5 ml" /></label>
</>
)}
{type === "temp" && (
<Stepper label="Temperature" value={temp} onChange={setTemp} step={0.1} unit={u.temp === "f" ? "°F" : "°C"} min={u.temp === "f" ? 90 : 32} max={u.temp === "f" ? 110 : 43} digits={1} />
)}
{type === "note" && (
<label className="field"><span>Note</span><textarea className="textarea" value={text} onChange={(e) => setText(e.target.value)} maxLength={1000} placeholder="First smile, rolled over, a question for the doctor" autoFocus={!event} /></label>
)}
<WhenField label={type === "growth" ? "Measured" : "When"} value={at} onChange={setAt} />
{type !== "note" && <NoteField value={note} onChange={setNote} />}
{type === "temp" && <p className="hint">Baby is a log, not a medical device. Call your doctor about a fever, especially in a baby under 3 months.</p>}
{type === "medicine" && <p className="hint">Check doses with your doctor or pharmacist. Baby only records what you enter.</p>}
<Err error={error} />
</Sheet>
);
}
export const TILE_ICON: Record<EventType, string> = {
breast: "breast", bottle: "bottle", pump: "pump", solids: "solids", sleep: "sleep",
diaper: "diaper", growth: "growth", medicine: "medicine", temp: "temp", note: "note",
};
export function TypeIcon({ type, size = 22 }: { type: EventType; size?: number }) {
return <span className={`type-icon t-${type}`}><Icon name={TILE_ICON[type]} size={size} /></span>;
}
Change history
Quality pass: undo on timers, sleep by hour chart, diaper color, fixes
Bounded source sync
Bounded source sync
Bounded source sync