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/ui.tsx
// Small shared pieces: bottom sheet, toast, copy and share, icons, steppers, choice rows, a ticking clock.
import { useEffect, useRef, useState, type ReactNode } from "react";
/** A sheet's open state with an animated close: call close(), and onClose runs once the exit animation ends. */
export function useSheet(onClose: () => void) {
const [closing, setClosing] = useState(false);
return { closing, close: () => setClosing(true), onClosed: onClose };
}
export type SheetHandle = ReturnType<typeof useSheet>;
const FOCUSABLE = "a[href], button:not(:disabled), input:not(:disabled), select:not(:disabled), textarea:not(:disabled), [tabindex]:not([tabindex='-1'])";
/** A dialog: a centered card on wide screens, a bottom sheet on phones. */
export function Sheet({ title, sheet, children, footer }: { title: string; sheet: SheetHandle; children: ReactNode; footer?: ReactNode }) {
const ref = useRef<HTMLDivElement>(null);
const close = useRef(sheet.close);
close.current = sheet.close;
useEffect(() => {
const prev = document.activeElement as HTMLElement | null;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") close.current();
if (e.key !== "Tab" || !ref.current) return;
// Keep Tab inside the sheet.
const items = [...ref.current.querySelectorAll<HTMLElement>(FOCUSABLE)];
const first = items[0], last = items[items.length - 1];
if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last?.focus(); }
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first?.focus(); }
};
document.addEventListener("keydown", onKey);
document.body.classList.add("sheet-open");
// React's autoFocus has already focused a field; otherwise focus the sheet itself.
if (!ref.current?.contains(document.activeElement)) ref.current?.focus();
return () => {
document.removeEventListener("keydown", onKey);
document.body.classList.remove("sheet-open");
prev?.focus?.();
};
}, []);
return (
<div className={`dialog-backdrop${sheet.closing ? " leaving" : ""}`}
onMouseDown={(e) => { if (e.target === e.currentTarget) sheet.close(); }}
onAnimationEnd={(e) => { if (sheet.closing && e.target === ref.current) sheet.onClosed(); }}>
<div className="dialog" role="dialog" aria-modal="true" aria-labelledby="sheet-title" ref={ref} tabIndex={-1}>
<div className="dialog-head">
<h2 className="dialog-title" id="sheet-title">{title}</h2>
<button className="btn btn-quiet btn-icon" type="button" onClick={sheet.close} aria-label="Close"><Icon name="close" /></button>
</div>
<div className="dialog-body">{children}</div>
{footer && <div className="dialog-foot">{footer}</div>}
</div>
</div>
);
}
/** Asks before something that cannot be undone. */
export function Confirm({ title, text, action, danger, onConfirm, onClose }: {
title: string; text: string; action: string; danger?: boolean; onConfirm: () => Promise<unknown>; onClose: () => void;
}) {
const sheet = useSheet(onClose);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const go = async () => {
setBusy(true);
setError(null);
try { await onConfirm(); sheet.close(); } catch (err) { setError(err instanceof Error ? err.message : String(err)); setBusy(false); }
};
return (
<Sheet title={title} sheet={sheet} footer={<>
<button type="button" className="btn btn-line" onClick={sheet.close}>Cancel</button>
<button type="button" className={`btn ${danger ? "btn-danger-solid" : ""}`} onClick={go} aria-busy={busy || undefined}>{action}</button>
</>}>
<p>{text}</p>
{error && <p className="notice notice-error" role="alert">{error}</p>}
</Sheet>
);
}
let pushToast: ((text: string, undo?: () => void) => void) | null = null;
/** A short note at the bottom of the screen, with an optional Undo. */
export function toast(text: string, undo?: () => void) { pushToast?.(text, undo); }
export function Toaster() {
const [item, setItem] = useState<{ text: string; undo?: () => void; key: number; leaving?: boolean } | null>(null);
useEffect(() => {
let timer: number | undefined;
pushToast = (text, undo) => {
setItem({ text, undo, key: Date.now() });
window.clearTimeout(timer);
timer = window.setTimeout(() => setItem((t) => (t ? { ...t, leaving: true } : t)), undo ? 6000 : 3000);
};
return () => { pushToast = null; window.clearTimeout(timer); };
}, []);
if (!item) return null;
return (
<div key={item.key} className={`toast${item.leaving ? " leaving" : ""}`} role="status"
onAnimationEnd={() => { if (item.leaving) setItem(null); }}>
<span>{item.text}</span>
{item.undo && <button type="button" onClick={() => { item.undo?.(); setItem({ ...item, undo: undefined, leaving: true }); }}>Undo</button>}
</div>
);
}
/** Placeholder shapes in the app's layout while the log opens, so nothing jumps when it arrives. */
export function AppSkeleton() {
return (
<div className="wrap-app app-skeleton" aria-busy="true" aria-label="Opening the log">
<div className="baby-bar"><span className="skeleton" style={{ width: 170, height: 34 }} /><span className="skeleton" style={{ width: 44, height: 44, borderRadius: 12 }} /></div>
<div className="today">
<div className="today-main">
<span className="skeleton" style={{ height: 86, borderRadius: 16 }} />
<div className="tiles-main">{[0, 1, 2, 3].map((i) => <span key={i} className="skeleton" style={{ height: 92, borderRadius: 16 }} />)}</div>
</div>
<div className="today-side"><span className="skeleton" style={{ height: 150, borderRadius: 18 }} /></div>
</div>
</div>
);
}
export async function copy(text: string, done = "Copied") {
try {
await navigator.clipboard.writeText(text);
toast(done);
} catch {
window.prompt("Copy this:", text);
}
}
export async function share(url: string, title: string, text: string) {
if (navigator.share && matchMedia("(pointer: coarse)").matches) {
try { await navigator.share({ title, text, url }); return; } catch { /* cancelled */ }
}
await copy(url, "Link copied. Send it to your partner or a caregiver.");
}
/** The current time in unix seconds, ticking every `ms`. */
export function useNow(ms = 1000): number {
const [now, setNow] = useState(() => Math.floor(Date.now() / 1000));
useEffect(() => {
const tick = () => setNow(Math.floor(Date.now() / 1000));
const t = window.setInterval(tick, ms);
document.addEventListener("visibilitychange", tick);
return () => { window.clearInterval(t); document.removeEventListener("visibilitychange", tick); };
}, [ms]);
return now;
}
/** A row of choices where one is picked. */
export function Choice<T extends string>({ label, options, value, onChange }: { label: string; options: { value: T; label: string }[]; value: T; onChange: (v: T) => void }) {
return (
<div className="choice" role="radiogroup" aria-label={label} style={{ ["--n" as string]: String(options.length) }}>
{options.map((o) => (
<button key={o.value} type="button" role="radio" aria-checked={o.value === value} onClick={() => onChange(o.value)}>{o.label}</button>
))}
</div>
);
}
/** A number with big minus and plus buttons, for one-handed amounts. */
export function Stepper({ label, value, onChange, step, unit, min = 0, max = 9999, digits = 0 }: {
label: string; value: number; onChange: (v: number) => void; step: number; unit: string; min?: number; max?: number; digits?: number;
}) {
const clamp = (v: number) => Math.min(max, Math.max(min, Number(v.toFixed(digits))));
return (
<div className="stepper">
<span className="stepper-label">{label}</span>
<div className="stepper-row">
<button type="button" className="stepper-btn" aria-label={`Less ${label.toLowerCase()}`} onClick={() => onChange(clamp(value - step))}><Icon name="minus" size={22} /></button>
<label className="stepper-value">
<input inputMode="decimal" aria-label={`${label} in ${unit}`} value={Number.isFinite(value) ? String(value) : ""}
onChange={(e) => { const v = Number(e.target.value.replace(",", ".")); onChange(Number.isFinite(v) ? Math.min(max, Math.max(0, v)) : 0); }} />
<span>{unit}</span>
</label>
<button type="button" className="stepper-btn" aria-label={`More ${label.toLowerCase()}`} onClick={() => onChange(clamp(value + step))}><Icon name="plus" size={22} /></button>
</div>
</div>
);
}
const PATHS: Record<string, ReactNode> = {
breast: <path d="M12 3.5c3.2 4.2 6 7.4 6 10.6a6 6 0 0 1-12 0c0-3.2 2.8-6.4 6-10.6Z" />,
bottle: <><path d="M9.5 3h5M10.5 3v2.6L8.6 8A2 2 0 0 0 8 9.4V19a2 2 0 0 0 2 2h4a2 2 0 0 0 2-2V9.4a2 2 0 0 0-.6-1.4l-1.9-2.4V3" /><path d="M8 12.5h4M8 16h4" /></>,
pump: <><path d="M6.5 9.5h11l-1.6 10.1a1.6 1.6 0 0 1-1.6 1.4H9.7a1.6 1.6 0 0 1-1.6-1.4Z" /><path d="M9 9.5V7a3 3 0 0 1 6 0v2.5M9.5 14h5" /></>,
solids: <><ellipse cx="9" cy="7" rx="3" ry="4" /><path d="M9 11v10M16 3v18M16 3c1.8 1 2.5 3 2.5 5s-1 3-2.5 3" /></>,
sleep: <path d="M19.5 14.5A8 8 0 0 1 9.5 4.5a8 8 0 1 0 10 10Z" />,
diaper: <><path d="M3.5 7.5h17V10c0 4.7-3.8 8.5-8.5 8.5S3.5 14.7 3.5 10Z" /><path d="M8 7.5c0 2.2 1.8 4 4 4s4-1.8 4-4" /></>,
growth: <><path d="M3.5 16.5 16.5 3.5l4 4-13 13Z" /><path d="M7.5 12.5 9 14M10.5 9.5 12 11M13.5 6.5 15 8" /></>,
medicine: <><path d="M10.6 20.4a4.6 4.6 0 0 1-6.5-6.5l5.8-5.8a4.6 4.6 0 0 1 6.5 6.5Z" /><path d="m7 11 6 6" /></>,
temp: <><path d="M14 14.2V5a2 2 0 0 0-4 0v9.2a4 4 0 1 0 4 0Z" /><path d="M12 15.5V9" /></>,
note: <><path d="M4 20h4L19.2 8.8a2.8 2.8 0 0 0-4-4L4 16Z" /><path d="m13.5 6.5 4 4" /></>,
today: <><circle cx="12" cy="12" r="8.5" /><path d="M12 7v5l3 2" /></>,
timeline: <path d="M8 6h12M8 12h12M8 18h12M4 6h.01M4 12h.01M4 18h.01" />,
charts: <path d="M4 20V11M10 20V5M16 20v-6M2.5 20.5h19" />,
family: <><circle cx="9" cy="8" r="3" /><path d="M3.5 19c.6-3 2.8-4.8 5.5-4.8s4.9 1.8 5.5 4.8" /><circle cx="16.5" cy="9" r="2.3" /><path d="M16.5 13.5c2.1 0 3.6 1.3 4 3.6" /></>,
close: <path d="M6 6l12 12M18 6 6 18" />,
back: <path d="M15 5l-7 7 7 7" />,
next: <path d="M9 5l7 7-7 7" />,
plus: <path d="M12 5v14M5 12h14" />,
minus: <path d="M5 12h14" />,
share: <path d="M12 3v12M7 8l5-5 5 5M5 13v6h14v-6" />,
swap: <path d="M7 7h12l-3-3M17 17H5l3 3" />,
pause: <path d="M9 5.5v13M15 5.5v13" />,
play: <path d="M8 5.5v13l10.5-6.5Z" />,
check: <path d="M5 12.5l4.5 4.5L19 7.5" />,
down: <path d="M6 9l6 6 6-6" />,
moon: <path d="M19.5 14.5A8 8 0 0 1 9.5 4.5a8 8 0 1 0 10 10Z" />,
sun: <><circle cx="12" cy="12" r="4" /><path d="M12 2.5v2M12 19.5v2M2.5 12h2M19.5 12h2M5.3 5.3l1.4 1.4M17.3 17.3l1.4 1.4M5.3 18.7l1.4-1.4M17.3 6.7l1.4-1.4" /></>,
print: <><path d="M7 9V3.5h10V9M7 17H4.5V10a1 1 0 0 1 1-1h13a1 1 0 0 1 1 1v7H17" /><path d="M7 14h10v6.5H7Z" /></>,
download: <path d="M12 4v11M7 10l5 5 5-5M5 20h14" />,
key: <><circle cx="8" cy="15" r="4" /><path d="m11 12 8.5-8.5M16 7l2.5 2.5M14 9l2 2" /></>,
edit: <path d="M4 20h4L19.2 8.8a2.8 2.8 0 0 0-4-4L4 16Z" />,
};
export type IconName = keyof typeof PATHS;
export function Icon({ name, size = 20 }: { name: IconName | string; size?: number }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
{PATHS[name]}
</svg>
);
}
/** Local date and time for a datetime-local input. */
export function toLocalInput(sec: number): string {
const d = new Date(sec * 1000);
const p = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}T${p(d.getHours())}:${p(d.getMinutes())}`;
}
export function fromLocalInput(v: string): number {
const t = new Date(v).getTime();
return Number.isFinite(t) ? Math.floor(t / 1000) : NaN;
}
export const clockTime = (sec: number) => new Date(sec * 1000).toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" });
/** "Today", "Yesterday" or "Mon, Oct 13". */
export function dayLabel(sec: number): string {
const d = new Date(sec * 1000);
const today = new Date();
const y = new Date(); y.setDate(today.getDate() - 1);
if (d.toDateString() === today.toDateString()) return "Today";
if (d.toDateString() === y.toDateString()) return "Yesterday";
return d.toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric", ...(d.getFullYear() !== today.getFullYear() ? { year: "numeric" } : {}) });
}
/** Local midnight of the day containing `sec`, in this device's time zone. */
export function localDayStart(sec: number): number {
const d = new Date(sec * 1000);
d.setHours(0, 0, 0, 0);
return Math.floor(d.getTime() / 1000);
}
/** Local midnight `n` days after (or before) a midnight, safe across daylight saving changes. */
export function addDays(midnight: number, n: number): number {
const d = new Date(midnight * 1000);
d.setDate(d.getDate() + n);
d.setHours(0, 0, 0, 0);
return Math.floor(d.getTime() / 1000);
}
/** "3 weeks old" from YYYY-MM-DD. */
export function ageText(birth: string): string | null {
if (!/^\d{4}-\d{2}-\d{2}$/.test(birth)) return null;
const b = new Date(`${birth}T00:00:00`);
const days = Math.floor((Date.now() - b.getTime()) / 864e5);
if (days < 0) return "Due soon";
if (days === 0) return "Born today";
if (days < 14) return `${days} day${days === 1 ? "" : "s"} old`;
if (days < 7 * 13) return `${Math.floor(days / 7)} weeks old`;
const months = Math.floor(days / 30.44);
return months < 24 ? `${months} months old` : `${Math.floor(months / 12)} years old`;
}
type InstallPrompt = Event & { prompt(): Promise<void>; userChoice: Promise<{ outcome: string }> };
let early: InstallPrompt | null = null;
window.addEventListener("beforeinstallprompt", (e) => { e.preventDefault(); early = e as InstallPrompt; });
export function Install() {
const [prompt, setPrompt] = useState<InstallPrompt | null>(early);
const [installed, setInstalled] = useState(() => matchMedia("(display-mode: standalone)").matches);
useEffect(() => {
const offer = (e: Event) => { e.preventDefault(); setPrompt(e as InstallPrompt); };
const done = () => { setInstalled(true); setPrompt(null); };
window.addEventListener("beforeinstallprompt", offer);
window.addEventListener("appinstalled", done);
return () => { window.removeEventListener("beforeinstallprompt", offer); window.removeEventListener("appinstalled", done); };
}, []);
if (installed) return <p className="muted small">Baby is on your home screen.</p>;
if (prompt) {
return <button type="button" className="btn btn-line" onClick={async () => { await prompt.prompt(); const { outcome } = await prompt.userChoice; if (outcome === "accepted") setInstalled(true); setPrompt(null); }}>Add Baby to your home screen</button>;
}
return <p className="muted small">On iPhone, tap Share, then Add to Home Screen. In Chrome or Edge, choose Install app from the menu.</p>;
}
Change history
Quality pass: undo on timers, sleep by hour chart, diaper color, fixes
Bounded source sync
Bounded source sync
Bounded source sync