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/ui.tsx
// Small shared pieces: bottom sheet, toast, copy and share, icons, steppers, choice rows, a ticking clock.
import { useEffect, useRef, useState, type ReactNode } from "react";

/** A sheet's open state with an animated close: call close(), and onClose runs once the exit animation ends. */
export function useSheet(onClose: () => void) {
  const [closing, setClosing] = useState(false);
  return { closing, close: () => setClosing(true), onClosed: onClose };
}
export type SheetHandle = ReturnType<typeof useSheet>;

const FOCUSABLE = "a[href], button:not(:disabled), input:not(:disabled), select:not(:disabled), textarea:not(:disabled), [tabindex]:not([tabindex='-1'])";

/** A dialog: a centered card on wide screens, a bottom sheet on phones. */
export function Sheet({ title, sheet, children, footer }: { title: string; sheet: SheetHandle; children: ReactNode; footer?: ReactNode }) {
  const ref = useRef<HTMLDivElement>(null);
  const close = useRef(sheet.close);
  close.current = sheet.close;
  useEffect(() => {
    const prev = document.activeElement as HTMLElement | null;
    const onKey = (e: KeyboardEvent) => {
      if (e.key === "Escape") close.current();
      if (e.key !== "Tab" || !ref.current) return;
      // Keep Tab inside the sheet.
      const items = [...ref.current.querySelectorAll<HTMLElement>(FOCUSABLE)];
      const first = items[0], last = items[items.length - 1];
      if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last?.focus(); }
      else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first?.focus(); }
    };
    document.addEventListener("keydown", onKey);
    document.body.classList.add("sheet-open");
    // React's autoFocus has already focused a field; otherwise focus the sheet itself.
    if (!ref.current?.contains(document.activeElement)) ref.current?.focus();
    return () => {
      document.removeEventListener("keydown", onKey);
      document.body.classList.remove("sheet-open");
      prev?.focus?.();
    };
  }, []);
  return (
    <div className={`dialog-backdrop${sheet.closing ? " leaving" : ""}`}
      onMouseDown={(e) => { if (e.target === e.currentTarget) sheet.close(); }}
      onAnimationEnd={(e) => { if (sheet.closing && e.target === ref.current) sheet.onClosed(); }}>
      <div className="dialog" role="dialog" aria-modal="true" aria-labelledby="sheet-title" ref={ref} tabIndex={-1}>
        <div className="dialog-head">
          <h2 className="dialog-title" id="sheet-title">{title}</h2>
          <button className="btn btn-quiet btn-icon" type="button" onClick={sheet.close} aria-label="Close"><Icon name="close" /></button>
        </div>
        <div className="dialog-body">{children}</div>
        {footer && <div className="dialog-foot">{footer}</div>}
      </div>
    </div>
  );
}

/** Asks before something that cannot be undone. */
export function Confirm({ title, text, action, danger, onConfirm, onClose }: {
  title: string; text: string; action: string; danger?: boolean; onConfirm: () => Promise<unknown>; onClose: () => void;
}) {
  const sheet = useSheet(onClose);
  const [busy, setBusy] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const go = async () => {
    setBusy(true);
    setError(null);
    try { await onConfirm(); sheet.close(); } catch (err) { setError(err instanceof Error ? err.message : String(err)); setBusy(false); }
  };
  return (
    <Sheet title={title} sheet={sheet} footer={<>
      <button type="button" className="btn btn-line" onClick={sheet.close}>Cancel</button>
      <button type="button" className={`btn ${danger ? "btn-danger-solid" : ""}`} onClick={go} aria-busy={busy || undefined}>{action}</button>
    </>}>
      <p>{text}</p>
      {error && <p className="notice notice-error" role="alert">{error}</p>}
    </Sheet>
  );
}

let pushToast: ((text: string, undo?: () => void) => void) | null = null;
/** A short note at the bottom of the screen, with an optional Undo. */
export function toast(text: string, undo?: () => void) { pushToast?.(text, undo); }

