time-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 4857f8a940b5
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 703ed60f2cc5ad3d98a5bf4a
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/session.ts
// The session and its workspace. A first visit creates a guest and a workspace; "Keep my time on all
// devices" moves the guest's workspace into an email account. Right before the hosted sign-in the guest
// makes a one-time hand-off code; right after it, the account claims the code and becomes an owner.
// A new account adopts the workspace as it is. An account that already has a workspace (signed in on
// another device first) gets this browser's projects, clients, tags and time copied into it.
import { useEffect, useState } from "react";
import { get, set, setMany, serverTimestamp } from "@bounded-sh/client";
import { CURRENCIES, localTz, randomId, rescale, type Entry, type Project } from "../../functions/lib/time";
import { me, signIn, type Me } from "./bounded";

export const NOW = () => serverTimestamp();
export type Write = { path: string; document: Record<string, unknown> | null };
export interface Session { me: Me; ws: string }

const KEY = "time:handoff";
interface Handoff { guest: string; ws: string; code: string; at: number }

/** Runs writes in batches small enough for one transaction each. */
export async function writeAll(writes: Write[], size = 100) {
  for (let i = 0; i < writes.length; i += size) await setMany(writes.slice(i, i + size) as never);
}

export async function listAll<T>(path: string, filter?: Record<string, unknown>): Promise<(T & { id: string })[]> {
  const rows: (T & { id: string })[] = [];
  let cursor: string | undefined;
  do {
    const res = await get(path, { limit: 1000, ...(filter ? { filter } : {}), ...(cursor ? { cursor } : {}) });
    rows.push(...((res?.data || []) as (T & { id: string })[]));
    cursor = res?.nextCursor || undefined;
  } while (cursor);
  return rows;
}

export function guessCurrency(): string {
  try {
    const region = new Intl.Locale(navigator.language).maximize().region || "US";
    const byRegion: Record<string, string> = { US: "USD", GB: "GBP", CA: "CAD", AU: "AUD", NZ: "NZD", JP: "JPY", CH: "CHF", SE: "SEK", NO: "NOK", DK: "DKK", PL: "PLN", IN: "INR", SG: "SGD", HK: "HKD", MX: "MXN", BR: "BRL", ZA: "ZAR", IL: "ILS", KR: "KRW", AE: "AED" };
    const euro = ["DE", "FR", "ES", "IT", "NL", "BE", "AT", "PT", "IE", "FI", "GR", "LU", "SK", "SI", "EE", "LV", "LT", "MT", "CY", "HR"];
    const c = byRegion[region] || (euro.includes(region) ? "EUR" : "USD");
    return CURRENCIES.includes(c) ? c : "USD";
  } catch {
    return "USD";
  }
}

function guessWeekStart(): number {
  try {
    const region = new Intl.Locale(navigator.language).maximize().region || "US";
    return ["US", "CA", "MX", "BR", "JP", "KR", "IL", "PH", "IN", "ZA", "HK", "TW", "AU"].includes(region) ? 0 : 1;
  } catch {
    return 1;
  }
}

async function createWorkspace(m: Me): Promise<string> {
  const id = randomId(20);
  await setMany([
    { path: `ws/${id}`, document: { name: "", currency: guessCurrency(), rate: 0, weekStart: guessWeekStart(), tz: localTz(), createdBy: m.id, createdAt: NOW() } },
    { path: `ws/${id}/members/${m.id}`, document: { role: "owner", invite: "created", at: NOW() } },
    { path: `users/${m.id}`, document: { ws: id, at: NOW() } },
  ] as never);
  return id;
}

function readHandoff(): Handoff | null {
  try {
    const h = JSON.parse(localStorage.getItem(KEY) || "null") as Handoff | null;
    return h && Date.now() - h.at < 3600_000 ? h : null;
  } catch {
    return null;
  }
}

let moved: "adopted" | "copied" | null = null;
/** What happened to a guest's time at sign-in, once. */
export function takeMoved() {
  const m = moved;
  moved = null;
  return m;
}

/** The account claims the hand-off code and becomes an owner of the guest's workspace. */
async function finishHandoff(m: Me): Promise<string | null> {
  const h = readHandoff();
  try { localStorage.removeItem(KEY); } catch { /* ignore */ }
  if (!h) return null;
  if (h.guest === m.id || m.guest) {
    // Sign-in was cancelled: the code is not needed.
    if (h.guest === m.id) await set(`ws/${h.ws}/handoffs/${h.code}`, null).catch(() => null);
    return null;
  }
  const base = `ws/${h.ws}`;
  if (!(await get(`${base}/members/${m.id}`).catch(() => null))) {
    await setMany([
      { path: `${base}/handoffs/${h.code}`, document: { usedBy: m.id } },
      { path: `${base}/members/${m.id}`, document: { role: "owner", invite: h.code, at: NOW() } },
    ] as never);
  }
  const home = ((await get(`users/${m.id}`)) as { ws?: string } | null)?.ws;
  const doc = (await get(base)) as { name?: string; currency?: string } | null;
  const projects = await listAll<Project>(`${base}/projects`);
  const name = doc?.name || "Teammate";
  if (!home || home === h.ws) {
    await set(`users/${m.id}`, { ws: h.ws, at: NOW() });
    await relink(m, h.ws, h.ws, projects, name);
    await set(`${base}/members/${h.guest}`, null).catch(() => null);
    moved = "adopted";
    return h.ws;
  }
  await copyInto(h.ws, home, doc?.currency || "USD", projects);
  await relink(m, h.ws, home, projects, name);
  await set(`${base}/members/${h.guest}`, null).catch(() => null);
  moved = "copied";
  return home;
}

