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/LogSheets.tsx
// The sheets for logging and editing each kind of entry. Big buttons for the common case
// (start a feed, a wet diaper, a bottle of the usual size), and a time field for anything earlier.
import { useState, type ReactNode } from "react";
import { addEvent, updateEvent, deleteEvent, restoreEvent, startFeed, startSleep, stopTimer, reopenTimer } from "./lib/family";
import { explain } from "./lib/bounded";
import { useUnits } from "./lib/prefs";
import { Sheet, useSheet, Choice, Stepper, Icon, toast, toLocalInput, fromLocalInput, clockTime } from "./ui";
import {
  TYPE_LABEL, DIAPER_COLORS, parseData, sideTotals, lastSide, isRunning, describe, toMl, fromMl, toC, KG_PER_LB, CM_PER_IN,
  type BabyEvent, type DiaperColor, type EventData, type EventType, type Segment, type Side, type Units,
} from "../functions/lib/baby";
import type { FamilyCtx } from "./App";

const nowSec = () => Math.floor(Date.now() / 1000);

/** Shows what was saved, with Undo for new entries. */
function saved(ctx: FamilyCtx, id: string, text: string, isNew: boolean) {
  toast(`Saved: ${text}`, isNew ? () => void deleteEvent(ctx.h, id).catch(() => null) : undefined);
}

function WhenField({ label = "When", value, onChange }: { label?: string; value: number; onChange: (v: number) => void }) {
  return (
    <label className="field when">
      <span>{label}</span>
      <div className="when-row">
        <input className="input" type="datetime-local" value={toLocalInput(value)} max={toLocalInput(nowSec() + 60)}
          onChange={(e) => { const v = fromLocalInput(e.target.value); if (Number.isFinite(v)) onChange(v); }} />
        <button type="button" className="btn btn-line btn-sm" onClick={() => onChange(nowSec())}>Now</button>
      </div>
    </label>
  );
}

function NoteField({ value, onChange }: { value: string; onChange: (v: string) => void }) {
  return (
    <label className="field"><span>Note <em>optional</em></span>
      <input className="input" value={value} maxLength={300} onChange={(e) => onChange(e.target.value)} placeholder="Anything to remember" />
    </label>
  );
}

function Foot({ busy, onSave, onDelete }: { busy: boolean; onSave: () => void; onDelete?: () => void }) {
  return (
    <>
      {onDelete && <button type="button" className="btn btn-quiet btn-danger" onClick={onDelete} disabled={busy}>Delete</button>}
      <span className="grow" />
      <button type="button" className="btn btn-lg btn-save" onClick={onSave} aria-busy={busy || undefined}>Save</button>
    </>
  );
}

interface Props { ctx: FamilyCtx; type: EventType; event?: BabyEvent; onClose: () => void }

/** Opens the right sheet for a kind of entry, new or existing. */
export function LogSheet(props: Props) {
  const { type } = props;
  if (type === "breast") return <BreastSheet {...props} />;
  if (type === "sleep") return <SleepSheet {...props} />;
  if (type === "diaper") return <DiaperSheet {...props} />;
  return <DetailSheet {...props} />;
}

/** Saving and deleting, shared by every sheet. */
function useSave({ ctx, event, onClose }: Props, u: Units) {
  const sheet = useSheet(onClose);
  const [busy, setBusy] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const run = async (fn: () => Promise<void>) => {
    setBusy(true);
    setError(null);
    try {
      await fn();
      sheet.close();
    } catch (err) {
      setError(explain(err));
      setBusy(false);
    }
  };
  const save = (type: EventType, start: number, data: EventData, end?: number | null) => run(async () => {
    if (!ctx.baby) throw new Error("Add a baby first, under Family.");
    if (end != null && end < start) throw new Error("The end is before the start.");
    if (start > nowSec() + 120) throw new Error("That time is in the future.");
    const text = describe({ id: "", baby: ctx.baby.id, type, start, end, data: JSON.stringify(data), by: "" }, u, nowSec());
    if (event) {
      await updateEvent(ctx.h, event.id, { start, end, data });
      saved(ctx, event.id, text, false);
    } else {
      const id = await addEvent(ctx.h, { baby: ctx.baby.id, type, start, end, data });
      saved(ctx, id, text, true);
    }
  });
  // Deleting asks nothing: the toast's Undo brings the entry back.
  const remove = event ? () => run(async () => {
    await deleteEvent(ctx.h, event.id);
    toast(`Deleted: ${TYPE_LABEL[event.type].toLowerCase()}`, () => void restoreEvent(ctx.h, event.id).catch(() => null));
  }) : undefined;
  return { sheet, busy, error, setError, save, remove, run };
}

