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
src/FamilyPage.tsx
// Family: the people in the log and the family link, babies, exports for the doctor, this device's
// units and look, keeping the log on all devices, and the assistant key.
import { useState } from "react";
import { useUnits, useLook, setActiveFamily, type Look } from "./lib/prefs";
import { signOut, explain } from "./lib/bounded";
import {
  renameMe, removeMember, replaceKey, leaveFamily, addBaby, updateBaby, renameFamily, keepOnAllDevices, loadRange, type Member,
} from "./lib/family";
import { navigate } from "./lib/router";
import { Sheet, useSheet, Confirm, Choice, Icon, Install, toast, copy, share, localDayStart, addDays, ageText } from "./ui";
import { SITE_URL } from "../functions/lib/config";
import { describe, parseData, duration, TYPE_LABEL, METRIC, type Baby } from "../functions/lib/baby";
import type { FamilyCtx } from "./App";

export function FamilyPage({ ctx }: { ctx: FamilyCtx }) {
  const [leaving, setLeaving] = useState(false);
  return (
    <div className="family wrap-app enter">
      <div className="page-head">
        <p className="eyebrow">Family</p>
        <h1 className="page-title small-title">{ctx.house.name}</h1>
      </div>
      <div className="family-grid">
        <div className="family-col">
          <People ctx={ctx} />
          <Babies ctx={ctx} />
          <Export ctx={ctx} />
        </div>
        <div className="family-col">
          <Account ctx={ctx} />
          <Device ctx={ctx} />
          <Assistant ctx={ctx} />
        </div>
      </div>
      {!ctx.isOwner && <button type="button" className="btn btn-quiet btn-danger leave-btn" onClick={() => setLeaving(true)}>Leave this family log</button>}
      <p className="muted small fine">Baby is a log, not a medical device. It makes no predictions and gives no medical advice. Ask your doctor about anything that worries you.</p>
      {leaving && (
        <Confirm title={`Leave ${ctx.house.name}?`} danger action="Leave"
          text="You stop seeing this log. To come back, someone in the family sends you the link again. Your past entries stay in the log."
          onConfirm={async () => { try { await leaveFamily(ctx.h); } catch (err) { throw new Error(explain(err)); } navigate("/app"); }}
          onClose={() => setLeaving(false)} />
      )}
    </div>
  );
}

function People({ ctx }: { ctx: FamilyCtx }) {
  const link = ctx.familyKey ? `${SITE_URL}/join#${ctx.familyKey}` : null;
  const [removing, setRemoving] = useState<Member | null>(null);
  const [naming, setNaming] = useState(false);
  return (
    <section className="card pad" aria-labelledby="people-title">
      <h2 className="card-title" id="people-title">People</h2>
      <ul className="people">
        {ctx.members.map((m) => (
          <li key={m.id}>
            <span className="person">
              <strong>{m.name}{m.id === ctx.me.id ? " (you)" : ""}</strong>
              <small>{[ctx.owners.has(m.id) ? "Owner" : "", m.name === "Assistant" && !m.account ? "Assistant key" : m.account ? "Signed in" : "Guest", `joined ${new Date(m.at * 1000).toLocaleDateString(undefined, { month: "short", day: "numeric" })}`].filter(Boolean).join(" · ")}</small>
            </span>
            {m.id === ctx.me.id
              ? <button type="button" className="btn btn-sm btn-quiet" onClick={() => setNaming(true)} aria-label="Change your name">Edit</button>
              : ctx.isOwner && <button type="button" className="btn btn-sm btn-quiet btn-danger" onClick={() => setRemoving(m)} aria-label={`Remove ${m.name}`}>Remove</button>}
          </li>
        ))}
      </ul>
      <div className="invite">
        <p className="label">Invite your partner, a grandparent or a nanny</p>
        <p className="muted small">They open the family link, add their name, and see and log everything live. No account needed.</p>
        {link ? (
          <div className="row">
            <button type="button" className="btn" onClick={() => share(link, "Our baby log", `Join ${ctx.house.name} on Baby, our shared baby log:`)}><Icon name="share" /> Share the family link</button>
            <button type="button" className="btn btn-line" onClick={() => copy(link, "Family link copied")}>Copy</button>
          </div>
        ) : <span className="skeleton" style={{ height: 46, width: 260 }} />}
      </div>
      {removing && <RemoveSheet ctx={ctx} member={removing} onClose={() => setRemoving(null)} />}
      {naming && <NameSheet ctx={ctx} onClose={() => setNaming(false)} />}
    </section>
  );
}

