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/Today.tsx
// Today: timers that are running, time since the last feed, diaper and sleep, big buttons to log,
// today's totals and the latest entries. Everything updates live for everyone in the family.
import { useState } from "react";
import { useUnits } from "./lib/prefs";
import { switchSide, pauseFeed, resumeFeed, stopTimer, reopenTimer } from "./lib/family";
import { explain } from "./lib/bounded";
import { onLink } from "./lib/router";
import { Icon, toast, useNow, clockTime, localDayStart, addDays } from "./ui";
import { LogSheet, TypeIcon } from "./LogSheets";
import {
TYPE_LABEL, parseData, sideTotals, openSide, lastSide, isRunning, describe, snapshot, totals, fmtAgo, fmtClock, fmtVol, fmtShort,
type BabyEvent, type EventType, type Side,
} from "../functions/lib/baby";
import type { FamilyCtx } from "./App";
type Open = { type: EventType; event?: BabyEvent } | null;
const MAIN: EventType[] = ["breast", "bottle", "sleep", "diaper"];
const MORE: EventType[] = ["pump", "solids", "growth", "medicine", "temp", "note"];
export function Today({ ctx }: { ctx: FamilyCtx }) {
const [u] = useUnits();
const [open, setOpen] = useState<Open>(null);
const mine = ctx.baby ? ctx.events.filter((e) => e.baby === ctx.baby!.id) : [];
const anyRunning = ctx.events.some(isRunning);
const now = useNow(anyRunning ? 1000 : 20_000);
const snap = snapshot(mine);
const from = localDayStart(now);
const t = totals(mine, from, addDays(from, 1), now);
const others = ctx.events.filter((e) => isRunning(e) && e.baby !== ctx.baby?.id);
const recent = [...mine].sort((a, b) => b.start - a.start).slice(0, 6);
if (!ctx.baby) return null;
const feeding = snap.running.find((e) => e.type === "breast");
return (
<div className="today wrap-app enter">
<div className="today-main">
{snap.running.map((e) => <TimerCard key={e.id} ctx={ctx} event={e} now={now} onEdit={() => setOpen({ type: e.type, event: e })} />)}
{others.map((e) => {
const b = ctx.babies.find((x) => x.id === e.baby);
return b ? (
<button key={e.id} type="button" className="other-timer" onClick={() => ctx.setBaby(b.id)}>
<TypeIcon type={e.type} size={18} /> {b.name}: {e.type === "sleep" ? "asleep" : "feeding"} for {fmtClock(now - e.start)}
</button>
) : null;
})}
<section className="status" aria-label="Since last">
<Status label="Last feed" value={feeding ? (openSide(parseData(feeding.data).seg) ? "Feeding now" : "Feed paused") : snap.lastFeed && since(now - snap.lastFeed.start)}
sub={feeding ? `since ${clockTime(feeding.start)}` : snap.lastFeed && `${describe(snap.lastFeed, u, now).replace(/^.* · /, "")}, ${clockTime(snap.lastFeed.start)}`} />
<Status label="Last diaper" value={snap.lastDiaper && since(now - snap.lastDiaper.start)}
sub={snap.lastDiaper && `${describe(snap.lastDiaper, u, now).replace(/^.* · /, "")}, ${clockTime(snap.lastDiaper.start)}`} />
<Status label="Sleep"
value={snap.asleep ? (now - snap.asleep.start < 60 ? "Asleep" : `Asleep ${fmtShort(now - snap.asleep.start)}`) : snap.lastSleep?.end ? (now - snap.lastSleep.end < 60 ? "Just woke up" : `Awake ${fmtShort(now - snap.lastSleep.end)}`) : null}
sub={snap.asleep ? `since ${clockTime(snap.asleep.start)}` : snap.lastSleep?.end ? `slept ${fmtShort(snap.lastSleep.end - snap.lastSleep.start)}` : null} />
</section>
<section className="log" aria-label="Log">
<div className="tiles-main">
{MAIN.map((type) => {
const run = snap.running.find((e) => e.type === type);
return (
<button key={type} type="button" className={`tile tile-big ${run ? "on" : ""}`} onClick={() => setOpen({ type, event: run && type !== "sleep" ? run : undefined })}>
<TypeIcon type={type} size={26} />
<span className="tile-text">
<strong>{type === "breast" ? "Breastfeed" : TYPE_LABEL[type]}</strong>
<small>{run ? (type === "sleep" ? `Asleep ${fmtClock(now - run.start)}` : `Feeding ${fmtClock(sideTotals(parseData(run.data).seg, now).L + sideTotals(parseData(run.data).seg, now).R)}`) : tileHint(type, mine, now)}</small>
</span>
</button>
);
})}
</div>
<div className="tiles-more">
{MORE.map((type) => (
<button key={type} type="button" className="tile tile-small" onClick={() => setOpen({ type })}>
<TypeIcon type={type} size={20} />
<span>{TYPE_LABEL[type]}</span>
</button>
))}
</div>
</section>
</div>
<div className="today-side">
<section className="card totals-card" aria-label="Today's totals">
<h2 className="card-title">Today so far</h2>
<dl className="totals">
<div><dt>Feeds</dt><dd>{t.feeds}<small>{t.feeds ? [t.breastFeeds ? `${t.breastFeeds} breast` : "", t.bottles ? `${t.bottles} bottle` : ""].filter(Boolean).join(", ") : ""}</small></dd></div>
<div><dt>Sleep</dt><dd>{t.sleepSec >= 60 ? fmtShort(t.sleepSec) : "0m"}<small>{t.sleeps ? `${t.sleeps} sleep${t.sleeps === 1 ? "" : "s"} started` : ""}</small></dd></div>
<div><dt>Diapers</dt><dd>{t.diapers}<small>{t.diapers ? `${t.wet} wet, ${t.dirty} dirty` : ""}</small></dd></div>
{t.bottles > 0 && <div><dt>Bottle</dt><dd>{fmtVol(t.bottleMl, u.vol)}</dd></div>}
{t.breastFeeds > 0 && <div><dt>Breast</dt><dd>{fmtShort(t.breastSec)}</dd></div>}
{t.pumpMl > 0 && <div><dt>Pumped</dt><dd>{fmtVol(t.pumpMl, u.vol)}</dd></div>}
{t.solids > 0 && <div><dt>Solids</dt><dd>{t.solids}</dd></div>}
</dl>
</section>
<section className="recent" aria-label="Latest entries">
<div className="spread"><h2 className="card-title">Latest</h2><a className="small" href="/app/timeline" onClick={onLink}>Full timeline</a></div>
{recent.length ? (
<ul className="entries">
{recent.map((e, i) => <EntryRow key={e.id} ctx={ctx} event={e} now={now} index={i} onOpen={() => setOpen({ type: e.type, event: e })} showDay />)}
</ul>
) : (
<div className="empty-state">
<img className="art" src="/art/card-baby.webp" alt="" width="160" height="136" />
<h3>Nothing logged yet</h3>
<p>Tap Breastfeed, Bottle, Sleep or Diaper. Everyone in the family sees it right away.</p>
</div>
)}
</section>
<aside className="also">
<p><a href="https://monitor-free.openapps.xyz/">Monitor</a> turns two phones into a free baby monitor. <a href="https://still-free.openapps.xyz/sounds">Still</a> plays white noise and rain for naps.</p>
<p className="muted small">Baby is a log, not a medical device. It makes no predictions and gives no medical advice. Ask your doctor about anything that worries you.</p>
</aside>
</div>
{open && <LogSheet ctx={ctx} type={open.type} event={open.event} onClose={() => setOpen(null)} />}
</div>
);
}
/** "Just now" or "2h 15m ago". */
const since = (sec: number) => { const ago = fmtAgo(sec); return ago.charAt(0).toUpperCase() + ago.slice(1); };
function Status({ label, value, sub }: { label: string; value: string | null | undefined; sub?: string | null }) {
return (
<div className="status-cell">
<span className="status-label">{label}</span>
<strong className={`status-value${value ? "" : " none"}`}>{value || "None yet"}</strong>
{sub && <span className="status-sub">{sub}</span>}
</div>
);
}
function tileHint(type: EventType, mine: BabyEvent[], now: number): string {
const last = mine.filter((e) => e.type === type).sort((a, b) => b.start - a.start)[0];
if (type === "sleep") return "Start a timer";
if (type === "breast") {
// The side to start on is the one the last feed did not end on.
const side = last ? lastSide(parseData(last.data).seg) : null;
return side ? `Next side: ${side === "L" ? "right" : "left"}` : "Left or right";
}
if (!last) return type === "bottle" ? "Amount, milk" : "Wet or dirty";
return since(now - last.start);
}
function TimerCard({ ctx, event, now, onEdit }: { ctx: FamilyCtx; event: BabyEvent; now: number; onEdit: () => void }) {
const [busy, setBusy] = useState(false);
const d = parseData(event.data);
const baby = ctx.babies.find((b) => b.id === event.baby);
const act = async (fn: () => Promise<unknown>) => {
setBusy(true);
try { await fn(); } catch (err) { toast(explain(err)); } finally { setBusy(false); }
};
// Stopping by mistake is easy at 3 am, so the toast can put the timer back as it was.
const stop = (text: string) => act(async () => {
await stopTimer(ctx.h, event, d);
toast(text, () => void reopenTimer(ctx.h, event).catch((err) => toast(explain(err))));
});
if (event.type === "sleep") {
return (
<section className="timer card" aria-label="Sleep timer">
<div className="timer-top">
<span className="timer-label"><Icon name="sleep" size={18} /> {baby && ctx.babies.length > 1 ? `${baby.name} is asleep` : "Asleep"}</span>
<button type="button" className="link-btn" onClick={onEdit}>Since {clockTime(event.start)}</button>
</div>
<p className="timer-clock" aria-live="off">{fmtClock(now - event.start)}</p>
<button type="button" className="btn btn-xl btn-block" disabled={busy} onClick={() => stop(`Saved: slept ${fmtShort(now - event.start)}`)}>Woke up</button>
</section>
);
}
const tot = sideTotals(d.seg, now);
const side = openSide(d.seg);
const paused = !side;
const next: Side = (side ?? d.seg?.[d.seg.length - 1]?.[0]) === "L" ? "R" : "L";
return (
<section className={`timer card ${paused ? "paused" : ""}`} aria-label="Feeding timer">
<div className="timer-top">
<span className="timer-label"><Icon name="breast" size={18} /> {paused ? "Paused" : `Feeding on the ${side === "L" ? "left" : "right"}`}</span>
<button type="button" className="link-btn" onClick={onEdit}>Started {clockTime(event.start)}</button>
</div>
<p className="timer-clock">{fmtClock(tot.L + tot.R)}</p>
<div className="sides">
{(["L", "R"] as Side[]).map((s) => (
<button key={s} type="button" className="side" aria-pressed={side === s} disabled={busy}
onClick={() => (side === s ? undefined : act(() => (paused ? resumeFeed(ctx.h, event, d, s) : switchSide(ctx.h, event, d))))}>
<span>{s === "L" ? "Left" : "Right"}</span>
<strong>{fmtClock(tot[s])}</strong>
</button>
))}
</div>
<div className="timer-actions">
{paused ? (
<button type="button" className="btn btn-xl" disabled={busy} onClick={() => act(() => resumeFeed(ctx.h, event, d))}><Icon name="play" /> Resume</button>
) : (
<button type="button" className="btn btn-xl" disabled={busy} onClick={() => act(() => switchSide(ctx.h, event, d))}><Icon name="swap" /> Switch to {next === "L" ? "left" : "right"}</button>
)}
<div className="two">
{!paused && <button type="button" className="btn btn-line btn-lg" disabled={busy} onClick={() => act(() => pauseFeed(ctx.h, event, d))}><Icon name="pause" /> Pause</button>}
<button type="button" className={`btn btn-line btn-lg ${paused ? "span-2" : ""}`} disabled={busy} onClick={() => stop(`Saved: fed ${fmtShort(tot.L + tot.R)}`)}><Icon name="check" /> Done</button>
</div>
</div>
</section>
);
}
export function EntryRow({ ctx, event, now, onOpen, showDay, index = 0 }: { ctx: FamilyCtx; event: BabyEvent; now: number; onOpen: () => void; showDay?: boolean; index?: number }) {
const [u] = useUnits();
const d = parseData(event.data);
const desc = describe(event, u, now);
const [title, detail] = desc.includes(" · ") ? [desc.slice(0, desc.indexOf(" · ")), desc.slice(desc.indexOf(" · ") + 3)] : [desc, ""];
const today = localDayStart(now);
const day = event.start < today ? (event.start >= addDays(today, -1) ? "Yesterday " : new Date(event.start * 1000).toLocaleDateString(undefined, { month: "short", day: "numeric" }) + " ") : "";
return (
<li className="enter" style={{ ["--i" as string]: index }}>
<button type="button" className="entry" onClick={onOpen}>
<TypeIcon type={event.type} size={20} />
<span className="entry-main">
<span className="entry-title">{title}{detail && <span className="entry-detail"> {detail}</span>}{isRunning(event) && <span className="live-dot"> running</span>}</span>
<span className="entry-meta">
{showDay ? day : ""}{clockTime(event.start)}{event.end && event.type === "sleep" ? ` to ${clockTime(event.end)}` : ""} · {ctx.nameOf(event.by)}
{d.note ? ` · ${d.note}` : ""}
</span>
</span>
<Icon name="next" size={16} />
</button>
</li>
);
}
Change history
Quality pass: undo on timers, sleep by hour chart, diaper color, fixes
Bounded source sync
Bounded source sync
Bounded source sync