const Err = ({ error }: { error: string | null }) => (error ? <p className="notice notice-error" role="alert">{error}</p> : null);

function Divider({ children }: { children: ReactNode }) {
  return <p className="divider"><span>{children}</span></p>;
}

// ---------- Breastfeeding ----------

function BreastSheet(props: Props) {
  const { ctx, event } = props;
  const [u] = useUnits();
  const { sheet, busy, error, save, remove, run } = useSave(props, u);
  const d = event ? parseData(event.data) : {};
  const running = event ? isRunning(event) : false;
  const totals = sideTotals(d.seg, nowSec());
  const [start, setStart] = useState(event?.start ?? nowSec() - 15 * 60);
  const [left, setLeft] = useState(Math.round(totals.L / 60) || (event ? 0 : 10));
  const [right, setRight] = useState(Math.round(totals.R / 60) || (event ? 0 : 5));

  const prev = ctx.events.filter((e) => e.type === "breast" && e.baby === ctx.baby?.id && !isRunning(e)).sort((a, b) => b.start - a.start)[0];
  const prevSide = prev ? lastSide(parseData(prev.data).seg) : null;
  const suggest: Side = prevSide === "L" ? "R" : "L";

  const begin = (side: Side) => run(async () => {
    if (!ctx.baby) throw new Error("Add a baby first, under Family.");
    const id = await startFeed(ctx.h, ctx.baby.id, side);
    toast(`Feeding on the ${side === "L" ? "left" : "right"}. The timer keeps going if you close the app.`, () => void deleteEvent(ctx.h, id).catch(() => null));
  });

  const saveManual = () => {
    if (running && event) {
      // A running feed: only its start moves.
      const seg = [...(d.seg || [])] as Segment[];
      if (seg[0]) seg[0] = [seg[0][0], start, seg[0][2]];
      return save("breast", start, { ...d, seg }, undefined);
    }
    if (!left && !right) return save("breast", start, d, start);
    const unchanged = event && start === event.start && left === Math.round(totals.L / 60) && right === Math.round(totals.R / 60);
    if (unchanged) return save("breast", start, d, event!.end ?? start);
    const seg: Segment[] = [];
    let t = start;
    for (const [side, mins] of [["L", left], ["R", right]] as [Side, number][]) {
      if (!mins) continue;
      seg.push([side, t, t + Math.round(mins * 60)]);
      t += Math.round(mins * 60);
    }
    return save("breast", start, { ...d, seg }, t);
  };

  if (!event) {
    return (
      <Sheet title="Breastfeed" sheet={sheet}>
        <p className="muted">{prevSide ? `The last feed ended on the ${prevSide === "L" ? "left" : "right"}, so start on the ${suggest === "L" ? "left" : "right"}.` : "Pick the side to start on. The timer runs on every phone in the family."}</p>
        <div className="big-pair">
          {(["L", "R"] as Side[]).map((s) => (
            <button key={s} type="button" className={`big-btn ${s === suggest ? "primary" : ""}`} onClick={() => begin(s)} disabled={busy}>
              <span className="big-btn-title">Start {s === "L" ? "left" : "right"}</span>
              {s === suggest && prevSide && <span className="big-btn-sub">Suggested</span>}
            </button>
          ))}
        </div>
        <Divider>Or log a feed that already happened</Divider>
        <WhenField label="Started" value={start} onChange={setStart} />
        <div className="two">
          <Stepper label="Left" value={left} onChange={setLeft} step={1} unit="min" max={180} />
          <Stepper label="Right" value={right} onChange={setRight} step={1} unit="min" max={180} />
        </div>
        <Err error={error} />
        <button type="button" className="btn btn-line btn-lg btn-block" onClick={saveManual} disabled={busy}>Save past feed</button>
      </Sheet>
    );
  }
  return (
    <Sheet title={running ? "Feed in progress" : "Breastfeed"} sheet={sheet} footer={<Foot busy={busy} onSave={saveManual} onDelete={remove} />}>
      <WhenField label="Started" value={start} onChange={setStart} />
      {running ? <p className="muted small">The timer keeps running. Change the start if you began before tapping Start.</p> : (
        <div className="two">
          <Stepper label="Left" value={left} onChange={setLeft} step={1} unit="min" max={180} />
          <Stepper label="Right" value={right} onChange={setRight} step={1} unit="min" max={180} />
        </div>
      )}
      <Err error={error} />
    </Sheet>
  );
}

