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/App.tsx
// The app behind the landing page: Today, Timeline, Charts and Family, with tabs in the header on
// wide screens and at the bottom on phones. The night look turns on after dark (or always, or never).
import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { me as getMe, explain, takeLoginError, type Me } from "./lib/bounded";
import { useFamilies, useFamily, useEvents, finishUpgrade, forgetFamily, type Family, type FamilyRef, type Member } from "./lib/family";
import { useActive, useLook, write, read } from "./lib/prefs";
import { onLink, navigate } from "./lib/router";
import { Icon, AppSkeleton, toast, useNow, localDayStart, addDays, ageText } from "./ui";
import { JoinPage, StartForm } from "./Start";
import { Today } from "./Today";
import { Timeline } from "./Timeline";
import { Charts } from "./Charts";
import { FamilyPage } from "./FamilyPage";
import { Report } from "./Report";
import type { Baby, BabyEvent } from "../functions/lib/baby";
import { set } from "@bounded-sh/client";

export interface FamilyCtx {
  h: string;
  me: Me;
  house: Family;
  members: Member[];
  owners: Set<string>;
  babies: Baby[];
  allBabies: Baby[];
  baby: Baby | null;
  events: BabyEvent[];
  familyKey: string | null;
  families: FamilyRef[];
  isOwner: boolean;
  nameOf: (uid: string) => string;
  setBaby: (id: string) => void;
}

const TABS = [
  { id: "", label: "Today", icon: "today" },
  { id: "timeline", label: "Timeline", icon: "timeline" },
  { id: "charts", label: "Charts", icon: "charts" },
  { id: "family", label: "Family", icon: "family" },
] as const;

function useNightLook(off: boolean): boolean {
  const [look] = useLook();
  const now = useNow(60_000);
  const [dark, setDark] = useState(() => matchMedia("(prefers-color-scheme: dark)").matches);
  useEffect(() => {
    const q = matchMedia("(prefers-color-scheme: dark)");
    const on = () => setDark(q.matches);
    q.addEventListener("change", on);
    return () => q.removeEventListener("change", on);
  }, []);
  const hour = new Date(now * 1000).getHours();
  const night = !off && (look === "night" || (look === "auto" && (dark || hour >= 19 || hour < 7)));
  useEffect(() => {
    document.body.classList.toggle("night", night);
    document.querySelector('meta[name="theme-color"]')?.setAttribute("content", night ? "#0e0f11" : "#fdfdfc");
    return () => { document.body.classList.remove("night"); };
  }, [night]);
  return night;
}

/** A line under the header while the phone has no connection: the log is shared live, so nothing saves offline. */
function Offline() {
  const [offline, setOffline] = useState(() => !navigator.onLine);
  useEffect(() => {
    const on = () => setOffline(!navigator.onLine);
    window.addEventListener("online", on);
    window.addEventListener("offline", on);
    return () => { window.removeEventListener("online", on); window.removeEventListener("offline", on); };
  }, []);
  return offline ? <p className="offline enter" role="status">You are offline. New entries cannot be saved until you reconnect.</p> : null;
}

export default function App({ path }: { path: string }) {
  const section = path === "/join" ? "join" : path.split("/")[2] || "";
  useNightLook(section === "report");
  const [m, setM] = useState<Me | null>(null);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    if (section === "join") return;
    let cancelled = false;
    (async () => {
      try {
        const who = await getMe();
        const note = await finishUpgrade(who).catch((e) => explain(e));
        const loginError = takeLoginError();
        if (cancelled) return;
        setM(who);
        if (loginError) toast(loginError); else if (note) toast(note);
      } catch (err) {
        if (!cancelled) setError(explain(err));
      }
    })();
    return () => { cancelled = true; };
  }, [section === "join"]);

  if (section === "join") return <JoinPage />;
  if (error) return <ErrorPage text={error} />;
  if (!m) return <AppSkeleton />;
  return <><Offline /><Families me={m} section={section} /></>;
}

function Families({ me, section }: { me: Me; section: string }) {
  const { families, error } = useFamilies(me.id);
  const [active] = useActive();
  if (error) return <ErrorPage text={explain(error)} />;
  if (!families) return <AppSkeleton />;
  if (!families.length) return <NoFamily />;
  const h = families.some((f) => f.id === active.family) ? active.family! : families[0].id;
  return <FamilyApp key={h} h={h} me={me} families={families} section={section} />;
}

function NoFamily() {
  return (
    <div className="page narrow">
      <h1 className="page-title">Start your baby log</h1>
      <p className="page-lede">Add your baby, then share the family link with your partner or anyone who helps.</p>
      <div style={{ marginTop: 22 }}><StartForm /></div>
      <p className="muted small" style={{ marginTop: 18 }}>Someone already started a log for your family? Open the family link they sent you.</p>
    </div>
  );
}

function ErrorPage({ text }: { text: string }) {
  return (
    <div className="page narrow">
      <div className="error-state">
        <h2>The log did not open</h2>
        <p>{text}</p>
        <button className="btn" type="button" onClick={() => location.reload()}>Try again</button>
      </div>
    </div>
  );
}

