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.
functions/lib/baby.ts
// Baby's log logic, shared by the web app and the public API: event shapes, units, timers,
// one-line descriptions and the daily summary. Amounts are stored in metric (ml, kg, cm, degrees C)
// and shown in each person's units. Times are unix seconds.
export const EVENT_TYPES = ["breast", "bottle", "pump", "solids", "sleep", "diaper", "growth", "medicine", "temp", "note"] as const;
export type EventType = (typeof EVENT_TYPES)[number];
export const TYPE_LABEL: Record<EventType, string> = {
breast: "Breastfeed", bottle: "Bottle", pump: "Pump", solids: "Solids", sleep: "Sleep",
diaper: "Diaper", growth: "Growth", medicine: "Medicine", temp: "Temperature", note: "Note",
};
export type Side = "L" | "R";
/** One stretch on one side: [side, from, to]; `to` is null while that side is feeding now. */
export type Segment = [Side, number, number | null];
export interface EventData {
seg?: Segment[];
milk?: "breast" | "formula";
ml?: number;
l?: number;
r?: number;
food?: string;
amount?: string;
kind?: "wet" | "dirty" | "both";
/** A dirty diaper's color, when someone noted it. */
color?: DiaperColor;
kg?: number;
cm?: number;
head?: number;
name?: string;
dose?: string;
c?: number;
text?: string;
note?: string;
}
export const DIAPER_COLORS = ["yellow", "green", "brown", "black", "red", "pale"] as const;
export type DiaperColor = (typeof DIAPER_COLORS)[number];
export interface BabyEvent {
id: string;
baby: string;
type: EventType;
start: number;
end?: number | null;
data: string;
by: string;
createdAt?: number;
deleted?: boolean | null;
}
export interface Baby { id: string; name: string; birth: string; archived?: boolean | null }
export function parseData(raw: string | undefined | null): EventData {
try {
const v = JSON.parse(raw || "{}");
return v && typeof v === "object" && !Array.isArray(v) ? v : {};
} catch {
return {};
}
}
/** A breastfeed or sleep with no end is a timer that is still going. */
export const isTimer = (e: Pick<BabyEvent, "type">) => e.type === "breast" || e.type === "sleep";
export const isRunning = (e: Pick<BabyEvent, "type" | "end" | "deleted">) => isTimer(e) && e.end == null && !e.deleted;
/** Seconds fed on each side, counting an open stretch up to `now`. */
export function sideTotals(seg: Segment[] | undefined, now: number): { L: number; R: number } {
const out = { L: 0, R: 0 };
for (const [side, from, to] of seg || []) out[side] += Math.max(0, (to ?? now) - from);
return out;
}
/** The side feeding now, or null when the feed is paused or over. */
export function openSide(seg: Segment[] | undefined): Side | null {
const last = seg?.[seg.length - 1];
return last && last[2] == null ? last[0] : null;
}
export function lastSide(seg: Segment[] | undefined): Side | null {
return seg?.length ? seg[seg.length - 1][0] : null;
}
/** How long an event lasted (or has lasted so far), in seconds. */
export function duration(e: BabyEvent, now: number): number {
if (e.type === "breast") {
const t = sideTotals(parseData(e.data).seg, now);
return t.L + t.R;
}
if (e.type === "sleep") return Math.max(0, (e.end ?? now) - e.start);
return 0;
}
// ---------- Units ----------
export interface Units { vol: "ml" | "oz"; weight: "kg" | "lb"; length: "cm" | "in"; temp: "c" | "f" }
export const METRIC: Units = { vol: "ml", weight: "kg", length: "cm", temp: "c" };
export const IMPERIAL: Units = { vol: "oz", weight: "lb", length: "in", temp: "f" };
export const ML_PER_OZ = 29.5735;
export const KG_PER_LB = 0.45359237;
export const CM_PER_IN = 2.54;
const trim = (n: number, digits: number) => String(Number(n.toFixed(digits)));
export const toMl = (v: number, unit: Units["vol"]) => (unit === "oz" ? v * ML_PER_OZ : v);
export const fromMl = (ml: number, unit: Units["vol"]) => (unit === "oz" ? ml / ML_PER_OZ : ml);
export const fmtVol = (ml: number, unit: Units["vol"]) => (unit === "oz" ? `${trim(ml / ML_PER_OZ, 1)} oz` : `${Math.round(ml)} ml`);
export function fmtWeight(kg: number, unit: Units["weight"]): string {
if (unit === "kg") return `${trim(kg, 2)} kg`;
const totalOz = kg / KG_PER_LB * 16;
let lb = Math.floor(totalOz / 16);
let oz = Number((totalOz - lb * 16).toFixed(1));
if (oz >= 16) { lb += 1; oz = 0; }
return lb ? `${lb} lb ${trim(oz, 1)} oz` : `${trim(oz, 1)} oz`;
}
export const fmtLength = (cm: number, unit: Units["length"]) => (unit === "in" ? `${trim(cm / CM_PER_IN, 1)} in` : `${trim(cm, 1)} cm`);
export const toC = (v: number, unit: Units["temp"]) => (unit === "f" ? (v - 32) * 5 / 9 : v);
export const fmtTemp = (c: number, unit: Units["temp"]) => (unit === "f" ? `${(c * 9 / 5 + 32).toFixed(1)} °F` : `${c.toFixed(1)} °C`);
// ---------- Time ----------
/** "2h 15m", "45m", "under a minute". */
export function fmtDur(sec: number): string {
const s = Math.max(0, Math.floor(sec));
if (s < 60) return "under a minute";
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
if (h >= 48) return `${Math.floor(h / 24)} days`;
return h ? `${h}h ${m}m` : `${m}m`;
}
/** "just now" or "2h 15m ago". */
export const fmtAgo = (sec: number) => (sec < 60 ? "just now" : `${fmtDur(sec)} ago`);
/** A short length for lists: "1h 20m", "12m", "40s". */
export function fmtShort(sec: number): string {
const s = Math.max(0, Math.round(sec));
if (s < 60) return `${s}s`;
const h = Math.floor(s / 3600);
const m = Math.round((s % 3600) / 60);
return h ? `${h}h ${m}m` : `${m}m`;
}
/** A running timer: "12:04" or "1:12:04". */
export function fmtClock(sec: number): string {
const s = Math.max(0, Math.floor(sec));
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
const r = String(s % 60).padStart(2, "0");
return h ? `${h}:${String(m).padStart(2, "0")}:${r}` : `${m}:${r}`;
}
/** Minutes east of UTC for a time zone at a moment (Intl does the rules, including daylight saving). */
export function tzOffset(tz: string, atSec: number): number {
try {
const parts = Object.fromEntries(
new Intl.DateTimeFormat("en-US", { timeZone: tz, hourCycle: "h23", year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit" })
.formatToParts(new Date(atSec * 1000)).map((p) => [p.type, p.value]),
);
const asUtc = Date.UTC(Number(parts.year), Number(parts.month) - 1, Number(parts.day), Number(parts.hour) % 24, Number(parts.minute), Number(parts.second));
return Math.round((asUtc / 1000 - atSec) / 60);
} catch {
return 0;
}
}
/** Unix seconds of the midnight that starts the day containing `atSec`, in a time zone. */
export function dayStartIn(tz: string, atSec: number): number {
const off = tzOffset(tz, atSec) * 60;
const local = atSec + off;
const midnight = local - (((local % 86400) + 86400) % 86400) - off;
// Correct across a daylight saving change that happened since midnight.
return midnight - (tzOffset(tz, midnight) * 60 - off);
}
export function isoDayIn(tz: string, atSec: number): string {
return new Date((atSec + tzOffset(tz, atSec) * 60) * 1000).toISOString().slice(0, 10);
}
// ---------- Descriptions ----------
const MILK = { breast: "breast milk", formula: "formula" } as const;
const KIND = { wet: "Wet", dirty: "Dirty", both: "Wet and dirty" } as const;
/** A one-line description, like "Bottle · 4 oz formula" or "Breastfeed · L 12m, R 8m". */
export function describe(e: BabyEvent, u: Units, now: number): string {
const d = parseData(e.data);
const label = TYPE_LABEL[e.type] ?? "Entry";
switch (e.type) {
case "breast": {
const t = sideTotals(d.seg, now);
const parts = [t.L ? `L ${fmtShort(t.L)}` : "", t.R ? `R ${fmtShort(t.R)}` : ""].filter(Boolean);
return `${label} · ${parts.join(", ") || "0m"}`;
}
case "bottle":
return `${label} · ${fmtVol(d.ml || 0, u.vol)}${d.milk ? ` ${MILK[d.milk]}` : ""}`;
case "pump": {
const parts = [d.l ? `L ${fmtVol(d.l, u.vol)}` : "", d.r ? `R ${fmtVol(d.r, u.vol)}` : ""].filter(Boolean);
return `${label} · ${fmtVol((d.l || 0) + (d.r || 0), u.vol)}${parts.length === 2 ? ` (${parts.join(", ")})` : ""}`;
}
case "solids":
return `${label} · ${d.food || "food"}${d.amount ? `, ${d.amount}` : ""}`;
case "sleep":
return `${label} · ${fmtShort(Math.max(0, (e.end ?? now) - e.start))}${e.end == null ? " so far" : ""}`;
case "diaper":
return `${label} · ${KIND[d.kind || "wet"]}${d.color && d.kind !== "wet" ? `, ${d.color}` : ""}`;
case "growth":
return `${label} · ${[d.kg ? fmtWeight(d.kg, u.weight) : "", d.cm ? fmtLength(d.cm, u.length) : "", d.head ? `head ${fmtLength(d.head, u.length)}` : ""].filter(Boolean).join(", ")}`;
case "medicine":
return `${label} · ${d.name || "medicine"}${d.dose ? `, ${d.dose}` : ""}`;
case "temp":
return `${label} · ${d.c != null ? fmtTemp(d.c, u.temp) : ""}`;
case "note":
return `${label} · ${d.text || ""}`;
default:
return label;
}
}
// ---------- Summary ----------
export interface DayTotals {
feeds: number; breastFeeds: number; breastSec: number; bottles: number; bottleMl: number;
sleepSec: number; sleeps: number; diapers: number; wet: number; dirty: number; pumpMl: number; solids: number;
}
export const emptyTotals = (): DayTotals => ({ feeds: 0, breastFeeds: 0, breastSec: 0, bottles: 0, bottleMl: 0, sleepSec: 0, sleeps: 0, diapers: 0, wet: 0, dirty: 0, pumpMl: 0, solids: 0 });
/** Totals for one baby between `from` and `to` (unix seconds). Sleep counts the part inside the window. */
export function totals(events: BabyEvent[], from: number, to: number, now: number): DayTotals {
const t = emptyTotals();
for (const e of events) {
if (e.deleted) continue;
const d = parseData(e.data);
if (e.type === "sleep") {
const a = Math.max(e.start, from);
const b = Math.min(e.end ?? now, to, now);
if (b > a) t.sleepSec += b - a;
if (e.start >= from && e.start < to) t.sleeps += 1;
continue;
}
if (e.start < from || e.start >= to) continue;
if (e.type === "breast") { t.feeds += 1; t.breastFeeds += 1; t.breastSec += duration(e, now); }
else if (e.type === "bottle") { t.feeds += 1; t.bottles += 1; t.bottleMl += d.ml || 0; }
else if (e.type === "diaper") {
t.diapers += 1;
if (d.kind === "wet" || d.kind === "both" || !d.kind) t.wet += 1;
if (d.kind === "dirty" || d.kind === "both") t.dirty += 1;
}
else if (e.type === "pump") t.pumpMl += (d.l || 0) + (d.r || 0);
else if (e.type === "solids") t.solids += 1;
}
return t;
}
const newest = (list: BabyEvent[]) => list.reduce<BabyEvent | null>((a, e) => (!a || e.start > a.start ? e : a), null);
export interface Snapshot {
lastFeed: BabyEvent | null;
lastDiaper: BabyEvent | null;
/** The sleep going on now, if any. */
asleep: BabyEvent | null;
lastSleep: BabyEvent | null;
running: BabyEvent[];
}
/** Where things stand for one baby: the last feed, diaper and sleep, and any timers running. */
export function snapshot(events: BabyEvent[]): Snapshot {
const live = events.filter((e) => !e.deleted);
const running = live.filter(isRunning).sort((a, b) => a.start - b.start);
const sleeps = live.filter((e) => e.type === "sleep");
return {
lastFeed: newest(live.filter((e) => e.type === "breast" || e.type === "bottle")),
lastDiaper: newest(live.filter((e) => e.type === "diaper")),
asleep: running.find((e) => e.type === "sleep") ?? null,
lastSleep: newest(sleeps.filter((e) => e.end != null)),
running,
};
}
// ---------- Ids and keys ----------
const ALPHABET = "abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789";
/** A random id from an unambiguous alphabet; 20 characters is about 116 bits. */
export function randomId(length = 20): string {
const bytes = crypto.getRandomValues(new Uint8Array(length));
let out = "";
for (const b of bytes) out += ALPHABET[b % ALPHABET.length];
return out;
}
/** The family key is the family id (20 characters) followed by its secret (24 characters). */
export const FAMILY_KEY_RE = /^[A-Za-z0-9]{44}$/;
export const familyKey = (householdId: string, secret: string) => householdId + secret;
export function splitFamilyKey(key: string): { householdId: string; secret: string } | null {
const k = key.trim();
return FAMILY_KEY_RE.test(k) ? { householdId: k.slice(0, 20), secret: k.slice(20) } : null;
}
Change history
Quality pass: undo on timers, sleep by hour chart, diaper color, fixes
Bounded source sync
Bounded source sync
Bounded source sync