// ---------- Sleep ----------

function SleepSheet(props: Props) {
  const { ctx, event } = props;
  const [u] = useUnits();
  const { sheet, busy, error, save, remove, run } = useSave(props, u);
  const running = event ? isRunning(event) : false;
  const asleep = !event && ctx.baby ? ctx.events.find((e) => e.type === "sleep" && e.baby === ctx.baby!.id && isRunning(e)) : undefined;
  const [start, setStart] = useState(event?.start ?? nowSec() - 60 * 60);
  const [end, setEnd] = useState(event?.end ?? nowSec());
  const [note, setNote] = useState(event ? parseData(event.data).note || "" : "");

  const begin = () => run(async () => {
    if (!ctx.baby) throw new Error("Add a baby first, under Family.");
    const id = await startSleep(ctx.h, ctx.baby.id);
    toast("Sleep timer started. Tap Woke up when they wake.", () => void deleteEvent(ctx.h, id).catch(() => null));
  });
  const wake = (e: BabyEvent) => run(async () => {
    await stopTimer(ctx.h, e, parseData(e.data));
    toast("Saved the sleep.", () => void reopenTimer(ctx.h, e).catch(() => null));
  });
  const data = (): EventData => (note.trim() ? { note: note.trim() } : {});

  if (asleep) {
    return (
      <Sheet title="Sleep" sheet={sheet}>
        <p className="muted">Asleep since {clockTime(asleep.start)}.</p>
        <button type="button" className="big-btn primary block" onClick={() => wake(asleep)} disabled={busy}><span className="big-btn-title">Woke up now</span></button>
        <Err error={error} />
      </Sheet>
    );
  }
  if (!event) {
    return (
      <Sheet title="Sleep" sheet={sheet}>
        <button type="button" className="big-btn primary block" onClick={begin} disabled={busy}>
          <span className="big-btn-title">Fell asleep now</span>
          <span className="big-btn-sub">Starts a timer everyone in the family sees</span>
        </button>
        <Divider>Or log a sleep that already happened</Divider>
        <WhenField label="Fell asleep" value={start} onChange={setStart} />
        <WhenField label="Woke up" value={end} onChange={setEnd} />
        <Err error={error} />
        <button type="button" className="btn btn-line btn-lg btn-block" onClick={() => save("sleep", start, data(), end)} disabled={busy}>Save past sleep</button>
      </Sheet>
    );
  }
  return (
    <Sheet title={running ? "Sleep in progress" : "Sleep"} sheet={sheet} footer={<Foot busy={busy} onSave={() => save("sleep", start, data(), running ? undefined : end)} onDelete={remove} />}>
      <WhenField label="Fell asleep" value={start} onChange={setStart} />
      {!running && <WhenField label="Woke up" value={end} onChange={setEnd} />}
      <NoteField value={note} onChange={setNote} />
      <Err error={error} />
    </Sheet>
  );
}

// ---------- Diapers ----------

/** Optional color for a dirty diaper; tap the picked one again to clear it. */
function ColorPick({ value, onChange }: { value?: DiaperColor; onChange: (c: DiaperColor | undefined) => void }) {
  return (
    <div className="field"><span className="label">Color <em>optional, for dirty diapers</em></span>
      <div className="presets" role="group" aria-label="Diaper color">
        {DIAPER_COLORS.map((c) => (
          <button key={c} type="button" className="preset" aria-pressed={value === c} onClick={() => onChange(value === c ? undefined : c)}>
            <i className={`dot-color c-${c}`} aria-hidden="true" />{c === "pale" ? "Pale or white" : c[0].toUpperCase() + c.slice(1)}
          </button>
        ))}
      </div>
    </div>
  );
}

