habits-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 bc888334fc51
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.
← All files
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
bc888334fc · 18 hours ago
Bind agent address in policy
1f744a2b83 · 19 hours ago
Use OpenApp service URLs and add contribution strip
a9bb601ee0 · 19 hours ago
Bounded source sync
5646afc3a0 · 1 day ago
Bounded source sync
f1b1bac8d6 · 1 day ago
Bounded source sync
b1e14b41b0 · 1 day ago