function NameSheet({ ctx, onClose }: { ctx: FamilyCtx; onClose: () => void }) {
  const sheet = useSheet(onClose);
  const mine = ctx.members.find((m) => m.id === ctx.me.id);
  const [name, setName] = useState(mine?.name || "");
  const [busy, setBusy] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const save = async () => {
    const n = name.trim().slice(0, 40);
    if (!n) return setError("Add your name, so the family sees who logged what.");
    setBusy(true);
    try { if (n !== mine?.name) await renameMe(ctx.h, n); sheet.close(); } catch (err) { setError(explain(err)); setBusy(false); }
  };
  return (
    <Sheet title="Your name" sheet={sheet} footer={<><span className="grow" /><button type="button" className="btn btn-lg btn-save" onClick={save} aria-busy={busy || undefined}>Save</button></>}>
      <form className="stack" onSubmit={(e) => { e.preventDefault(); void save(); }}>
        <label className="field"><span>Your name in this log</span>
          <input className="input input-lg" value={name} maxLength={40} onChange={(e) => setName(e.target.value)} aria-invalid={error ? true : undefined} autoFocus autoComplete="given-name" />
        </label>
        {error && <p className="field-error" role="alert">{error}</p>}
        <p className="muted small">Shown next to the entries you log.</p>
      </form>
    </Sheet>
  );
}

function RemoveSheet({ ctx, member, onClose }: { ctx: FamilyCtx; member: Member; onClose: () => void }) {
  const sheet = useSheet(onClose);
  const [newKey, setNewKey] = useState(true);
  const [busy, setBusy] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const go = async () => {
    setBusy(true);
    try {
      await removeMember(ctx.h, member.id, newKey);
      toast(newKey ? `${member.name} was removed. The family link changed: share the new one with anyone who has not joined yet.` : `${member.name} was removed.`);
      sheet.close();
    } catch (err) {
      setError(explain(err));
      setBusy(false);
    }
  };
  return (
    <Sheet title={`Remove ${member.name}?`} sheet={sheet} footer={<><button type="button" className="btn btn-line" onClick={sheet.close}>Cancel</button><button type="button" className="btn btn-danger-solid" onClick={go} aria-busy={busy || undefined}>Remove</button></>}>
      <p>{member.name} will no longer see or add to this log. Their past entries stay.</p>
      <label className="check"><input type="checkbox" checked={newKey} onChange={(e) => setNewKey(e.target.checked)} /> Also change the family link and assistant key</label>
      <p className="muted small">Leave this on if they still have the link, or they could join again. People already in the family are not affected, but any assistant you set up needs the new key.</p>
      {error && <p className="notice notice-error" role="alert">{error}</p>}
    </Sheet>
  );
}

function Babies({ ctx }: { ctx: FamilyCtx }) {
  const [editing, setEditing] = useState<Baby | "new" | null>(null);
  return (
    <section className="card pad" aria-labelledby="babies-title">
      <h2 className="card-title" id="babies-title">Babies</h2>
      <ul className="people">
        {ctx.babies.map((b) => (
          <li key={b.id}>
            <span className="person"><strong>{b.name}</strong><small>{b.birth ? `${ageText(b.birth) ?? ""}, born ${new Date(`${b.birth}T00:00:00`).toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" })}` : "No birth date"}</small></span>
            <button type="button" className="btn btn-sm btn-quiet" onClick={() => setEditing(b)} aria-label={`Edit ${b.name}`}>Edit</button>
          </li>
        ))}
      </ul>
      <button type="button" className="btn btn-line" onClick={() => setEditing("new")}><Icon name="plus" /> Add a baby</button>
      {editing && <BabySheet ctx={ctx} baby={editing === "new" ? null : editing} onClose={() => setEditing(null)} />}
    </section>
  );
}