export function Toaster() {
  const [item, setItem] = useState<{ text: string; undo?: () => void; key: number; leaving?: boolean } | null>(null);
  useEffect(() => {
    let timer: number | undefined;
    pushToast = (text, undo) => {
      setItem({ text, undo, key: Date.now() });
      window.clearTimeout(timer);
      timer = window.setTimeout(() => setItem((t) => (t ? { ...t, leaving: true } : t)), undo ? 6000 : 3000);
    };
    return () => { pushToast = null; window.clearTimeout(timer); };
  }, []);
  if (!item) return null;
  return (
    <div key={item.key} className={`toast${item.leaving ? " leaving" : ""}`} role="status"
      onAnimationEnd={() => { if (item.leaving) setItem(null); }}>
      <span>{item.text}</span>
      {item.undo && <button type="button" onClick={() => { item.undo?.(); setItem({ ...item, undo: undefined, leaving: true }); }}>Undo</button>}
    </div>
  );
}

/** Placeholder shapes in the app's layout while the log opens, so nothing jumps when it arrives. */
export function AppSkeleton() {
  return (
    <div className="wrap-app app-skeleton" aria-busy="true" aria-label="Opening the log">
      <div className="baby-bar"><span className="skeleton" style={{ width: 170, height: 34 }} /><span className="skeleton" style={{ width: 44, height: 44, borderRadius: 12 }} /></div>
      <div className="today">
        <div className="today-main">
          <span className="skeleton" style={{ height: 86, borderRadius: 16 }} />
          <div className="tiles-main">{[0, 1, 2, 3].map((i) => <span key={i} className="skeleton" style={{ height: 92, borderRadius: 16 }} />)}</div>
        </div>
        <div className="today-side"><span className="skeleton" style={{ height: 150, borderRadius: 18 }} /></div>
      </div>
    </div>
  );
}

export async function copy(text: string, done = "Copied") {
  try {
    await navigator.clipboard.writeText(text);
    toast(done);
  } catch {
    window.prompt("Copy this:", text);
  }
}

export async function share(url: string, title: string, text: string) {
  if (navigator.share && matchMedia("(pointer: coarse)").matches) {
    try { await navigator.share({ title, text, url }); return; } catch { /* cancelled */ }
  }
  await copy(url, "Link copied. Send it to your partner or a caregiver.");
}

/** The current time in unix seconds, ticking every `ms`. */
export function useNow(ms = 1000): number {
  const [now, setNow] = useState(() => Math.floor(Date.now() / 1000));
  useEffect(() => {
    const tick = () => setNow(Math.floor(Date.now() / 1000));
    const t = window.setInterval(tick, ms);
    document.addEventListener("visibilitychange", tick);
    return () => { window.clearInterval(t); document.removeEventListener("visibilitychange", tick); };
  }, [ms]);
  return now;
}

/** A row of choices where one is picked. */
export function Choice<T extends string>({ label, options, value, onChange }: { label: string; options: { value: T; label: string }[]; value: T; onChange: (v: T) => void }) {
  return (
    <div className="choice" role="radiogroup" aria-label={label} style={{ ["--n" as string]: String(options.length) }}>
      {options.map((o) => (
        <button key={o.value} type="button" role="radio" aria-checked={o.value === value} onClick={() => onChange(o.value)}>{o.label}</button>
      ))}
    </div>
  );
}

/** A number with big minus and plus buttons, for one-handed amounts. */
export function Stepper({ label, value, onChange, step, unit, min = 0, max = 9999, digits = 0 }: {
  label: string; value: number; onChange: (v: number) => void; step: number; unit: string; min?: number; max?: number; digits?: number;
}) {
  const clamp = (v: number) => Math.min(max, Math.max(min, Number(v.toFixed(digits))));
  return (
    <div className="stepper">
      <span className="stepper-label">{label}</span>
      <div className="stepper-row">
        <button type="button" className="stepper-btn" aria-label={`Less ${label.toLowerCase()}`} onClick={() => onChange(clamp(value - step))}><Icon name="minus" size={22} /></button>
        <label className="stepper-value">
          <input inputMode="decimal" aria-label={`${label} in ${unit}`} value={Number.isFinite(value) ? String(value) : ""}
            onChange={(e) => { const v = Number(e.target.value.replace(",", ".")); onChange(Number.isFinite(v) ? Math.min(max, Math.max(0, v)) : 0); }} />
          <span>{unit}</span>
        </label>
        <button type="button" className="stepper-btn" aria-label={`More ${label.toLowerCase()}`} onClick={() => onChange(clamp(value + step))}><Icon name="plus" size={22} /></button>
      </div>
    </div>
  );
}

