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/Today.tsx
// Today: timers that are running, time since the last feed, diaper and sleep, big buttons to log,
// today's totals and the latest entries. Everything updates live for everyone in the family.
import { useState } from "react";
import { useUnits } from "./lib/prefs";
import { switchSide, pauseFeed, resumeFeed, stopTimer, reopenTimer } from "./lib/family";
import { explain } from "./lib/bounded";
import { onLink } from "./lib/router";
import { Icon, toast, useNow, clockTime, localDayStart, addDays } from "./ui";
import { LogSheet, TypeIcon } from "./LogSheets";
import {
  TYPE_LABEL, parseData, sideTotals, openSide, lastSide, isRunning, describe, snapshot, totals, fmtAgo, fmtClock, fmtVol, fmtShort,
  type BabyEvent, type EventType, type Side,
} from "../functions/lib/baby";
import type { FamilyCtx } from "./App";

type Open = { type: EventType; event?: BabyEvent } | null;

const MAIN: EventType[] = ["breast", "bottle", "sleep", "diaper"];
const MORE: EventType[] = ["pump", "solids", "growth", "medicine", "temp", "note"];

export function Today({ ctx }: { ctx: FamilyCtx }) {
  const [u] = useUnits();
  const [open, setOpen] = useState<Open>(null);
  const mine = ctx.baby ? ctx.events.filter((e) => e.baby === ctx.baby!.id) : [];
  const anyRunning = ctx.events.some(isRunning);
  const now = useNow(anyRunning ? 1000 : 20_000);
  const snap = snapshot(mine);
  const from = localDayStart(now);
  const t = totals(mine, from, addDays(from, 1), now);
  const others = ctx.events.filter((e) => isRunning(e) && e.baby !== ctx.baby?.id);
  const recent = [...mine].sort((a, b) => b.start - a.start).slice(0, 6);

  if (!ctx.baby) return null;
  const feeding = snap.running.find((e) => e.type === "breast");

  return (
    <div className="today wrap-app enter">
      <div className="today-main">
        {snap.running.map((e) => <TimerCard key={e.id} ctx={ctx} event={e} now={now} onEdit={() => setOpen({ type: e.type, event: e })} />)}
        {others.map((e) => {
          const b = ctx.babies.find((x) => x.id === e.baby);
          return b ? (
            <button key={e.id} type="button" className="other-timer" onClick={() => ctx.setBaby(b.id)}>
              <TypeIcon type={e.type} size={18} /> {b.name}: {e.type === "sleep" ? "asleep" : "feeding"} for {fmtClock(now - e.start)}
            </button>
          ) : null;
        })}

        <section className="status" aria-label="Since last">
          <Status label="Last feed" value={feeding ? (openSide(parseData(feeding.data).seg) ? "Feeding now" : "Feed paused") : snap.lastFeed && since(now - snap.lastFeed.start)}
            sub={feeding ? `since ${clockTime(feeding.start)}` : snap.lastFeed && `${describe(snap.lastFeed, u, now).replace(/^.* · /, "")}, ${clockTime(snap.lastFeed.start)}`} />
          <Status label="Last diaper" value={snap.lastDiaper && since(now - snap.lastDiaper.start)}
            sub={snap.lastDiaper && `${describe(snap.lastDiaper, u, now).replace(/^.* · /, "")}, ${clockTime(snap.lastDiaper.start)}`} />
          <Status label="Sleep"
            value={snap.asleep ? (now - snap.asleep.start < 60 ? "Asleep" : `Asleep ${fmtShort(now - snap.asleep.start)}`) : snap.lastSleep?.end ? (now - snap.lastSleep.end < 60 ? "Just woke up" : `Awake ${fmtShort(now - snap.lastSleep.end)}`) : null}
            sub={snap.asleep ? `since ${clockTime(snap.asleep.start)}` : snap.lastSleep?.end ? `slept ${fmtShort(snap.lastSleep.end - snap.lastSleep.start)}` : null} />
        </section>

        <section className="log" aria-label="Log">
          <div className="tiles-main">
            {MAIN.map((type) => {
              const run = snap.running.find((e) => e.type === type);
              return (
                <button key={type} type="button" className={`tile tile-big ${run ? "on" : ""}`} onClick={() => setOpen({ type, event: run && type !== "sleep" ? run : undefined })}>
                  <TypeIcon type={type} size={26} />
                  <span className="tile-text">
                    <strong>{type === "breast" ? "Breastfeed" : TYPE_LABEL[type]}</strong>
                    <small>{run ? (type === "sleep" ? `Asleep ${fmtClock(now - run.start)}` : `Feeding ${fmtClock(sideTotals(parseData(run.data).seg, now).L + sideTotals(parseData(run.data).seg, now).R)}`) : tileHint(type, mine, now)}</small>
                  </span>
                </button>
              );
            })}
          </div>
          <div className="tiles-more">
            {MORE.map((type) => (
              <button key={type} type="button" className="tile tile-small" onClick={() => setOpen({ type })}>
                <TypeIcon type={type} size={20} />
                <span>{TYPE_LABEL[type]}</span>
              </button>
            ))}
          </div>
        </section>
      </div>

      <div className="today-side">
        <section className="card totals-card" aria-label="Today's totals">
          <h2 className="card-title">Today so far</h2>
          <dl className="totals">
            <div><dt>Feeds</dt><dd>{t.feeds}<small>{t.feeds ? [t.breastFeeds ? `${t.breastFeeds} breast` : "", t.bottles ? `${t.bottles} bottle` : ""].filter(Boolean).join(", ") : ""}</small></dd></div>
            <div><dt>Sleep</dt><dd>{t.sleepSec >= 60 ? fmtShort(t.sleepSec) : "0m"}<small>{t.sleeps ? `${t.sleeps} sleep${t.sleeps === 1 ? "" : "s"} started` : ""}</small></dd></div>
            <div><dt>Diapers</dt><dd>{t.diapers}<small>{t.diapers ? `${t.wet} wet, ${t.dirty} dirty` : ""}</small></dd></div>
            {t.bottles > 0 && <div><dt>Bottle</dt><dd>{fmtVol(t.bottleMl, u.vol)}</dd></div>}
            {t.breastFeeds > 0 && <div><dt>Breast</dt><dd>{fmtShort(t.breastSec)}</dd></div>}
            {t.pumpMl > 0 && <div><dt>Pumped</dt><dd>{fmtVol(t.pumpMl, u.vol)}</dd></div>}
            {t.solids > 0 && <div><dt>Solids</dt><dd>{t.solids}</dd></div>}
          </dl>
        </section>

        <section className="recent" aria-label="Latest entries">
          <div className="spread"><h2 className="card-title">Latest</h2><a className="small" href="/app/timeline" onClick={onLink}>Full timeline</a></div>
          {recent.length ? (
            <ul className="entries">
              {recent.map((e, i) => <EntryRow key={e.id} ctx={ctx} event={e} now={now} index={i} onOpen={() => setOpen({ type: e.type, event: e })} showDay />)}
            </ul>
          ) : (
            <div className="empty-state">
              <img className="art" src="/art/card-baby.webp" alt="" width="160" height="136" />
              <h3>Nothing logged yet</h3>
              <p>Tap Breastfeed, Bottle, Sleep or Diaper. Everyone in the family sees it right away.</p>
            </div>
          )}
        </section>

        <aside className="also">
          <p><a href="https://monitor-free.openapps.xyz/">Monitor</a> turns two phones into a free baby monitor. <a href="https://still-free.openapps.xyz/sounds">Still</a> plays white noise and rain for naps.</p>
          <p className="muted small">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>
        </aside>
      </div>

      {open && <LogSheet ctx={ctx} type={open.type} event={open.event} onClose={() => setOpen(null)} />}
    </div>
  );
}