function BabySheet({ ctx, baby, onClose }: { ctx: FamilyCtx; baby: Baby | null; onClose: () => void }) {
  const sheet = useSheet(onClose);
  const [name, setName] = useState(baby?.name || "");
  const [birth, setBirth] = useState(baby?.birth || "");
  const [busy, setBusy] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const hide = async () => {
    if (!baby) return;
    setBusy(true);
    try {
      await updateBaby(ctx.h, baby.id, { archived: true });
      toast(`${baby.name} is hidden. The log is kept.`, () => void updateBaby(ctx.h, baby.id, { archived: false }).catch((err) => toast(explain(err))));
      sheet.close();
    } catch (err) { setError(explain(err)); setBusy(false); }
  };
  const save = async () => {
    const n = name.trim().slice(0, 40);
    if (!n) return setError("Add a name or nickname.");
    setBusy(true);
    try {
      const b = /^\d{4}-\d{2}-\d{2}$/.test(birth) ? birth : "";
      if (baby) await updateBaby(ctx.h, baby.id, { name: n, birth: b }); else await addBaby(ctx.h, n, b);
      toast(baby ? "Saved" : `${n} added`);
      sheet.close();
    } catch (err) { setError(explain(err)); setBusy(false); }
  };
  return (
    <Sheet title={baby ? `Edit ${baby.name}` : "Add a baby"} sheet={sheet}
      footer={<>{baby && ctx.babies.length > 1 && <button type="button" className="btn btn-quiet btn-danger" onClick={hide} disabled={busy}>Hide</button>}<span className="grow" /><button type="button" className="btn btn-lg btn-save" onClick={save} aria-busy={busy || undefined}>Save</button></>}>
      <form className="stack" onSubmit={(e) => { e.preventDefault(); void save(); }}>
        <label className="field"><span>Name</span><input className="input input-lg" value={name} maxLength={40} onChange={(e) => setName(e.target.value)} aria-invalid={error ? true : undefined} autoFocus /></label>
        {error && <p className="field-error" role="alert">{error}</p>}
        <label className="field"><span>Birth date <em>optional</em></span><input className="input input-lg" type="date" value={birth} max={new Date().toLocaleDateString("en-CA")} onChange={(e) => setBirth(e.target.value)} /></label>
      </form>
    </Sheet>
  );
}

/** Units, look and the family name, plus the home screen. Units and look are per device. */
function Device({ ctx }: { ctx: FamilyCtx }) {
  const [u, setU] = useUnits();
  const [look, setLook] = useLook();
  const [famName, setFamName] = useState(ctx.house.name);
  const others = ctx.families.filter((f) => f.id !== ctx.h);
  const rename = async () => {
    const n = famName.trim().slice(0, 60);
    if (!n || n === ctx.house.name) return;
    try { await renameFamily(ctx.h, n); toast("Family name saved"); } catch (err) { toast(explain(err)); }
  };
  return (
    <section className="card pad" aria-labelledby="device-title">
      <div>
        <h2 className="card-title" id="device-title">On this phone</h2>
        <p className="muted small">Units and look are yours alone. Everyone in the family picks their own.</p>
      </div>
      <div className="settings">
        <div className="setting"><span className="label">Bottles</span><Choice label="Bottle and pumping units" value={u.vol} onChange={(vol) => setU({ vol })} options={[{ value: "ml", label: "ml" }, { value: "oz", label: "oz" }]} /></div>
        <div className="setting"><span className="label">Weight</span><Choice label="Weight units" value={u.weight} onChange={(weight) => setU({ weight })} options={[{ value: "kg", label: "kg" }, { value: "lb", label: "lb, oz" }]} /></div>
        <div className="setting"><span className="label">Length</span><Choice label="Length and head units" value={u.length} onChange={(length) => setU({ length })} options={[{ value: "cm", label: "cm" }, { value: "in", label: "in" }]} /></div>
        <div className="setting"><span className="label">Temperature</span><Choice label="Temperature units" value={u.temp} onChange={(temp) => setU({ temp })} options={[{ value: "c", label: "°C" }, { value: "f", label: "°F" }]} /></div>
        <div className="setting setting-wide"><span className="label">Look</span><Choice<Look> label="Look" value={look} onChange={setLook} options={[{ value: "auto", label: "Auto" }, { value: "night", label: "Night" }, { value: "day", label: "Day" }]} /></div>
        <p className="hint">Auto turns on the dark night look from 7 pm to 7 am, or when your phone is set to dark mode.</p>
      </div>
      <form className="name-row" onSubmit={(e) => { e.preventDefault(); void rename(); }}>
        <label className="field grow"><span>Family name</span><input className="input" value={famName} maxLength={60} onChange={(e) => setFamName(e.target.value)} /></label>
        <button type="submit" className="btn btn-line" disabled={!famName.trim() || famName.trim() === ctx.house.name}>Save</button>
      </form>
      {others.length > 0 && (
        <div className="field"><span className="label">Your other family logs</span>
          <div className="row">{others.map((f) => <button key={f.id} type="button" className="btn btn-sm btn-line" onClick={() => { setActiveFamily(f.id); navigate("/app"); }}>{f.name || "Family log"}</button>)}</div>
        </div>
      )}
      <div className="field"><span className="label">Home screen</span><Install /></div>
    </section>
  );
}