function Removed({ h, name }: { h: string; name: string }) {
  return (
    <div className="page narrow">
      <div className="empty-state">
        <h2>You no longer have access to {name || "this log"}</h2>
        <p>The family may have removed you, or changed the link. If you should still be in it, ask someone in the family for the current link.</p>
        <button className="btn" type="button" onClick={async () => { await forgetFamily(h).catch(() => null); write("baby:active", {}); }}>Remove it from my list</button>
      </div>
    </div>
  );
}

function FamilyApp({ h, me, families, section }: { h: string; me: Me; families: FamilyRef[]; section: string }) {
  const fam = useFamily(h);
  const [active, setActive] = useActive();
  const [since] = useState(() => addDays(localDayStart(Date.now() / 1000), -7));
  const events = useEvents(h, since);
  const ref = families.find((f) => f.id === h);

  // Keep this person's list of families in step with the family's current name.
  useEffect(() => {
    if (fam.house?.name && ref && ref.name !== fam.house.name) void set(`users/${me.id}/households/${h}`, { name: fam.house.name }).catch(() => null);
  }, [fam.house?.name, ref?.name]);
  useEffect(() => { if (fam.house?.name) document.title = section === "report" ? `Baby report | ${fam.house.name}` : `${fam.house.name} | Baby`; }, [fam.house?.name, section]);

  // Removed while the log is open: the member list stops including this person before any read fails.
  if (fam.error || fam.house === null || (fam.members && !fam.members.some((x) => x.id === me.id))) return <Removed h={h} name={ref?.name || ""} />;
  if (!fam.house || !fam.members || !fam.babies || !fam.owners || !events) return <AppSkeleton />;

  const babies = fam.babies.filter((b) => !b.archived);
  const baby = babies.find((b) => b.id === active.baby?.[h]) ?? babies[0] ?? null;
  const members = fam.members;
  const ctx: FamilyCtx = {
    h, me, house: fam.house, members, owners: fam.owners, babies, allBabies: fam.babies, baby, events,
    familyKey: fam.familyKey, families, isOwner: fam.owners.has(me.id),
    nameOf: (uid) => (uid === me.id ? "you" : members.find((x) => x.id === uid)?.name ?? "a past member"),
    setBaby: (id) => setActive({ ...read("baby:active", {}), family: h, baby: { ...(active.baby || {}), [h]: id } }),
  };

  const nav = document.getElementById("app-nav");
  const tabs = TABS.map((t) => (
    <a key={t.id} href={`/app${t.id ? `/${t.id}` : ""}`} onClick={onLink} className="tab" aria-current={section === t.id ? "page" : undefined}>
      <Icon name={t.icon} size={22} /><span>{t.label}</span>
    </a>
  ));

  if (section === "report") return <Report ctx={ctx} />;
  return (
    <>
      {nav && createPortal(tabs, nav)}
      <div className="app-body">
        {section !== "family" && <BabyBar ctx={ctx} />}
        {section === "" && <Today ctx={ctx} />}
        {section === "timeline" && <Timeline ctx={ctx} />}
        {section === "charts" && <Charts ctx={ctx} />}
        {section === "family" && <FamilyPage ctx={ctx} />}
        {!["", "timeline", "charts", "family"].includes(section) && <NotFound />}
      </div>
      <nav className="tabbar" aria-label="Baby">{tabs}</nav>
    </>
  );
}

function NotFound() {
  useEffect(() => { navigate("/app", true); }, []);
  return null;
}

/** The baby in view, a switch between babies, and the day or night look. */
function BabyBar({ ctx }: { ctx: FamilyCtx }) {
  const [look, setLook] = useLook();
  const night = document.body.classList.contains("night");
  if (!ctx.baby) {
    return (
      <div className="baby-bar wrap-app">
        <p className="muted">No baby in this log yet. <a href="/app/family" onClick={onLink}>Add one under Family</a>.</p>
      </div>
    );
  }
  return (
    <div className="baby-bar wrap-app">
      {ctx.babies.length > 1 ? (
        <div className="baby-switch" role="tablist" aria-label="Baby">
          {ctx.babies.map((b) => (
            <button key={b.id} type="button" role="tab" aria-selected={b.id === ctx.baby!.id} onClick={() => ctx.setBaby(b.id)}>{b.name}</button>
          ))}
        </div>
      ) : (
        <div className="baby-name">
          <strong>{ctx.baby.name}</strong>
          {ageText(ctx.baby.birth) && <span>{ageText(ctx.baby.birth)}</span>}
        </div>
      )}
      <button type="button" className="icon-btn look-btn" onClick={() => setLook(night ? "day" : "night")} aria-label={night ? "Use the day look" : "Use the night look"} title={look === "auto" ? "Night look turns on after 7 pm" : undefined}>
        <Icon name={night ? "sun" : "moon"} size={21} />
      </button>
    </div>
  );
}

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