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/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
Bounded source sync
Bounded source sync
Bounded source sync