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 e8a688fd21351d04b05468df
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/sync.ts
// Keeps the device copy of habits and the person's Bounded account in step, keeps each shared
// habit's streak row current for friends, and carries a guest's habits into their account when
// they choose to keep them on all devices.
import { useSyncExternalStore } from "react";
import { get, set, setMany, serverTimestamp } from "@bounded-sh/client";
import { me, signIn, type Me } from "./bounded";
import { getState, replace, update, editHabit, onLocalChange, type State } from "./store";
import { mergeHabit, randomId, stats, type Habit, type Kind, type Schedule } from "./habits";
const UPGRADE = "habits:upgrade";
const pushed = new Map<string, string>(); // doc path -> data last written or read
const joined = new Set<string>(); // shared habits this person has opened
let user: Me | null = null;
let timer: number | undefined;
let ready: Promise<Me> | null = null;
let upgraded = false;
let pulledAt = 0; // a push waits until the account's copy has been read once, so it never overwrites newer check-ins
let readyUser: Me | null = null;
const readyListeners = new Set<() => void>();
/** A shared habit, as stored at teams/<id>. */
export interface Team { id: string; name: string; kind: Kind; target: number; unit: string; schedule: string; createdBy: string }
/** One person's row on a shared habit. unit is day, week, time or without (an avoid habit). */
export interface Member { id: string; name: string; streak: number; best: number; unit: string; last: string }
type Write = { path: string; document: Record<string, unknown> | null };
const profileData = (s: State) => JSON.stringify({ name: s.name, weekStart: s.weekStart, at: s.at });
function memberRow(s: State, h: Habit) {
const st = stats(h, s.weekStart);
return { name: s.name.trim() || "A friend", streak: st.current, best: Math.max(st.best, st.current), unit: h.kind === "avoid" ? "without" : st.unit, last: st.last ?? "" };
}
const teamsOf = (s: State) => [...new Set(Object.values(s.habits).filter((h) => h.team && !h.deleted).map((h) => h.team as string))];
async function pull(uid: string) {
const profile = await get(`people/${uid}`);
const habits: Record<string, Habit> = {};
let cursor: string | undefined;
do {
const page = await get(`people/${uid}/habits`, { limit: 1000, cursor });
for (const row of page?.data || []) {
const h = JSON.parse(row.data) as Habit;
habits[h.id] = h;
pushed.set(`people/${uid}/habits/${h.id}`, row.data);
}
cursor = page?.nextCursor ?? undefined;
} while (cursor);
if (profile?.data) pushed.set(`people/${uid}`, profile.data);
const p = profile?.data ? (JSON.parse(profile.data) as Partial<State>) : {};
const local = getState();
const merged: Record<string, Habit> = { ...local.habits };
for (const [id, h] of Object.entries(habits)) merged[id] = merged[id] ? mergeHabit(merged[id], h) : h;
const newer = (p.at ?? 0) > local.at;
replace({
...local,
owner: uid,
habits: merged,
...(newer ? { name: p.name ?? local.name, weekStart: p.weekStart ?? local.weekStart, at: p.at ?? local.at } : {}),
});
pulledAt = Date.now();
}
async function push() {
if (!user || !pulledAt) return;
const uid = user.id;
const s = getState();
const writes: Write[] = [];
const keys = new Map<string, string>();
const add = (path: string, key: string, document: Record<string, unknown> | null) => {
if (pushed.get(path) === key) return;
writes.push({ path, document });
keys.set(path, key);
};
const pd = profileData(s);
add(`people/${uid}`, pd, { data: pd });
for (const h of Object.values(s.habits)) {
const data = JSON.stringify(h);
add(`people/${uid}/habits/${h.id}`, data, { data });
}
for (const h of Object.values(s.habits)) {
if (!h.team || h.deleted || h.archived || !joined.has(h.team)) continue;
const row = memberRow(s, h);
add(`teams/${h.team}/members/${uid}`, JSON.stringify(row), row);
}
for (let i = 0; i < writes.length; i += 20) {
const batch = writes.slice(i, i + 20);
await setMany(batch);
for (const w of batch) pushed.set(w.path, keys.get(w.path)!);
}
}
function schedulePush() {
window.clearTimeout(timer);
timer = window.setTimeout(() => void push().catch(() => { /* offline: the device copy has it, the next change retries */ }), 800);
}
/** Writes now (before leaving the page for sign-in, for example). */
export async function flush() {
window.clearTimeout(timer);
await push();
}
/** Makes sure this person has opened each shared habit they track, so their streak row can be written. */
async function ensureJoins(uid: string) {
for (const team of teamsOf(getState())) {
if (joined.has(team)) continue;
try {
if (!(await get(`teams/${team}/joins/${uid}`))) await set(`teams/${team}/joins/${uid}`, { at: serverTimestamp() });
joined.add(team);
} catch { /* the shared habit is gone or offline: try again next time */ }
}
}
/** Signs in (guest or account), merges the device copy with the account copy, and starts syncing. */
export function startSync(): Promise<Me> {
ready ??= (async () => {
const m = await me();
let pending: { guest: string; at: number } | null = null;
try { pending = JSON.parse(localStorage.getItem(UPGRADE) || "null"); } catch { /* ignore */ }
user = m;
onLocalChange(schedulePush);
// Habits on this device are never thrown away: they merge into whichever account is signed in.
// (Signing out clears the device on purpose.)
await pull(m.id).catch(() => null);
await ensureJoins(m.id).catch(() => null);
await push().catch(() => null);
if (pending) {
localStorage.removeItem(UPGRADE);
if (!m.guest && m.id !== pending.guest && Date.now() - pending.at < 3600_000) upgraded = true;
}
readyUser = m;
for (const l of readyListeners) l();
return m;
})().catch((err) => { ready = null; throw err; });
return ready;
}
/** Reads the account's copy again (another device may have checked something off), at most every 30 seconds. */
export async function refresh() {
if (!user || Date.now() - pulledAt < 30_000) return;
await pull(user.id);
await ensureJoins(user.id).catch(() => null);
await push();
}
/** True once, right after a guest's habits moved into their account. */
export function takeUpgraded(): boolean {
const u = upgraded;
upgraded = false;
return u;
}
/** "Keep my habits on all devices": saves, steps this guest out of shared habits, and goes to sign-in. */
export async function keepOnAllDevices() {
const m = await startSync();
if (!m.guest) return;
await flush().catch(() => null);
localStorage.setItem(UPGRADE, JSON.stringify({ guest: m.id, at: Date.now() }));
// Friends would otherwise see this guest and the account side by side. Whoever comes back
// (the account, or this guest if sign-in is cancelled) writes its row again.
const rows = [...joined].map((t) => ({ path: `teams/${t}/members/${m.id}`, document: null }));
if (rows.length) await setMany(rows).catch(() => null);
for (const r of rows) pushed.delete(r.path);
await signIn();
}
/** Starts sharing a habit: creates the shared habit and this person's streak row. Returns the share id. */
export async function shareHabit(habitId: string, name: string): Promise<string> {
const m = await startSync();
update((s) => { s.name = name; s.at = Date.now(); });
const h = getState().habits[habitId];
const id = randomId(20);
const row = memberRow(getState(), h);
await setMany([
{ path: `teams/${id}`, document: { name: h.name, kind: h.kind, target: Math.max(1, h.target), unit: h.unit, schedule: JSON.stringify(h.schedule), createdBy: m.id, createdAt: serverTimestamp() } },
{ path: `teams/${id}/joins/${m.id}`, document: { at: serverTimestamp() } },
{ path: `teams/${id}/members/${m.id}`, document: row },
]);
joined.add(id);
pushed.set(`teams/${id}/members/${m.id}`, JSON.stringify(row));
editHabit(habitId, (x) => { x.team = id; });
return id;
}
/** Opens a shared habit from its link: joins it (knowing the id is enough) and reads it. Null if it does not exist. */
export async function openTeam(id: string): Promise<Team | null> {
const m = await startSync();
if (!joined.has(id)) {
const j = await get(`teams/${id}/joins/${m.id}`);
if (!j) {
try { await set(`teams/${id}/joins/${m.id}`, { at: serverTimestamp() }); } catch { return null; }
}
joined.add(id);
}
const t = await get(`teams/${id}`);
return t ? ({ ...t, id } as Team) : null;
}
/** Links one of this person's habits to a shared habit; the next sync writes their streak row. */
export function linkTeam(habitId: string, teamId: string, name: string) {
update((s) => { s.name = name; s.at = Date.now(); });
editHabit(habitId, (h) => { h.team = teamId; });
}
/** Stops sharing a habit: removes this person's row and their access to the others' streaks. */
export async function leaveTeam(habitId: string) {
const h = getState().habits[habitId];
if (!h?.team) return;
const team = h.team;
const m = await startSync();
await setMany([
{ path: `teams/${team}/members/${m.id}`, document: null },
{ path: `teams/${team}/joins/${m.id}`, document: null },
]);
joined.delete(team);
pushed.delete(`teams/${team}/members/${m.id}`);
editHabit(habitId, (x) => { x.team = null; });
}
/** Parses a shared habit's schedule, falling back to every day. Anyone can write a shared habit, so
* only the shapes the editor makes get through (weekdays 0 to 6 would otherwise loop forever in stats). */
export function teamSchedule(t: Team): Schedule {
const int = (v: unknown, min: number, max: number): v is number => Number.isInteger(v) && (v as number) >= min && (v as number) <= max;
try {
const s = JSON.parse(t.schedule);
if (s?.type === "days" && Array.isArray(s.days) && s.days.length && s.days.every((d: unknown) => int(d, 0, 6))) {
return { type: "days", days: [...new Set<number>(s.days)].sort() };
}
if (s?.type === "weekly" && int(s.times, 1, 7)) return { type: "weekly", times: s.times };
if (s?.type === "every" && int(s.n, 2, 60)) return { type: "every", n: s.n };
} catch { /* fall through */ }
return { type: "daily" };
}
/** The signed-in person once syncing has started (null before, or offline). */
export function useUser(): Me | null {
return useSyncExternalStore((cb) => { readyListeners.add(cb); return () => readyListeners.delete(cb); }, () => readyUser);
}
Change history
Point OpenApps links to openapps.free
Bind agent address in policy
Use OpenApp service URLs and add contribution strip
Bounded source sync
Bounded source sync
Bounded source sync