/** "Just now" or "2h 15m ago". */
const since = (sec: number) => { const ago = fmtAgo(sec); return ago.charAt(0).toUpperCase() + ago.slice(1); };

function Status({ label, value, sub }: { label: string; value: string | null | undefined; sub?: string | null }) {
  return (
    <div className="status-cell">
      <span className="status-label">{label}</span>
      <strong className={`status-value${value ? "" : " none"}`}>{value || "None yet"}</strong>
      {sub && <span className="status-sub">{sub}</span>}
    </div>
  );
}

function tileHint(type: EventType, mine: BabyEvent[], now: number): string {
  const last = mine.filter((e) => e.type === type).sort((a, b) => b.start - a.start)[0];
  if (type === "sleep") return "Start a timer";
  if (type === "breast") {
    // The side to start on is the one the last feed did not end on.
    const side = last ? lastSide(parseData(last.data).seg) : null;
    return side ? `Next side: ${side === "L" ? "right" : "left"}` : "Left or right";
  }
  if (!last) return type === "bottle" ? "Amount, milk" : "Wet or dirty";
  return since(now - last.start);
}

function TimerCard({ ctx, event, now, onEdit }: { ctx: FamilyCtx; event: BabyEvent; now: number; onEdit: () => void }) {
  const [busy, setBusy] = useState(false);
  const d = parseData(event.data);
  const baby = ctx.babies.find((b) => b.id === event.baby);
  const act = async (fn: () => Promise<unknown>) => {
    setBusy(true);
    try { await fn(); } catch (err) { toast(explain(err)); } finally { setBusy(false); }
  };
  // Stopping by mistake is easy at 3 am, so the toast can put the timer back as it was.
  const stop = (text: string) => act(async () => {
    await stopTimer(ctx.h, event, d);
    toast(text, () => void reopenTimer(ctx.h, event).catch((err) => toast(explain(err))));
  });

  if (event.type === "sleep") {
    return (
      <section className="timer card" aria-label="Sleep timer">
        <div className="timer-top">
          <span className="timer-label"><Icon name="sleep" size={18} /> {baby && ctx.babies.length > 1 ? `${baby.name} is asleep` : "Asleep"}</span>
          <button type="button" className="link-btn" onClick={onEdit}>Since {clockTime(event.start)}</button>
        </div>
        <p className="timer-clock" aria-live="off">{fmtClock(now - event.start)}</p>
        <button type="button" className="btn btn-xl btn-block" disabled={busy} onClick={() => stop(`Saved: slept ${fmtShort(now - event.start)}`)}>Woke up</button>
      </section>
    );
  }

  const tot = sideTotals(d.seg, now);
  const side = openSide(d.seg);
  const paused = !side;
  const next: Side = (side ?? d.seg?.[d.seg.length - 1]?.[0]) === "L" ? "R" : "L";
  return (
    <section className={`timer card ${paused ? "paused" : ""}`} aria-label="Feeding timer">
      <div className="timer-top">
        <span className="timer-label"><Icon name="breast" size={18} /> {paused ? "Paused" : `Feeding on the ${side === "L" ? "left" : "right"}`}</span>
        <button type="button" className="link-btn" onClick={onEdit}>Started {clockTime(event.start)}</button>
      </div>
      <p className="timer-clock">{fmtClock(tot.L + tot.R)}</p>
      <div className="sides">
        {(["L", "R"] as Side[]).map((s) => (
          <button key={s} type="button" className="side" aria-pressed={side === s} disabled={busy}
            onClick={() => (side === s ? undefined : act(() => (paused ? resumeFeed(ctx.h, event, d, s) : switchSide(ctx.h, event, d))))}>
            <span>{s === "L" ? "Left" : "Right"}</span>
            <strong>{fmtClock(tot[s])}</strong>
          </button>
        ))}
      </div>
      <div className="timer-actions">
        {paused ? (
          <button type="button" className="btn btn-xl" disabled={busy} onClick={() => act(() => resumeFeed(ctx.h, event, d))}><Icon name="play" /> Resume</button>
        ) : (
          <button type="button" className="btn btn-xl" disabled={busy} onClick={() => act(() => switchSide(ctx.h, event, d))}><Icon name="swap" /> Switch to {next === "L" ? "left" : "right"}</button>
        )}
        <div className="two">
          {!paused && <button type="button" className="btn btn-line btn-lg" disabled={busy} onClick={() => act(() => pauseFeed(ctx.h, event, d))}><Icon name="pause" /> Pause</button>}
          <button type="button" className={`btn btn-line btn-lg ${paused ? "span-2" : ""}`} disabled={busy} onClick={() => stop(`Saved: fed ${fmtShort(tot.L + tot.R)}`)}><Icon name="check" /> Done</button>
        </div>
      </div>
    </section>
  );
}