const PATHS: Record<string, ReactNode> = {
  breast: <path d="M12 3.5c3.2 4.2 6 7.4 6 10.6a6 6 0 0 1-12 0c0-3.2 2.8-6.4 6-10.6Z" />,
  bottle: <><path d="M9.5 3h5M10.5 3v2.6L8.6 8A2 2 0 0 0 8 9.4V19a2 2 0 0 0 2 2h4a2 2 0 0 0 2-2V9.4a2 2 0 0 0-.6-1.4l-1.9-2.4V3" /><path d="M8 12.5h4M8 16h4" /></>,
  pump: <><path d="M6.5 9.5h11l-1.6 10.1a1.6 1.6 0 0 1-1.6 1.4H9.7a1.6 1.6 0 0 1-1.6-1.4Z" /><path d="M9 9.5V7a3 3 0 0 1 6 0v2.5M9.5 14h5" /></>,
  solids: <><ellipse cx="9" cy="7" rx="3" ry="4" /><path d="M9 11v10M16 3v18M16 3c1.8 1 2.5 3 2.5 5s-1 3-2.5 3" /></>,
  sleep: <path d="M19.5 14.5A8 8 0 0 1 9.5 4.5a8 8 0 1 0 10 10Z" />,
  diaper: <><path d="M3.5 7.5h17V10c0 4.7-3.8 8.5-8.5 8.5S3.5 14.7 3.5 10Z" /><path d="M8 7.5c0 2.2 1.8 4 4 4s4-1.8 4-4" /></>,
  growth: <><path d="M3.5 16.5 16.5 3.5l4 4-13 13Z" /><path d="M7.5 12.5 9 14M10.5 9.5 12 11M13.5 6.5 15 8" /></>,
  medicine: <><path d="M10.6 20.4a4.6 4.6 0 0 1-6.5-6.5l5.8-5.8a4.6 4.6 0 0 1 6.5 6.5Z" /><path d="m7 11 6 6" /></>,
  temp: <><path d="M14 14.2V5a2 2 0 0 0-4 0v9.2a4 4 0 1 0 4 0Z" /><path d="M12 15.5V9" /></>,
  note: <><path d="M4 20h4L19.2 8.8a2.8 2.8 0 0 0-4-4L4 16Z" /><path d="m13.5 6.5 4 4" /></>,
  today: <><circle cx="12" cy="12" r="8.5" /><path d="M12 7v5l3 2" /></>,
  timeline: <path d="M8 6h12M8 12h12M8 18h12M4 6h.01M4 12h.01M4 18h.01" />,
  charts: <path d="M4 20V11M10 20V5M16 20v-6M2.5 20.5h19" />,
  family: <><circle cx="9" cy="8" r="3" /><path d="M3.5 19c.6-3 2.8-4.8 5.5-4.8s4.9 1.8 5.5 4.8" /><circle cx="16.5" cy="9" r="2.3" /><path d="M16.5 13.5c2.1 0 3.6 1.3 4 3.6" /></>,
  close: <path d="M6 6l12 12M18 6 6 18" />,
  back: <path d="M15 5l-7 7 7 7" />,
  next: <path d="M9 5l7 7-7 7" />,
  plus: <path d="M12 5v14M5 12h14" />,
  minus: <path d="M5 12h14" />,
  share: <path d="M12 3v12M7 8l5-5 5 5M5 13v6h14v-6" />,
  swap: <path d="M7 7h12l-3-3M17 17H5l3 3" />,
  pause: <path d="M9 5.5v13M15 5.5v13" />,
  play: <path d="M8 5.5v13l10.5-6.5Z" />,
  check: <path d="M5 12.5l4.5 4.5L19 7.5" />,
  down: <path d="M6 9l6 6 6-6" />,
  moon: <path d="M19.5 14.5A8 8 0 0 1 9.5 4.5a8 8 0 1 0 10 10Z" />,
  sun: <><circle cx="12" cy="12" r="4" /><path d="M12 2.5v2M12 19.5v2M2.5 12h2M19.5 12h2M5.3 5.3l1.4 1.4M17.3 17.3l1.4 1.4M5.3 18.7l1.4-1.4M17.3 6.7l1.4-1.4" /></>,
  print: <><path d="M7 9V3.5h10V9M7 17H4.5V10a1 1 0 0 1 1-1h13a1 1 0 0 1 1 1v7H17" /><path d="M7 14h10v6.5H7Z" /></>,
  download: <path d="M12 4v11M7 10l5 5 5-5M5 20h14" />,
  key: <><circle cx="8" cy="15" r="4" /><path d="m11 12 8.5-8.5M16 7l2.5 2.5M14 9l2 2" /></>,
  edit: <path d="M4 20h4L19.2 8.8a2.8 2.8 0 0 0-4-4L4 16Z" />,
};

