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/api.ts
// Baby's public HTTP API, for AI and voice assistants. A family's assistant key (from Family in the
// app) goes in the x-household-key header; every route checks it before reading or writing anything,
// because every API caller acts as the same public principal. Writes go through the same policy
// rules as the web app.
import { SITE_URL } from "./lib/config";
import {
  EVENT_TYPES, DIAPER_COLORS, METRIC, IMPERIAL, ML_PER_OZ, KG_PER_LB, CM_PER_IN,
  describe, snapshot, totals, duration, isRunning, openSide, lastSide, parseData, randomId, splitFamilyKey,
  dayStartIn, isoDayIn, fmtDur, fmtAgo, fmtVol, toC,
  type BabyEvent, type Baby, type EventData, type EventType, type Segment, type Side, type Units,
} from "./lib/baby";

const NOW = { operation: "time", value: "now" };
const DAY = 86400;

const cors = {
  "access-control-allow-origin": "*",
  "access-control-allow-methods": "GET, POST, DELETE, OPTIONS",
  "access-control-allow-headers": "content-type, x-household-key, idempotency-key",
};
const json = (body: unknown, status = 200) =>
  new Response(JSON.stringify(body, null, 2), { status, headers: { "content-type": "application/json; charset=utf-8", ...cors } });
const fail = (status: number, error: string) => json({ error }, status);

class HttpError extends Error { constructor(public status: number, message: string) { super(message); } }

async function body(req: Request): Promise<Record<string, any>> {
  try {
    const parsed = await req.json();
    if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed;
  } catch { /* fall through */ }
  throw new HttpError(400, "Send a JSON object body.");
}

const bareId = (r: any) => r.id ?? String(r._id ?? r.pathId ?? "").split("/").pop();

async function listAll(ctx: any, path: string, opts: Record<string, unknown> = {}): Promise<any[]> {
  const rows: any[] = [];
  let cursor: string | undefined;
  for (let page = 0; page < 20; page++) {
    const res = await ctx.bounded.get(path, { limit: 500, ...opts, ...(cursor ? { cursor } : {}) });
    for (const r of res?.data || []) rows.push({ ...r, id: bareId(r) });
    cursor = res?.nextCursor;
    if (!cursor) break;
  }
  return rows;
}

interface Family { id: string; name: string; tz: string; babies: Baby[] }

/**
 * Checks the assistant key, then opens the family. The claim write only succeeds with the family's
 * current key, so it is the check. The route joins the family the first time, as "Assistant".
 */
async function openFamily(ctx: any, req: Request): Promise<Family> {
  const raw = req.headers.get("x-household-key") || "";
  const parts = splitFamilyKey(raw);
  if (!parts) throw new HttpError(401, "Send the family's assistant key in the x-household-key header. Find it in the app under Family, \"For your assistant\".");
  const { householdId: h, secret } = parts;
  const me = ctx.user.id;
  try {
    await ctx.bounded.set(`households/${h}/claims/${me}`, { key: secret, n: randomId(12) });
  } catch {
    throw new HttpError(403, "That assistant key does not work. It may have been replaced: copy the current one from Family in the app.");
  }
  if (!(await ctx.bounded.get(`households/${h}/members/${me}`))) {
    await ctx.bounded.set(`households/${h}/members/${me}`, { name: "Assistant", at: NOW });
  }
  const house = await ctx.bounded.get(`households/${h}`);
  if (!house) throw new HttpError(404, "That family no longer exists.");
  const babies = (await listAll(ctx, `households/${h}/babies`)).filter((b) => !b.archived) as Baby[];
  return { id: h, name: house.name, tz: house.tz || "UTC", babies };
}

function pickTz(req: Request, family: Family): string {
  const tz = new URL(req.url).searchParams.get("tz") || family.tz;
  try { new Intl.DateTimeFormat("en-US", { timeZone: tz }); return tz; } catch { throw new HttpError(400, "tz must be a time zone name like America/New_York."); }
}

function pickUnits(req: Request, input?: Record<string, any>): Units {
  const u = (new URL(req.url).searchParams.get("units") || "").toLowerCase();
  if (u) return u === "us" || u === "imperial" ? IMPERIAL : METRIC;
  // Without ?units, answer in the units the entry was sent in, so "4 oz" reads back as 4 oz.
  const sent = [input?.unit, input?.weightUnit, input?.lengthUnit].map((x) => String(x ?? "").toLowerCase());
  return sent.some((x) => /^(oz|ounce|fl oz|lb|pound|in|inch|f)/.test(x)) ? IMPERIAL : METRIC;
}