export function EntryRow({ ctx, event, now, onOpen, showDay, index = 0 }: { ctx: FamilyCtx; event: BabyEvent; now: number; onOpen: () => void; showDay?: boolean; index?: number }) {
  const [u] = useUnits();
  const d = parseData(event.data);
  const desc = describe(event, u, now);
  const [title, detail] = desc.includes(" · ") ? [desc.slice(0, desc.indexOf(" · ")), desc.slice(desc.indexOf(" · ") + 3)] : [desc, ""];
  const today = localDayStart(now);
  const day = event.start < today ? (event.start >= addDays(today, -1) ? "Yesterday " : new Date(event.start * 1000).toLocaleDateString(undefined, { month: "short", day: "numeric" }) + " ") : "";
  return (
    <li className="enter" style={{ ["--i" as string]: index }}>
      <button type="button" className="entry" onClick={onOpen}>
        <TypeIcon type={event.type} size={20} />
        <span className="entry-main">
          <span className="entry-title">{title}{detail && <span className="entry-detail"> {detail}</span>}{isRunning(event) && <span className="live-dot"> running</span>}</span>
          <span className="entry-meta">
            {showDay ? day : ""}{clockTime(event.start)}{event.end && event.type === "sleep" ? ` to ${clockTime(event.end)}` : ""} · {ctx.nameOf(event.by)}
            {d.note ? ` · ${d.note}` : ""}
          </span>
        </span>
        <Icon name="next" size={16} />
      </button>
    </li>
  );
}

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