export type IconName = keyof typeof PATHS;

export function Icon({ name, size = 20 }: { name: IconName | string; size?: number }) {
  return (
    <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
      {PATHS[name]}
    </svg>
  );
}

/** Local date and time for a datetime-local input. */
export function toLocalInput(sec: number): string {
  const d = new Date(sec * 1000);
  const p = (n: number) => String(n).padStart(2, "0");
  return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}T${p(d.getHours())}:${p(d.getMinutes())}`;
}
export function fromLocalInput(v: string): number {
  const t = new Date(v).getTime();
  return Number.isFinite(t) ? Math.floor(t / 1000) : NaN;
}

export const clockTime = (sec: number) => new Date(sec * 1000).toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" });

/** "Today", "Yesterday" or "Mon, Oct 13". */
export function dayLabel(sec: number): string {
  const d = new Date(sec * 1000);
  const today = new Date();
  const y = new Date(); y.setDate(today.getDate() - 1);
  if (d.toDateString() === today.toDateString()) return "Today";
  if (d.toDateString() === y.toDateString()) return "Yesterday";
  return d.toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric", ...(d.getFullYear() !== today.getFullYear() ? { year: "numeric" } : {}) });
}

/** Local midnight of the day containing `sec`, in this device's time zone. */
export function localDayStart(sec: number): number {
  const d = new Date(sec * 1000);
  d.setHours(0, 0, 0, 0);
  return Math.floor(d.getTime() / 1000);
}
/** Local midnight `n` days after (or before) a midnight, safe across daylight saving changes. */
export function addDays(midnight: number, n: number): number {
  const d = new Date(midnight * 1000);
  d.setDate(d.getDate() + n);
  d.setHours(0, 0, 0, 0);
  return Math.floor(d.getTime() / 1000);
}

/** "3 weeks old" from YYYY-MM-DD. */
export function ageText(birth: string): string | null {
  if (!/^\d{4}-\d{2}-\d{2}$/.test(birth)) return null;
  const b = new Date(`${birth}T00:00:00`);
  const days = Math.floor((Date.now() - b.getTime()) / 864e5);
  if (days < 0) return "Due soon";
  if (days === 0) return "Born today";
  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`;
}

type InstallPrompt = Event & { prompt(): Promise<void>; userChoice: Promise<{ outcome: string }> };
let early: InstallPrompt | null = null;
window.addEventListener("beforeinstallprompt", (e) => { e.preventDefault(); early = e as InstallPrompt; });

export function Install() {
  const [prompt, setPrompt] = useState<InstallPrompt | null>(early);
  const [installed, setInstalled] = useState(() => matchMedia("(display-mode: standalone)").matches);
  useEffect(() => {
    const offer = (e: Event) => { e.preventDefault(); setPrompt(e as InstallPrompt); };
    const done = () => { setInstalled(true); setPrompt(null); };
    window.addEventListener("beforeinstallprompt", offer);
    window.addEventListener("appinstalled", done);
    return () => { window.removeEventListener("beforeinstallprompt", offer); window.removeEventListener("appinstalled", done); };
  }, []);
  if (installed) return <p className="muted small">Baby is on your home screen.</p>;
  if (prompt) {
    return <button type="button" className="btn btn-line" onClick={async () => { await prompt.prompt(); const { outcome } = await prompt.userChoice; if (outcome === "accepted") setInstalled(true); setPrompt(null); }}>Add Baby to your home screen</button>;
  }
  return <p className="muted small">On iPhone, tap Share, then Add to Home Screen. In Chrome or Edge, choose Install app from the menu.</p>;
}

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