function DiaperSheet(props: Props) {
  const { event } = props;
  const [u] = useUnits();
  const { sheet, busy, error, save, remove } = useSave(props, u);
  const d = event ? parseData(event.data) : {};
  const [at, setAt] = useState(event?.start ?? nowSec());
  const [kind, setKind] = useState<"wet" | "dirty" | "both">(d.kind || "wet");
  const [color, setColor] = useState<DiaperColor | undefined>(d.color);
  const [note, setNote] = useState(d.note || "");
  const [more, setMore] = useState(Boolean(event));
  const data = (k: typeof kind): EventData => ({ kind: k, ...(color && k !== "wet" ? { color } : {}), ...(note.trim() ? { note: note.trim() } : {}) });

  if (!event) {
    return (
      <Sheet title="Diaper" sheet={sheet}>
        {more ? <><WhenField value={at} onChange={setAt} /><ColorPick value={color} onChange={setColor} /><NoteField value={note} onChange={setNote} /><p className="muted">Then tap one to save.</p></> : (
          <p className="muted">Tap one to save it for now. <button type="button" className="link-btn" onClick={() => setMore(true)}>Change the time or add details</button></p>
        )}
        <div className="big-trio">
          {(["wet", "dirty", "both"] as const).map((k) => (
            <button key={k} type="button" className="big-btn" onClick={() => save("diaper", at, data(k))} disabled={busy}>
              <span className="big-btn-title">{k === "wet" ? "Wet" : k === "dirty" ? "Dirty" : "Both"}</span>
            </button>
          ))}
        </div>
        <Err error={error} />
      </Sheet>
    );
  }
  return (
    <Sheet title="Diaper" sheet={sheet} footer={<Foot busy={busy} onSave={() => save("diaper", at, data(kind))} onDelete={remove} />}>
      <WhenField value={at} onChange={setAt} />
      <Choice label="Kind" value={kind} onChange={setKind} options={[{ value: "wet", label: "Wet" }, { value: "dirty", label: "Dirty" }, { value: "both", label: "Both" }]} />
      {kind !== "wet" && <ColorPick value={color} onChange={setColor} />}
      <NoteField value={note} onChange={setNote} />
      <Err error={error} />
    </Sheet>
  );
}

// ---------- Bottle, pump, solids, growth, medicine, temperature, note ----------