function findBaby(family: Family, ref: unknown): Baby {
  const s = String(ref ?? "").trim();
  if (!family.babies.length) throw new HttpError(409, "This family has no baby yet. Add one in the app first.");
  if (!s) {
    if (family.babies.length === 1) return family.babies[0];
    throw new HttpError(400, `Say which baby: ${family.babies.map((b) => b.name).join(" or ")}.`);
  }
  const b = family.babies.find((x) => x.id === s) || family.babies.find((x) => x.name.toLowerCase() === s.toLowerCase());
  if (!b) throw new HttpError(400, `No baby called "${s}". Babies: ${family.babies.map((x) => x.name).join(", ")}.`);
  return b;
}

async function eventsSince(ctx: any, h: string, babyId: string, since: number): Promise<BabyEvent[]> {
  const rows = await listAll(ctx, `households/${h}/events`, { filter: { baby: babyId, start: { $gte: since } } });
  return (rows as BabyEvent[]).filter((e) => e.baby === babyId && e.start >= since && !e.deleted);
}

const iso = (sec: number) => new Date(sec * 1000).toISOString();

function age(birth: string, now: number): string | null {
  const t = Date.parse(`${birth}T00:00:00Z`);
  if (!birth || !Number.isFinite(t)) return null;
  const days = Math.floor((now * 1000 - t) / 864e5);
  if (days < 0) return null;
  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`;
}

function entry(e: BabyEvent, u: Units, now: number) {
  return {
    id: e.id, type: e.type, start: iso(e.start), end: e.end ? iso(e.end) : null, running: isRunning(e),
    minutes: duration(e, now) ? Math.round(duration(e, now) / 60) : undefined,
    text: describe(e, u, now), details: parseData(e.data),
  };
}

function babySummary(baby: Baby, events: BabyEvent[], tz: string, u: Units, now: number) {
  const snap = snapshot(events);
  const from = dayStartIn(tz, now);
  const t = totals(events, from, from + DAY, now);
  const ago = (e: BabyEvent | null) => (e ? { at: iso(e.start), ago: fmtAgo(now - e.start), text: describe(e, u, now) } : null);
  const feeding = snap.running.find((e) => e.type === "breast");
  const seg = feeding ? parseData(feeding.data).seg : undefined;
  const says: string[] = [];
  if (feeding) says.push(`${baby.name} is feeding now (${fmtDur(duration(feeding, now))} so far).`);
  else if (snap.lastFeed) says.push(`${baby.name} last ate ${fmtAgo(now - snap.lastFeed.start)} (${describe(snap.lastFeed, u, now).replace(/ · /, ", ").toLowerCase()}).`);
  if (snap.lastDiaper) says.push(`Last diaper ${fmtAgo(now - snap.lastDiaper.start)}, ${describe(snap.lastDiaper, u, now).replace(/^.* · /, "").toLowerCase()}.`);
  if (snap.asleep) says.push(`Asleep for ${fmtDur(now - snap.asleep.start)}.`);
  else if (snap.lastSleep?.end) says.push(`Awake for ${fmtDur(now - snap.lastSleep.end)}.`);
  says.push(`Today: ${t.feeds} feed${t.feeds === 1 ? "" : "s"}, ${t.diapers} diaper${t.diapers === 1 ? "" : "s"}, ${t.sleepSec < 60 ? "no sleep logged" : `${fmtDur(t.sleepSec)} of sleep`}.`);
  return {
    id: baby.id, name: baby.name, age: age(baby.birth, now),
    now: {
      asleep: Boolean(snap.asleep), asleepFor: snap.asleep ? fmtDur(now - snap.asleep.start) : null,
      feeding: feeding ? { side: openSide(seg) === "L" ? "left" : openSide(seg) === "R" ? "right" : "paused", for: fmtDur(duration(feeding, now)) } : null,
      timers: snap.running.map((e) => entry(e, u, now)),
    },
    lastFeed: ago(snap.lastFeed),
    lastDiaper: ago(snap.lastDiaper),
    lastSleep: snap.lastSleep ? { ...ago(snap.lastSleep), ended: iso(snap.lastSleep.end!), awakeFor: snap.asleep ? null : fmtDur(now - snap.lastSleep.end!) } : null,
    today: {
      date: isoDayIn(tz, now), feeds: t.feeds, breastfeeds: t.breastFeeds, breastMinutes: Math.round(t.breastSec / 60),
      bottles: t.bottles, bottle: fmtVol(t.bottleMl, u.vol), bottleMl: Math.round(t.bottleMl),
      sleepMinutes: Math.round(t.sleepSec / 60), sleep: fmtDur(t.sleepSec), diapers: t.diapers, wet: t.wet, dirty: t.dirty,
      pumped: fmtVol(t.pumpMl, u.vol), pumpedMl: Math.round(t.pumpMl), solids: t.solids,
    },
    say: says.join(" "),
  };
}

// ---------- Turning friendly input into an entry ----------

const num = (v: unknown, name: string, max: number): number => {
  const n = typeof v === "string" ? Number(v.trim()) : Number(v);
  if (!Number.isFinite(n) || n < 0 || n > max) throw new HttpError(400, `${name} must be a number from 0 to ${max}.`);
  return n;
};
const text = (v: unknown, name: string, max: number, required = false): string | undefined => {
  const s = String(v ?? "").trim();
  if (!s) { if (required) throw new HttpError(400, `${name} is required.`); return undefined; }
  if (s.length > max) throw new HttpError(400, `Keep ${name} under ${max} characters.`);
  return s;
};

function volume(v: unknown, unit: unknown, name: string): number {
  const n = num(v, name, 2000);
  const u = String(unit ?? "").toLowerCase().replace(/s$/, "");
  if (u === "oz" || u === "ounce" || u === "fl oz") return n * ML_PER_OZ;
  if (u === "ml" || u === "milliliter" || u === "millilitre") return n;
  throw new HttpError(400, `Say the unit for ${name}: "ml" or "oz".`);
}

/** When it happened: "time" (ISO), or "minutesAgo"; defaults to now. */
function when(input: Record<string, any>, now: number): number {
  if (input.minutesAgo != null) return now - Math.round(num(input.minutesAgo, "minutesAgo", 60 * 24 * 7) * 60);
  if (input.time == null || input.time === "") return now;
  const t = Date.parse(String(input.time));
  if (!Number.isFinite(t)) throw new HttpError(400, "time must be a date and time like 2026-10-19T03:10:00Z.");
  const sec = Math.floor(t / 1000);
  if (sec > now + 300) throw new HttpError(400, "time is in the future.");
  return sec;
}

const TYPE_ALIASES: Record<string, EventType> = {
  breastfeed: "breast", breastfeeding: "breast", nursing: "breast", nurse: "breast", feed: "breast",
  formula: "bottle", pumping: "pump", food: "solids", solid: "solids", nap: "sleep",
  nappy: "diaper", temperature: "temp", fever: "temp", medication: "medicine", weight: "growth",
};

/** A timer's type, by the same names POST /events accepts ("nap", "nursing"); anything else is "". */
function timerType(v: unknown): "sleep" | "breast" | "" {
  const raw = String(v ?? "").trim().toLowerCase();
  const t = TYPE_ALIASES[raw] ?? raw;
  return t === "sleep" || t === "breast" ? t : "";
}

function buildEntry(input: Record<string, any>, now: number): { type: EventType; start: number; end?: number; data: EventData } {
  const rawType = String(input.type ?? "").trim().toLowerCase();
  const type = (TYPE_ALIASES[rawType] ?? rawType) as EventType;
  if (!EVENT_TYPES.includes(type)) throw new HttpError(400, `type must be one of: ${EVENT_TYPES.join(", ")}.`);
  const note = text(input.note, "note", 500);
  const at = when(input, now);
  const data: EventData = note ? { note } : {};
  switch (type) {
    case "bottle": {
      data.ml = Math.round(volume(input.amount, input.unit, "amount"));
      if (!data.ml) throw new HttpError(400, "amount must be above zero.");
      const milk = String(input.milk ?? "").toLowerCase();
      if (milk) data.milk = milk.startsWith("breast") ? "breast" : milk === "formula" ? "formula" : (() => { throw new HttpError(400, 'milk must be "formula" or "breast milk".'); })();
      return { type, start: at, data };
    }
    case "breast": {
      let l = input.left != null ? num(input.left, "left", 180) : 0;
      let r = input.right != null ? num(input.right, "right", 180) : 0;
      if (!l && !r && input.minutes != null) {
        const side = String(input.side ?? "").toLowerCase();
        if (side.startsWith("l")) l = num(input.minutes, "minutes", 180);
        else if (side.startsWith("r")) r = num(input.minutes, "minutes", 180);
        else throw new HttpError(400, 'With minutes, say side: "left" or "right". Or send left and right minutes.');
      }
      if (!l && !r) throw new HttpError(400, "Send minutes on each side as left and right, like {\"left\": 10, \"right\": 8}. To start a live timer, POST /timers/start.");
      // Without a time, the feed ended now.
      const total = Math.round((l + r) * 60);
      const start = input.time != null || input.minutesAgo != null ? at : now - total;
      const seg: Segment[] = [];
      let t = start;
      for (const [side, mins] of [["L", l], ["R", r]] as [Side, number][]) {
        if (!mins) continue;
        seg.push([side, t, t + Math.round(mins * 60)]);
        t += Math.round(mins * 60);
      }
      data.seg = seg;
      return { type, start, end: t, data };
    }
    case "pump": {
      if (input.left != null || input.right != null) {
        data.l = input.left != null ? Math.round(volume(input.left, input.unit, "left")) : 0;
        data.r = input.right != null ? Math.round(volume(input.right, input.unit, "right")) : 0;
      } else {
        data.l = Math.round(volume(input.amount, input.unit, "amount"));
        data.r = 0;
      }
      if (!data.l && !data.r) throw new HttpError(400, "Send the amount pumped (amount, or left and right) with unit.");
      return { type, start: at, data };
    }
    case "solids":
      data.food = text(input.food, "food", 120, true);
      data.amount = text(input.amount, "amount", 80);
      return { type, start: at, data };
    case "sleep": {
      if (input.start != null) {
        const s = Date.parse(String(input.start));
        const e = input.end != null ? Date.parse(String(input.end)) : now * 1000;
        if (!Number.isFinite(s) || !Number.isFinite(e) || e <= s) throw new HttpError(400, "start and end must be times, with end after start.");
        if (e / 1000 > now + 300) throw new HttpError(400, "end is in the future.");
        return { type, start: Math.floor(s / 1000), end: Math.floor(e / 1000), data };
      }
      const mins = num(input.minutes, "minutes", 24 * 60);
      if (!mins) throw new HttpError(400, "Send minutes slept, or start and end. To start a live sleep timer, POST /timers/start.");
      const end = input.time != null || input.minutesAgo != null ? at + Math.round(mins * 60) : now;
      return { type, start: end - Math.round(mins * 60), end, data };
    }
    case "diaper": {
      const k = String(input.kind ?? "wet").toLowerCase();
      data.kind = /both|mixed|wet and dirty/.test(k) ? "both" : /dirty|poo|poop|bm|stool/.test(k) ? "dirty" : /wet|pee/.test(k) ? "wet" : (() => { throw new HttpError(400, 'kind must be "wet", "dirty" or "both".'); })();
      if (input.color != null && input.color !== "") {
        const c = String(input.color).trim().toLowerCase();
        const color = (({ white: "pale", grey: "pale", gray: "pale", clay: "pale", mustard: "yellow", orange: "yellow", tan: "brown" }) as Record<string, string>)[c] ?? c;
        if (!(DIAPER_COLORS as readonly string[]).includes(color)) throw new HttpError(400, `color must be one of: ${DIAPER_COLORS.join(", ")}.`);
        if (data.kind === "wet") throw new HttpError(400, 'color is for a dirty diaper: send kind "dirty" or "both".');
        data.color = color as EventData["color"];
      }
      return { type, start: at, data };
    }
    case "growth": {
      const lenUnit = (u: unknown, name: string) => { const s = String(u ?? "").toLowerCase(); if (s.startsWith("in")) return CM_PER_IN; if (s === "cm") return 1; throw new HttpError(400, `Say ${name}: "cm" or "in".`); };
      if (input.weight != null) {
        const w = String(input.weightUnit ?? "").toLowerCase();
        const n = num(input.weight, "weight", 80);
        data.kg = Number((w.startsWith("lb") || w.startsWith("pound") ? n * KG_PER_LB : w === "kg" ? n : (() => { throw new HttpError(400, 'Say weightUnit: "kg" or "lb".'); })()).toFixed(3));
      }
      if (input.length != null) data.cm = Number((num(input.length, "length", 200) * lenUnit(input.lengthUnit, "lengthUnit")).toFixed(1));
      if (input.head != null) data.head = Number((num(input.head, "head", 100) * lenUnit(input.headUnit, "headUnit")).toFixed(1));
      if (!data.kg && !data.cm && !data.head) throw new HttpError(400, "Send weight, length or head, each with its unit.");
      return { type, start: at, data };
    }
    case "medicine":
      data.name = text(input.name, "name", 80, true);
      data.dose = text(input.dose, "dose", 80);
      return { type, start: at, data };
    case "temp": {
      const u = String(input.unit ?? "").toLowerCase();
      if (u !== "c" && u !== "f") throw new HttpError(400, 'Say unit: "c" or "f".');
      const c = toC(num(input.value, "value", 120), u);
      if (c < 30 || c > 45) throw new HttpError(400, "That temperature looks wrong. Check the number and the unit.");
      data.c = Number(c.toFixed(1));
      return { type, start: at, data };
    }
    case "note":
      data.text = text(input.text ?? input.note, "text", 1000, true);
      delete data.note;
      return { type, start: at, data };
  }
}

const routes = {
  name: "Baby by OpenApps",
  about: "A free shared baby log: feeds, sleep, diapers, growth and more. Send the family's assistant key in the x-household-key header.",
  docs: `${SITE_URL}/llms.txt`,
  routes: [
    "GET /today?baby=&tz=&units=us -> where things stand and today's totals, with a sentence to read out",
    "GET /events?date=YYYY-MM-DD&baby=&units=us -> one day's entries",
    "POST /events {type, baby?, ...} -> log an entry (bottle, breast, pump, solids, sleep, diaper, growth, medicine, temp, note)",
    "POST /timers/start {type: sleep | breast, side?, baby?} -> start a live timer the family sees",
    "POST /timers/stop {type?, baby?} -> stop it and save",
    "POST /timers/switch {baby?} -> switch breast sides",
    "DELETE /events/:id -> remove a mistaken entry",
  ],
};

export default async function api(req: Request, ctx: any) {
  if (req.method === "OPTIONS") return new Response(null, { status: 204, headers: cors });
  const url = new URL(req.url);
  const parts = url.pathname.split("/").filter(Boolean);
  // Accept both /today and /api/today so a copied path works either way.
  if (parts[0] === "api") parts.shift();
  try {
    if (!parts.length && req.method === "GET") return json(routes);
    const known = ["today", "events", "timers"];
    if (!known.includes(parts[0])) return fail(404, "Unknown route. GET / lists the routes.");

    const family = await openFamily(ctx, req);
    const tz = pickTz(req, family);
    const u = pickUnits(req);
    const now = Math.floor(Date.now() / 1000);
    const familyOut = { name: family.name, url: `${SITE_URL}/app`, timeZone: tz };

    if (parts[0] === "today" && parts.length === 1 && req.method === "GET") {
      const ref = url.searchParams.get("baby");
      const babies = ref ? [findBaby(family, ref)] : family.babies;
      const since = dayStartIn(tz, now) - 2 * DAY;
      const out = [];
      for (const b of babies) out.push(babySummary(b, await eventsSince(ctx, family.id, b.id, since), tz, u, now));
      return json({ family: familyOut, babies: out });
    }

    if (parts[0] === "events" && parts.length === 1 && req.method === "GET") {
      const date = url.searchParams.get("date") || isoDayIn(tz, now);
      const noon = Date.parse(`${date}T12:00:00Z`);
      if (!/^\d{4}-\d{2}-\d{2}$/.test(date) || !Number.isFinite(noon)) return fail(400, "date must look like 2026-10-19.");
      const from = dayStartIn(tz, Math.floor(noon / 1000));
      const baby = findBaby(family, url.searchParams.get("baby") || (family.babies.length === 1 ? family.babies[0].id : ""));
      const events = (await eventsSince(ctx, family.id, baby.id, from)).filter((e) => e.start < from + DAY).sort((a, b) => a.start - b.start);
      return json({ family: familyOut, baby: baby.name, date, entries: events.map((e) => entry(e, u, now)) });
    }

    if (parts[0] === "events" && parts.length === 1 && req.method === "POST") {
      const input = await body(req);
      const baby = findBaby(family, input.baby);
      const built = buildEntry(input, now);
      const u = pickUnits(req, input);
      const id = randomId(14);
      const doc: Record<string, unknown> = { baby: baby.id, type: built.type, start: built.start, data: JSON.stringify(built.data), by: ctx.user.id, createdAt: NOW };
      if (built.end != null) doc.end = built.end;
      await ctx.bounded.set(`households/${family.id}/events/${id}`, doc);
      const saved: BabyEvent = { id, baby: baby.id, type: built.type, start: built.start, end: built.end ?? null, data: JSON.stringify(built.data), by: ctx.user.id };
      const events = await eventsSince(ctx, family.id, baby.id, dayStartIn(tz, now) - 2 * DAY);
      if (!events.some((e) => e.id === id)) events.push(saved);
      return json({ created: entry(saved, u, now), baby: babySummary(baby, events, tz, u, now) }, 201);
    }

    if (parts[0] === "events" && parts.length === 2 && req.method === "DELETE") {
      const e = await ctx.bounded.get(`households/${family.id}/events/${parts[1]}`);
      if (!e || e.deleted) return fail(404, "No entry with that id in this family.");
      await ctx.bounded.set(`households/${family.id}/events/${parts[1]}`, { deleted: true });
      return json({ deleted: parts[1] });
    }

    if (parts[0] === "timers" && parts.length === 2 && req.method === "POST") {
      const input = await body(req);
      const baby = findBaby(family, input.baby);
      const events = await eventsSince(ctx, family.id, baby.id, now - 14 * DAY);
      const running = events.filter(isRunning);
      const action = parts[1];
      if (action === "start") {
        const type = timerType(input.type);
        if (!type) return fail(400, 'type must be "sleep" or "breast".');
        if (running.some((e) => e.type === type)) return fail(409, `A ${type === "sleep" ? "sleep" : "feed"} timer is already running for ${baby.name}.`);
        const data: EventData = {};
        if (type === "breast") {
          const s = String(input.side ?? "").toLowerCase();
          const prev = events.filter((e) => e.type === "breast").sort((a, b) => b.start - a.start)[0];
          const side: Side = s.startsWith("l") ? "L" : s.startsWith("r") ? "R" : lastSide(prev ? parseData(prev.data).seg : undefined) === "L" ? "R" : "L";
          data.seg = [[side, now, null]];
        }
        const id = randomId(14);
        await ctx.bounded.set(`households/${family.id}/events/${id}`, { baby: baby.id, type, start: now, data: JSON.stringify(data), by: ctx.user.id, createdAt: NOW });
        const saved: BabyEvent = { id, baby: baby.id, type, start: now, end: null, data: JSON.stringify(data), by: ctx.user.id };
        return json({ started: entry(saved, u, now) }, 201);
      }
      if (action === "stop") {
        const want = input.type == null || input.type === "" ? "" : timerType(input.type);
        if (input.type && !want) return fail(400, 'type must be "sleep" or "breast", or leave it out to stop every timer.');
        const targets = running.filter((e) => !want || e.type === want);
        if (!targets.length) return fail(404, `No timer is running for ${baby.name}.`);
        const stopped = [];
        for (const e of targets) {
          const patch: Record<string, unknown> = { end: now };
          if (e.type === "breast") {
            const d = parseData(e.data);
            d.seg = (d.seg || []).map(([s, a, b]) => [s, a, b ?? now] as Segment);
            patch.data = JSON.stringify(d);
            e.data = patch.data as string;
          }
          await ctx.bounded.set(`households/${family.id}/events/${e.id}`, patch);
          stopped.push(entry({ ...e, end: now }, u, now));
        }
        return json({ stopped });
      }
      if (action === "switch") {
        const feed = running.find((e) => e.type === "breast");
        if (!feed) return fail(404, `No feed timer is running for ${baby.name}.`);
        const d = parseData(feed.data);
        const seg = (d.seg || []).map(([s, a, b]) => [s, a, b ?? now] as Segment);
        const next: Side = lastSide(seg) === "L" ? "R" : "L";
        seg.push([next, now, null]);
        d.seg = seg;
        await ctx.bounded.set(`households/${family.id}/events/${feed.id}`, { data: JSON.stringify(d) });
        return json({ switched: next === "L" ? "left" : "right", feed: entry({ ...feed, data: JSON.stringify(d) }, u, now) });
      }
    }

    return fail(404, "Unknown route. GET / lists the routes.");
  } catch (err) {
    if (err instanceof HttpError) return fail(err.status, err.message);
    const message = err instanceof Error ? err.message : String(err);
    return fail(500, `Something went wrong: ${message}`);
  }
}

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