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/lib/family.ts
// The family log on Bounded: creating and joining a family, live reads, logging and timers,
// removing people, and carrying a guest's families into their account after email sign-in.
import { useMemo } from "react";
import { get, set, setMany, useQuery, serverTimestamp } from "@bounded-sh/client";
import { me, signIn, explain, type Me } from "./bounded";
import { ownerKeys, saveOwnerKey, setActiveFamily, read, write } from "./prefs";
import {
  randomId, splitFamilyKey, familyKey, sideTotals, lastSide, parseData,
  type BabyEvent, type Baby, type EventData, type EventType, type Segment, type Side,
} from "../../functions/lib/baby";

export interface Family { name: string; tz?: string; createdBy: string; createdAt: number }
export interface Member { id: string; name: string; at: number; account?: boolean | null }
export interface FamilyRef { id: string; name: string }

const tzName = () => { try { return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; } catch { return "UTC"; } };
const nowSec = () => Math.floor(Date.now() / 1000);
const bare = <T,>(rows: T[] | undefined | null) => (rows || []).map((r: any) => ({ ...r, id: r.id ?? String(r._id ?? "").split("/").pop() })) as (T & { id: string })[];

async function isMember(h: string, uid: string): Promise<boolean> {
  try { return Boolean(await get(`households/${h}/members/${uid}`)); } catch { return false; }
}

export async function createFamily(input: { babyName: string; birth: string; myName: string }): Promise<string> {
  const m = await me();
  const h = randomId(20);
  const ownerKey = randomId(24);
  const name = `${input.babyName}'s family`.slice(0, 60);
  await setMany([
    { path: `households/${h}`, document: { name, tz: tzName(), createdBy: m.id, createdAt: serverTimestamp() } },
    { path: `households/${h}/members/${m.id}`, document: { name: input.myName, at: serverTimestamp(), ...(m.guest ? {} : { account: true }) } },
    { path: `households/${h}/owners/${m.id}`, document: { at: serverTimestamp() } },
    { path: `householdkeys/${h}`, document: { key: randomId(24) } },
    { path: `ownerkeys/${h}`, document: { key: ownerKey } },
    { path: `households/${h}/babies/${randomId(12)}`, document: { name: input.babyName, birth: input.birth, createdAt: serverTimestamp() } },
    { path: `users/${m.id}/households/${h}`, document: { name, at: serverTimestamp() } },
  ]);
  saveOwnerKey(h, ownerKey);
  setActiveFamily(h);
  return h;
}

/** Whether this person is already in the family a family key points to. */
export async function alreadyIn(key: string): Promise<boolean> {
  const parts = splitFamilyKey(key);
  if (!parts) return false;
  const m = await me();
  return isMember(parts.householdId, m.id);
}

/** Joins with a family key (from a family link). Returns the family id. */
export async function joinFamily(key: string, name: string): Promise<string> {
  const parts = splitFamilyKey(key);
  if (!parts) throw new Error("This link is not complete. Ask for the family link again.");
  const m = await me();
  const { householdId: h, secret } = parts;
  if (!(await isMember(h, m.id))) {
    try {
      await setMany([
        { path: `households/${h}/claims/${m.id}`, document: { key: secret, n: randomId(12) } },
        { path: `households/${h}/members/${m.id}`, document: { name, at: serverTimestamp(), ...(m.guest ? {} : { account: true }) } },
        { path: `users/${m.id}/households/${h}`, document: { name: "", at: serverTimestamp() } },
      ]);
    } catch (err) {
      const why = explain(err);
      throw new Error(/offline/.test(why) ? why : "This family link does not work any more. Ask someone in the family for the current link.");
    }
  }
  const house = (await get(`households/${h}`)) as Family | null;
  await set(`users/${m.id}/households/${h}`, { name: house?.name || "", at: serverTimestamp() });
  setActiveFamily(h);
  return h;
}

// ---------- Live reads ----------

type Q<T> = { data: T | undefined; error?: unknown };

export function useFamilies(uid: string | null) {
  const q = useQuery(uid ? `users/${uid}/households` : null, { limit: 100 }) as Q<FamilyRef[]>;
  return { families: q.data ? bare(q.data) : undefined, error: q.error };
}

export function useFamily(h: string) {
  const house = useQuery(`households/${h}`) as Q<Family | null>;
  const members = useQuery(`households/${h}/members`, { limit: 500 }) as Q<Member[]>;
  const owners = useQuery(`households/${h}/owners`, { limit: 500 }) as Q<{ id: string }[]>;
  const babies = useQuery(`households/${h}/babies`, { limit: 100 }) as Q<Baby[]>;
  const key = useQuery(`householdkeys/${h}`) as Q<{ key: string } | null>;
  return {
    house: house.data,
    members: members.data ? bare(members.data).sort((a, b) => a.at - b.at) : undefined,
    owners: owners.data ? new Set(bare(owners.data).map((o) => o.id)) : undefined,
    babies: babies.data ? bare(babies.data).sort((a: any, b: any) => (a.createdAt ?? 0) - (b.createdAt ?? 0)) : undefined,
    familyKey: key.data?.key ? familyKey(h, key.data.key) : null,
    error: house.error || members.error,
  };
}