function Account({ ctx }: { ctx: FamilyCtx }) {
  const [busy, setBusy] = useState(false);
  if (!ctx.me.guest) {
    return (
      <section className="card pad" aria-labelledby="account-title">
        <h2 className="card-title" id="account-title">Your account</h2>
        <p className="muted small">Signed in{ctx.me.email ? ` as ${ctx.me.email}` : ""}. Sign in with the same email on any phone or computer to open this log.</p>
        <div><button type="button" className="btn btn-line" aria-busy={busy || undefined} onClick={async () => { setBusy(true); try { await signOut(); location.href = "/app"; } catch (err) { toast(explain(err)); setBusy(false); } }}>Sign out</button></div>
      </section>
    );
  }
  const keep = async () => {
    setBusy(true);
    try {
      const names: Record<string, string> = { [ctx.h]: ctx.members.find((m) => m.id === ctx.me.id)?.name || "Me" };
      await keepOnAllDevices(ctx.families.map((f) => (f.id === ctx.h ? { id: f.id, name: ctx.house.name } : f)), names);
    } catch (err) { toast(explain(err)); setBusy(false); }
  };
  return (
    <section className="card pad panel-card" aria-labelledby="account-title">
      <h2 className="card-title" id="account-title">Keep it on all your devices</h2>
      <p className="muted small">Right now only this browser holds your place in the log. Sign in with your email to open it on any phone or computer, and to keep it if this browser's data is cleared. No password, and it stays free.</p>
      <div><button type="button" className="btn" aria-busy={busy || undefined} onClick={keep}>Continue with email</button></div>
    </section>
  );
}

function Assistant({ ctx }: { ctx: FamilyCtx }) {
  const [shown, setShown] = useState(false);
  const [renewing, setRenewing] = useState(false);
  const key = ctx.familyKey;
  return (
    <section className="card pad" aria-labelledby="assistant-title">
      <h2 className="card-title" id="assistant-title">For your assistant</h2>
      <p className="muted small">An AI or voice assistant can log for you ("a 4 ounce bottle", "she fell asleep") and tell you when the baby last ate. Give it this key and the instructions at <a href="/llms.txt">llms.txt</a>. Anyone with the key can read and add to the log, like the family link.</p>
      {key ? (
        <div className="key-row">
          <code className="link-code">{shown ? key : `${key.slice(0, 6)}${"•".repeat(14)}`}</code>
          <button type="button" className="btn btn-sm btn-line" onClick={() => setShown(!shown)} aria-pressed={shown}>{shown ? "Hide" : "Show"}</button>
          <button type="button" className="btn btn-sm btn-line" onClick={() => copy(key, "Assistant key copied")}>Copy</button>
        </div>
      ) : <span className="skeleton" style={{ height: 40 }} />}
      {ctx.isOwner && <div><button type="button" className="btn btn-quiet btn-sm" onClick={() => setRenewing(true)}><Icon name="key" /> Make a new key</button></div>}
      {renewing && (
        <Confirm title="Make a new key?" action="Make a new key"
          text="The current family link and assistant key stop working. People already in the family stay. Share the new link with anyone who has not joined yet."
          onConfirm={async () => { try { await replaceKey(ctx.h); } catch (err) { throw new Error(explain(err)); } toast("New key made. The family link changed too."); }}
          onClose={() => setRenewing(false)} />
      )}
    </section>
  );
}