function DetailSheet(props: Props) {
  const { ctx, type, event } = props;
  const [u] = useUnits();
  const { sheet, busy, error, setError, save, remove } = useSave(props, u);
  const d = event ? parseData(event.data) : {};
  const mine = ctx.events.filter((e) => e.baby === ctx.baby?.id).sort((a, b) => b.start - a.start);
  const lastOf = (t: EventType) => mine.find((e) => e.type === t);
  const oz = u.vol === "oz";
  const volStep = oz ? 0.5 : 10;
  const volDefault = (ml: number | undefined, fallbackMl: number) => Number(fromMl(ml ?? fallbackMl, u.vol).toFixed(oz ? 1 : 0));
  const lastBottle = parseData(lastOf("bottle")?.data);

  const [at, setAt] = useState(event?.start ?? nowSec());
  const [note, setNote] = useState(d.note || "");
  const [milk, setMilk] = useState<"formula" | "breast">(d.milk || lastBottle.milk || "formula");
  const [amount, setAmount] = useState(volDefault(event ? d.ml : lastBottle.ml, 90));
  const [pl, setPl] = useState(volDefault(d.l, event ? 0 : 60));
  const [pr, setPr] = useState(volDefault(d.r, event ? 0 : 60));
  const [food, setFood] = useState(d.food || "");
  const [foodAmount, setFoodAmount] = useState(d.amount || "");
  const [kg, setKg] = useState(d.kg ? (u.weight === "kg" ? String(d.kg) : "") : "");
  const [lb, setLb] = useState(d.kg && u.weight === "lb" ? String(Math.floor(d.kg / KG_PER_LB)) : "");
  const [lbOz, setLbOz] = useState(d.kg && u.weight === "lb" ? String(Number(((d.kg / KG_PER_LB % 1) * 16).toFixed(1))) : "");
  const lenIn = (cm?: number) => (cm ? String(u.length === "in" ? Number((cm / CM_PER_IN).toFixed(1)) : cm) : "");
  const [len, setLen] = useState(lenIn(d.cm));
  const [head, setHead] = useState(lenIn(d.head));
  const [medName, setMedName] = useState(d.name || "");
  const [dose, setDose] = useState(d.dose || "");
  const [temp, setTemp] = useState(d.c != null ? Number((u.temp === "f" ? d.c * 9 / 5 + 32 : d.c).toFixed(1)) : u.temp === "f" ? 98.6 : 37.0);
  const [text, setText] = useState(d.text || "");
  const withNote = (x: EventData): EventData => (note.trim() ? { ...x, note: note.trim() } : x);
  const pastMeds = [...new Set(ctx.events.filter((e) => e.type === "medicine").map((e) => parseData(e.data).name).filter(Boolean))] as string[];

  const onSave = () => {
    const parse = (s: string) => Number(s.replace(",", "."));
    switch (type) {
      case "bottle": {
        const ml = Math.round(toMl(amount, u.vol));
        if (!ml) return setError("Enter the amount.");
        return save(type, at, withNote({ milk, ml }));
      }
      case "pump":
        if (!pl && !pr) return setError("Enter the amount pumped.");
        return save(type, at, withNote({ l: Math.round(toMl(pl, u.vol)), r: Math.round(toMl(pr, u.vol)) }));
      case "solids":
        return save(type, at, withNote({ food: food.trim().slice(0, 120) || "Food", ...(foodAmount.trim() ? { amount: foodAmount.trim().slice(0, 80) } : {}) }));
      case "growth": {
        const out: EventData = {};
        const w = u.weight === "kg" ? parse(kg) : (parse(lb || "0") + parse(lbOz || "0") / 16) * KG_PER_LB;
        if (w > 0) out.kg = Number(w.toFixed(3));
        const toCm = (s: string) => (u.length === "in" ? parse(s) * CM_PER_IN : parse(s));
        if (len && toCm(len) > 0) out.cm = Number(toCm(len).toFixed(1));
        if (head && toCm(head) > 0) out.head = Number(toCm(head).toFixed(1));
        if (!out.kg && !out.cm && !out.head) return setError("Enter a weight, length or head size.");
        return save(type, at, withNote(out));
      }
      case "medicine":
        return save(type, at, withNote({ name: medName.trim().slice(0, 80) || "Medicine", ...(dose.trim() ? { dose: dose.trim().slice(0, 80) } : {}) }));
      case "temp":
        return save(type, at, withNote({ c: Number(toC(temp, u.temp).toFixed(1)) }));
      case "note":
        return save(type, at, { text: text.trim().slice(0, 1000) || "Note" });
    }
  };

  const presets = oz ? [2, 3, 4, 5, 6] : [60, 90, 120, 150, 180];
  return (
    <Sheet title={TYPE_LABEL[type]} sheet={sheet} footer={<Foot busy={busy} onSave={onSave} onDelete={remove} />}>
      {type === "bottle" && (
        <>
          <Choice label="Milk" value={milk} onChange={setMilk} options={[{ value: "formula", label: "Formula" }, { value: "breast", label: "Breast milk" }]} />
          <Stepper label="Amount" value={amount} onChange={setAmount} step={volStep} unit={u.vol} max={oz ? 20 : 600} digits={oz ? 1 : 0} />
          <div className="presets" role="group" aria-label="Common amounts">
            {presets.map((p) => <button key={p} type="button" className="preset" aria-pressed={amount === p} onClick={() => setAmount(p)}>{p} {u.vol}</button>)}
          </div>
        </>
      )}
      {type === "pump" && (
        <div className="two">
          <Stepper label="Left" value={pl} onChange={setPl} step={volStep} unit={u.vol} max={oz ? 20 : 600} digits={oz ? 1 : 0} />
          <Stepper label="Right" value={pr} onChange={setPr} step={volStep} unit={u.vol} max={oz ? 20 : 600} digits={oz ? 1 : 0} />
        </div>
      )}
      {type === "solids" && (
        <>
          <label className="field"><span>Food</span><input className="input" value={food} onChange={(e) => setFood(e.target.value)} maxLength={120} placeholder="Banana, oatmeal, peas" autoFocus={!event} /></label>
          <label className="field"><span>How much <em>optional</em></span><input className="input" value={foodAmount} onChange={(e) => setFoodAmount(e.target.value)} maxLength={80} placeholder="3 spoons, half a jar" /></label>
        </>
      )}
      {type === "growth" && (
        <>
          {u.weight === "kg" ? (
            <label className="field"><span>Weight</span><div className="unit-input"><input className="input" inputMode="decimal" value={kg} onChange={(e) => setKg(e.target.value)} placeholder="4.2" /><span>kg</span></div></label>
          ) : (
            <div className="field"><span className="label">Weight</span>
              <div className="two">
                <div className="unit-input"><input className="input" inputMode="numeric" aria-label="Pounds" value={lb} onChange={(e) => setLb(e.target.value)} placeholder="9" /><span>lb</span></div>
                <div className="unit-input"><input className="input" inputMode="decimal" aria-label="Ounces" value={lbOz} onChange={(e) => setLbOz(e.target.value)} placeholder="4" /><span>oz</span></div>
              </div>
            </div>
          )}
          <div className="two">
            <label className="field"><span>Length</span><div className="unit-input"><input className="input" inputMode="decimal" value={len} onChange={(e) => setLen(e.target.value)} placeholder={u.length === "in" ? "21.5" : "55"} /><span>{u.length}</span></div></label>
            <label className="field"><span>Head</span><div className="unit-input"><input className="input" inputMode="decimal" value={head} onChange={(e) => setHead(e.target.value)} placeholder={u.length === "in" ? "15" : "38"} /><span>{u.length}</span></div></label>
          </div>
        </>
      )}
      {type === "medicine" && (
        <>
          <label className="field"><span>Medicine</span><input className="input" list="med-names" value={medName} onChange={(e) => setMedName(e.target.value)} maxLength={80} placeholder="Vitamin D, acetaminophen" autoFocus={!event} /></label>
          <datalist id="med-names">{pastMeds.map((n) => <option key={n} value={n} />)}</datalist>
          <label className="field"><span>Dose <em>optional</em></span><input className="input" value={dose} onChange={(e) => setDose(e.target.value)} maxLength={80} placeholder="1 drop, 2.5 ml" /></label>
        </>
      )}
      {type === "temp" && (
        <Stepper label="Temperature" value={temp} onChange={setTemp} step={0.1} unit={u.temp === "f" ? "°F" : "°C"} min={u.temp === "f" ? 90 : 32} max={u.temp === "f" ? 110 : 43} digits={1} />
      )}
      {type === "note" && (
        <label className="field"><span>Note</span><textarea className="textarea" value={text} onChange={(e) => setText(e.target.value)} maxLength={1000} placeholder="First smile, rolled over, a question for the doctor" autoFocus={!event} /></label>
      )}
      <WhenField label={type === "growth" ? "Measured" : "When"} value={at} onChange={setAt} />
      {type !== "note" && <NoteField value={note} onChange={setNote} />}
      {type === "temp" && <p className="hint">Baby is a log, not a medical device. Call your doctor about a fever, especially in a baby under 3 months.</p>}
      {type === "medicine" && <p className="hint">Check doses with your doctor or pharmacist. Baby only records what you enter.</p>}
      <Err error={error} />
    </Sheet>
  );
}

export const TILE_ICON: Record<EventType, string> = {
  breast: "breast", bottle: "bottle", pump: "pump", solids: "solids", sleep: "sleep",
  diaper: "diaper", growth: "growth", medicine: "medicine", temp: "temp", note: "note",
};

export function TypeIcon({ type, size = 22 }: { type: EventType; size?: number }) {
  return <span className={`type-icon t-${type}`}><Icon name={TILE_ICON[type]} size={size} /></span>;
}

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