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