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.
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.
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
Bounded source sync
Bounded source sync
Bounded source sync