/** The account takes the guest's place on shared projects: owner of the ones it shared, teammate on the ones it joined. */
async function relink(m: Me, from: string, home: string, projects: Project[], name: string) {
  for (const p of projects) {
    if (!p.share) continue;
    const write = p.team
      ? { path: `shared/${p.share}/joins/${m.id}`, document: { name, ws: home, at: NOW() } }
      : { path: `shared/${p.share}/owners/${m.id}`, document: { ws: from, at: NOW() } };
    await setMany([write] as never).catch(() => null);
  }
}

/** Copies a workspace's clients, projects, tags, time and running timer into another one (same ids, so it can run twice). */
async function copyInto(from: string, to: string, fromCurrency: string, projects: Project[]) {
  const target = (await get(`ws/${to}`)) as { currency?: string } | null;
  const toCurrency = target?.currency || fromCurrency;
  const strip = <T extends { id?: string; _id?: string; pathId?: string }>(r: T) => {
    const { id: _a, _id: _b, pathId: _c, ...rest } = r as Record<string, unknown>;
    for (const k of Object.keys(rest)) if (k.startsWith("_")) delete rest[k];
    return rest;
  };
  const writes: Write[] = [];
  for (const c of await listAll<Record<string, unknown>>(`ws/${from}/clients`)) writes.push({ path: `ws/${to}/clients/${c.id}`, document: { ...strip(c), at: NOW() } });
  for (const t of await listAll<Record<string, unknown>>(`ws/${from}/tags`)) writes.push({ path: `ws/${to}/tags/${t.id}`, document: { ...strip(t), at: NOW() } });
  for (const p of projects) writes.push({ path: `ws/${to}/projects/${p.id}`, document: { ...strip(p), rate: rescale(p.rate || 0, fromCurrency, toCurrency), at: NOW() } });
  for (const e of await listAll<Entry>(`ws/${from}/entries`)) writes.push({ path: `ws/${to}/entries/${e.id}`, document: { ...strip(e), at: NOW() } });
  const timer = (await get(`ws/${from}/timer/now`)) as Record<string, unknown> | null;
  const theirs = (await get(`ws/${to}/timer/now`)) as { start?: number } | null;
  if (timer?.start && !theirs?.start) writes.push({ path: `ws/${to}/timer/now`, document: { ...strip(timer), at: NOW() } });
  await writeAll(writes);
}

let ready: Promise<Session> | null = null;
export function session(): Promise<Session> {
  ready ??= (async () => {
    const m = await me();
    const handed = await finishHandoff(m).catch(() => null);
    if (handed) return { me: m, ws: handed };
    const u = (await get(`users/${m.id}`)) as { ws?: string } | null;
    return { me: m, ws: u?.ws || (await createWorkspace(m)) };
  })().catch((e) => { ready = null; throw e; });
  return ready;
}

export function useSession(): { session: Session | null; error: unknown } {
  const [state, setState] = useState<{ session: Session | null; error: unknown }>({ session: null, error: null });
  useEffect(() => {
    let dead = false;
    session().then((s) => { if (!dead) setState({ session: s, error: null }); }, (e) => { if (!dead) setState({ session: null, error: e }); });
    return () => { dead = true; };
  }, []);
  return state;
}

/** "Keep my time on all devices": makes a one-time hand-off code, then goes to the hosted email sign-in. */
export async function keepOnAllDevices(s: Session) {
  const code = randomId(20);
  await set(`ws/${s.ws}/handoffs/${code}`, { by: s.me.id, createdAt: NOW(), usedBy: "" });
  localStorage.setItem(KEY, JSON.stringify({ guest: s.me.id, ws: s.ws, code, at: Date.now() } satisfies Handoff));
  await signIn();
}

Change history

Point OpenApps links to openapps.free
4857f8a940 · 19 hours ago
Bind agent address constants in policy
62b1e6acb2 · 20 hours ago
Use functions.openapps.xyz alias and add OpenApp contribution strip
61d972193a · 20 hours ago
Bounded source sync
fc644b4327 · 1 day ago
Bounded source sync
22f511f3db · 1 day ago
Bounded source sync
351a8d754c · 1 day ago
Bounded source sync
0da238a77e · 1 day ago