/** Every entry for the family since `since`, live. Live reads stop at 1,000 rows, about a week for twins. */
export function useEvents(h: string, since: number) {
  const opts = useMemo(() => ({ filter: { start: { $gte: since } }, limit: 1000 }), [since]);
  const q = useQuery(`households/${h}/events`, opts) as Q<BabyEvent[]>;
  return useMemo(() => (q.data ? bare(q.data).filter((e) => !e.deleted) : undefined), [q.data]);
}

export function useGrowth(h: string, baby: string | null) {
  const opts = useMemo(() => ({ filter: { baby, type: "growth" }, limit: 1000 }), [baby]);
  const q = useQuery(baby ? `households/${h}/events` : null, opts) as Q<BabyEvent[]>;
  return useMemo(() => (q.data ? bare(q.data).filter((e) => !e.deleted && e.baby === baby && e.type === "growth").sort((a, b) => a.start - b.start) : undefined), [q.data, baby]);
}

/** One baby's entries between two times, read once (for older days, 30-day charts and exports). */
export async function loadRange(h: string, baby: string, from: number, to: number): Promise<BabyEvent[]> {
  const rows: BabyEvent[] = [];
  let cursor: string | undefined;
  for (let page = 0; page < 40; page++) {
    const res = (await get(`households/${h}/events`, { filter: { baby, start: { $gte: from, $lt: to } }, limit: 500, ...(cursor ? { cursor } : {}) })) as { data: BabyEvent[]; nextCursor?: string | null };
    rows.push(...bare(res?.data));
    cursor = res?.nextCursor ?? undefined;
    if (!cursor) break;
  }
  return rows.filter((e) => !e.deleted && e.baby === baby && e.start >= from && e.start < to).sort((a, b) => a.start - b.start);
}

// ---------- Logging ----------

export async function addEvent(h: string, e: { baby: string; type: EventType; start: number; end?: number | null; data: EventData }): Promise<string> {
  const m = await me();
  const id = randomId(14);
  await set(`households/${h}/events/${id}`, {
    baby: e.baby, type: e.type, start: e.start, ...(e.end != null ? { end: e.end } : {}),
    data: JSON.stringify(e.data), by: m.id, createdAt: serverTimestamp(),
  });
  return id;
}

export async function updateEvent(h: string, id: string, patch: { start?: number; end?: number | null; data?: EventData; baby?: string }) {
  const doc: Record<string, unknown> = {};
  if (patch.start != null) doc.start = patch.start;
  if (patch.end !== undefined) doc.end = patch.end;
  if (patch.data) doc.data = JSON.stringify(patch.data);
  if (patch.baby) doc.baby = patch.baby;
  await set(`households/${h}/events/${id}`, doc);
}

export const deleteEvent = (h: string, id: string) => set(`households/${h}/events/${id}`, { deleted: true });
export const restoreEvent = (h: string, id: string) => set(`households/${h}/events/${id}`, { deleted: null });

// Timers: a breastfeed or sleep with no end. Each change rewrites the stretches, so every device shows it.
const closeOpen = (seg: Segment[], at: number): Segment[] => seg.map(([s, a, b]) => [s, a, b ?? Math.max(a, at)] as Segment);

export const startFeed = (h: string, baby: string, side: Side) =>
  addEvent(h, { baby, type: "breast", start: nowSec(), data: { seg: [[side, nowSec(), null]] } });

export function switchSide(h: string, e: BabyEvent, data: EventData) {
  const at = nowSec();
  const seg = closeOpen(data.seg || [], at);
  const next: Side = lastSide(seg) === "L" ? "R" : "L";
  return updateEvent(h, e.id, { data: { ...data, seg: [...seg, [next, at, null]] } });
}

export function pauseFeed(h: string, e: BabyEvent, data: EventData) {
  return updateEvent(h, e.id, { data: { ...data, seg: closeOpen(data.seg || [], nowSec()) } });
}

export function resumeFeed(h: string, e: BabyEvent, data: EventData, side?: Side) {
  const seg = data.seg || [];
  return updateEvent(h, e.id, { data: { ...data, seg: [...seg, [side ?? lastSide(seg) ?? "L", nowSec(), null]] } });
}

export function stopTimer(h: string, e: BabyEvent, data: EventData) {
  const at = nowSec();
  if (e.type !== "breast") return updateEvent(h, e.id, { end: Math.max(e.start, at) });
  const seg = closeOpen(data.seg || [], at);
  const t = sideTotals(seg, at);
  // A paused feed ends when its last stretch ended, not when Done was tapped.
  const end = seg.length ? Math.max(...seg.map((s) => s[2] as number)) : at;
  return updateEvent(h, e.id, { end: t.L + t.R ? end : at, data: { ...data, seg } });
}