function Export({ ctx }: { ctx: FamilyCtx }) {
  const today = localDayStart(Date.now() / 1000);
  const iso = (sec: number) => new Date(sec * 1000).toLocaleDateString("en-CA");
  const [from, setFrom] = useState(iso(addDays(today, -13)));
  const [to, setTo] = useState(iso(today));
  const [baby, setBaby] = useState(ctx.baby?.id || "");
  const [busy, setBusy] = useState(false);
  const range = () => {
    const a = localDayStart(new Date(`${from}T00:00:00`).getTime() / 1000);
    const b = addDays(localDayStart(new Date(`${to}T00:00:00`).getTime() / 1000), 1);
    return Number.isFinite(a) && Number.isFinite(b) && b > a ? [a, b] as const : null;
  };
  const csv = async () => {
    const r = range();
    const b = ctx.babies.find((x) => x.id === baby);
    if (!r || !b) return toast("Pick a baby and a date range.");
    setBusy(true);
    try {
      const events = await loadRange(ctx.h, b.id, r[0], r[1]);
      const now = Math.floor(Date.now() / 1000);
      const cols = ["Date", "Time", "End", "Baby", "Type", "Details", "Minutes", "Amount (ml)", "Logged by", "Note"];
      const rows = events.map((e) => {
        const d = parseData(e.data);
        const t = new Date(e.start * 1000);
        const ml = e.type === "bottle" ? d.ml : e.type === "pump" ? (d.l || 0) + (d.r || 0) : undefined;
        return [t.toLocaleDateString("en-CA"), t.toTimeString().slice(0, 5), e.end ? new Date(e.end * 1000).toTimeString().slice(0, 5) : "", b.name, TYPE_LABEL[e.type],
          describe(e, METRIC, now).replace(/^.* · /, ""), duration(e, now) ? Math.round(duration(e, now) / 60) : "", ml != null ? Math.round(ml) : "", ctx.nameOf(e.by), d.note || ""];
      });
      // Names and notes come from anyone in the family or the assistant key: a leading = + - @ would run as a spreadsheet formula.
      const cell = (c: unknown) => { const s = String(c); return `"${(/^[=+\-@\t\r]/.test(s) ? `'${s}` : s).replace(/"/g, '""')}"`; };
      const text = [cols, ...rows].map((row) => row.map(cell).join(",")).join("\n");
      const a = document.createElement("a");
      a.href = URL.createObjectURL(new Blob([text], { type: "text/csv" }));
      a.download = `${b.name.replace(/[^\w-]+/g, "-")}-${from}-to-${to}.csv`;
      a.click();
      toast(`Downloaded ${rows.length} ${rows.length === 1 ? "entry" : "entries"}`);
    } catch (err) { toast(explain(err)); } finally { setBusy(false); }
  };
  return (
    <section className="card pad" aria-labelledby="doctor-title">
      <h2 className="card-title" id="doctor-title">For the doctor</h2>
      <p className="muted small">A printable summary of feeds, sleep, diapers, growth, medicine and temperatures, or every entry as a spreadsheet.</p>
      {ctx.babies.length > 1 && (
        <label className="field"><span>Baby</span><select className="select" value={baby} onChange={(e) => setBaby(e.target.value)}>{ctx.babies.map((b) => <option key={b.id} value={b.id}>{b.name}</option>)}</select></label>
      )}
      <div className="two">
        <label className="field"><span>From</span><input className="input" type="date" value={from} max={to} onChange={(e) => setFrom(e.target.value)} /></label>
        <label className="field"><span>To</span><input className="input" type="date" value={to} min={from} max={iso(today)} onChange={(e) => setTo(e.target.value)} /></label>
      </div>
      <div className="row">
        <a className="btn" href={`/app/report?baby=${encodeURIComponent(baby)}&from=${from}&to=${to}`} target="_blank" rel="noopener"><Icon name="print" /> Printable summary</a>
        <button type="button" className="btn btn-line" onClick={csv} aria-busy={busy || undefined}><Icon name="download" /> Download CSV</button>
      </div>
    </section>
  );
}

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