export const startSleep = (h: string, baby: string) => addEvent(h, { baby, type: "sleep", start: nowSec(), data: {} });

/** Undoes a stop: the timer runs again from where it was, as if it had never been stopped. */
export const reopenTimer = (h: string, e: BabyEvent) => updateEvent(h, e.id, { end: null, data: parseData(e.data) });

// ---------- People ----------

export async function renameMe(h: string, name: string) {
  const m = await me();
  await set(`households/${h}/members/${m.id}`, { name });
}

/** An owner removes someone. With `newKey`, the family key changes too, so the old link and assistant key stop working. */
export async function removeMember(h: string, uid: string, newKey: boolean) {
  await setMany([
    { path: `households/${h}/members/${uid}`, document: null },
    ...(newKey ? [{ path: `householdkeys/${h}`, document: { key: randomId(24) } }] : []),
  ]);
}

export const replaceKey = (h: string) => set(`householdkeys/${h}`, { key: randomId(24) });

export async function leaveFamily(h: string) {
  const m = await me();
  await setMany([
    { path: `households/${h}/members/${m.id}`, document: null },
    { path: `users/${m.id}/households/${h}`, document: null },
  ]);
}

export async function forgetFamily(h: string) {
  const m = await me();
  await setMany([{ path: `users/${m.id}/households/${h}`, document: null }]);
}

export const addBaby = (h: string, name: string, birth: string) =>
  set(`households/${h}/babies/${randomId(12)}`, { name, birth, createdAt: serverTimestamp() });

export const updateBaby = (h: string, id: string, patch: { name?: string; birth?: string; archived?: boolean }) =>
  set(`households/${h}/babies/${id}`, patch);

export const renameFamily = (h: string, name: string) => set(`households/${h}`, { name });

// ---------- Keeping the log on all devices ----------

const UPGRADE = "baby:upgrade";
interface Pending { guest: string; at: number; items: { id: string; name: string; key: string | null; ownerKey?: string; memberName: string }[] }

/** "Keep on all devices": remembers this guest's families and keys, then goes to email sign-in. */
export async function keepOnAllDevices(families: FamilyRef[], names: Record<string, string>) {
  const m = await me();
  if (!m.guest) return;
  const owned = ownerKeys();
  const items: Pending["items"] = [];
  for (const f of families) {
    const k = (await get(`householdkeys/${f.id}`).catch(() => null)) as { key?: string } | null;
    items.push({ id: f.id, name: f.name, key: k?.key ?? null, ownerKey: owned[f.id], memberName: names[f.id] || "Me" });
  }
  write<Pending>(UPGRADE, { guest: m.id, at: Date.now(), items });
  await signIn();
}

/**
 * After sign-in: the account joins every family the guest was in (with the family key), and becomes
 * an owner where this device holds the owner key. The guest keeps its own place, so signing out
 * on this device still opens the log. Returns a sentence to show, or null.
 */
export async function finishUpgrade(m: Me): Promise<string | null> {
  const pending = read<Pending | null>(UPGRADE, null);
  if (!pending) return null;
  if (Date.now() - pending.at > 3600_000) { write(UPGRADE, undefined); return null; }
  // Sign-in was cancelled: keep the list for another try.
  if (m.guest) return null;
  write(UPGRADE, undefined);
  const missed: string[] = [];
  for (const it of pending.items) {
    if (await isMember(it.id, m.id)) {
      await set(`users/${m.id}/households/${it.id}`, { name: it.name, at: serverTimestamp() }).catch(() => null);
      continue;
    }
    if (!it.key) { missed.push(it.name); continue; }
    const join = [
      { path: `households/${it.id}/claims/${m.id}`, document: { key: it.key, n: randomId(12) } },
      { path: `households/${it.id}/members/${m.id}`, document: { name: it.memberName, at: serverTimestamp(), account: true } },
      { path: `users/${m.id}/households/${it.id}`, document: { name: it.name, at: serverTimestamp() } },
    ];
    const own = it.ownerKey ? [
      { path: `households/${it.id}/ownerclaims/${m.id}`, document: { key: it.ownerKey, n: randomId(12) } },
      { path: `households/${it.id}/owners/${m.id}`, document: { at: serverTimestamp() } },
    ] : [];
    try {
      await setMany([...join, ...own]);
      if (it.ownerKey) saveOwnerKey(it.id, it.ownerKey);
    } catch {
      try { await setMany(join); } catch { missed.push(it.name); }
    }
  }
  if (pending.items[0]) setActiveFamily(pending.items[0].id);
  return missed.length
    ? `Signed in. Could not bring over ${missed.join(", ")}: the family link changed. Ask for the current link.`
    : "Signed in. Your family log is now on your account, on every device you sign in on.";
}

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