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/data.ts
// The workspace, live: settings, projects, clients, tags, the running timer and recent entries arrive
// through live queries, so a timer started on a phone shows on a laptop. Writes show at once (a short
// local overlay) and the live query confirms them. Reports page through older entries on demand.
import { createContext, useContext, useEffect, useMemo, useState, useSyncExternalStore } from "react";
import { get, set, setMany, useQuery } from "@bounded-sh/client";
import {
  randomId, parseTags, rescale, dayOf, zoned, addDays, minuteOf, durationOf, makeKey, sha256Hex, COLORS,
  type Workspace, type Project, type Client, type Tag, type Entry, type Timer, type TeamEntry, type Row,
} from "../../functions/lib/time";
import { NOW, listAll, type Session, type Write } from "./session";
import { nowSec } from "./hooks";
import { joinedStatus } from "../joined";

export type { Write };
export interface Store {
  session: Session;
  id: string;
  base: string;
  ws: Workspace;
  projects: Project[];
  clients: Client[];
  tags: Tag[];
  timer: Timer | null;
  tz: string;
}

type Row_<T> = T & { id: string };

const StoreContext = createContext<Store | null>(null);
export const StoreProvider = StoreContext.Provider;
export function useStore(): Store {
  const s = useContext(StoreContext);
  if (!s) throw new Error("No workspace loaded.");
  return s;
}

// ---- The running timer, shown at once on this device ----

const TIMER_KEY = "time:timer";
let localTimer: { timer: Timer | null; t: number } | null = null;
const timerListeners = new Set<() => void>();
function setLocalTimer(timer: Timer | null) {
  localTimer = { timer, t: Date.now() };
  remember(timer);
  timerListeners.forEach((f) => f());
}
/** The device's last known timer (for the landing page and the tab title before the session loads). */
export function rememberedTimer(): (Timer & { projectName?: string }) | null {
  try {
    const t = JSON.parse(localStorage.getItem(TIMER_KEY) || "null");
    return t && t.start ? t : null;
  } catch {
    return null;
  }
}
function remember(timer: Timer | null, projectName?: string) {
  try {
    if (timer?.start) localStorage.setItem(TIMER_KEY, JSON.stringify({ ...timer, projectName }));
    else localStorage.removeItem(TIMER_KEY);
  } catch { /* storage blocked */ }
}

// ---- Entries written on this device but not back from the live query yet ----

const pending = new Map<string, { entry: Entry | null; t: number }>();
const pendingListeners = new Set<() => void>();
let pendingVersion = 0;
function putPending(id: string, entry: Entry | null) {
  pending.set(id, { entry, t: Date.now() });
  pendingVersion++;
  pendingListeners.forEach((f) => f());
}
function dropPending(id: string) {
  if (pending.delete(id)) {
    pendingVersion++;
    pendingListeners.forEach((f) => f());
  }
}
export function usePendingVersion() {
  return useSyncExternalStore((cb) => { pendingListeners.add(cb); return () => pendingListeners.delete(cb); }, () => pendingVersion);
}
const same = (a: Entry, b: Entry) => a.start === b.start && a.end === b.end && a.description === b.description && a.project === b.project && a.tags === b.tags && Boolean(a.billable) === Boolean(b.billable);

/** Live entries that start in [from, to) (unix seconds), with this device's unconfirmed writes on top. */
export function useEntries(store: Store, from: number, to: number): Entry[] | undefined {
  const { data } = useQuery(`${store.base}/entries`, { filter: { start: { $gte: from, $lt: to } }, limit: 1000 }) as { data: Row_<Entry>[] | undefined };
  const version = usePendingVersion();
  return useMemo(() => {
    if (!data) return undefined;
    const map = new Map<string, Entry>(data.map((e) => [e.id, e]));
    const now = Date.now();
    for (const [id, p] of pending) {
      const live = map.get(id);
      if (now - p.t > 15000 || (p.entry ? live && same(live, p.entry) : !live)) { pending.delete(id); continue; }
      if (p.entry && p.entry.start >= from && p.entry.start < to) map.set(id, p.entry);
      else map.delete(id);
    }
    return [...map.values()].sort((a, b) => b.start - a.start);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [data, version, from, to]);
}

/** Everything about the workspace except entries, live. */
export function useLiveStore(session: Session): { store?: Store; missing?: boolean } {
  const base = `ws/${session.ws}`;
  const { data: doc } = useQuery(base) as { data: (Workspace & { createdBy: string }) | null | undefined };
  const ok = Boolean(doc);
  const { data: projects } = useQuery(ok ? `${base}/projects` : null, { limit: 1000 }) as { data: Row_<Project>[] | undefined };
  const { data: clients } = useQuery(ok ? `${base}/clients` : null, { limit: 1000 }) as { data: Row_<Client>[] | undefined };
  const { data: tags } = useQuery(ok ? `${base}/tags` : null, { limit: 1000 }) as { data: Row_<Tag>[] | undefined };
  const { data: liveTimer } = useQuery(ok ? `${base}/timer/now` : null) as { data: Timer | null | undefined };
  const [, tick] = useState(0);
  useEffect(() => {
    const f = () => tick((n) => n + 1);
    timerListeners.add(f);
    return () => { timerListeners.delete(f); };
  }, []);
  // A write from this device shows at once; the live document wins once it has caught up (or after 8 seconds).
  const timer = localTimer && Date.now() - localTimer.t < 8000 && !(liveTimer && localTimer.timer && liveTimer.start === localTimer.timer.start && liveTimer.description === localTimer.timer.description && liveTimer.project === localTimer.timer.project)
    ? localTimer.timer
    : liveTimer ?? null;
  useEffect(() => {
    if (liveTimer === undefined) return;
    const t = localTimer && Date.now() - localTimer.t < 8000 ? localTimer.timer : liveTimer;
    remember(t?.start ? t : null, t?.start ? projects?.find((p) => p.id === t.project)?.name : undefined);
  }, [liveTimer, projects]);
  useEffect(() => {
    if (!localTimer) return;
    const left = 8000 - (Date.now() - localTimer.t);
    if (left <= 0) return;
    const id = window.setTimeout(() => tick((n) => n + 1), left + 50);
    return () => window.clearTimeout(id);
  });

  const store = useMemo<Store | undefined>(() => {
    if (!doc || !projects || !clients || !tags || liveTimer === undefined) return undefined;
    const ws: Workspace = { name: doc.name || "", currency: doc.currency || "USD", rate: Number(doc.rate) || 0, weekStart: Number(doc.weekStart) === 0 ? 0 : 1, tz: doc.tz || "UTC" };
    return {
      session, id: session.ws, base, ws,
      projects: [...projects].sort((a, b) => a.name.localeCompare(b.name)),
      clients: [...clients].sort((a, b) => a.name.localeCompare(b.name)),
      tags: [...tags].sort((a, b) => a.name.localeCompare(b.name)),
      timer: timer && timer.start ? timer : null,
      tz: deviceTz(),
    };
  }, [doc, projects, clients, tags, liveTimer, timer, session, base]);
  return doc === null ? { missing: true } : { store };
}

function deviceTz(): string {
  try { return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; } catch { return "UTC"; }
}

// ---- Lookups ----

export const projectOf = (s: Store, id: string) => s.projects.find((p) => p.id === id);
export const clientOf = (s: Store, id: string) => s.clients.find((c) => c.id === id);
export const tagNamesOf = (s: Store, json: string) => parseTags(json).map((id) => s.tags.find((t) => t.id === id)?.name).filter((n): n is string => Boolean(n));
export const today = (s: Store) => dayOf(Date.now() / 1000, s.tz);
export const dayStart = (s: Store, day: string) => zoned(day, 0, s.tz);
export const nextColor = (s: Store) => COLORS[s.projects.filter((p) => !p.team).length % COLORS.length];
export { nowSec } from "./hooks";

export function toRow(s: Store, e: Entry): Row {
  return { id: e.id, description: e.description, project: e.project, tags: tagNamesOf(s, e.tags), billable: Boolean(e.billable), start: e.start, end: e.end, who: "" };
}

// ---- Entries ----

export type EntryInput = Omit<Entry, "id">;

/** A teammate's time on someone else's project also goes to the shared project, so its owner sees it. */
function teamWrites(s: Store, id: string, e: EntryInput | null, prev: Entry | null): Write[] {
  const out: Write[] = [];
  const was = prev ? projectOf(s, prev.project) : undefined;
  const now = e ? projectOf(s, e.project) : undefined;
  // A project you left (archived), or one that no longer takes your time (sharing stopped, or you were
  // removed): what you logged stays with its owner, and nothing more goes there.
  const takes = (p: Project | undefined) => (p?.team && !p.archived && !joinedStatus(p.id) ? p.share : "");
  const wasShare = takes(was);
  const nowShare = takes(now);
  if (wasShare && wasShare !== nowShare) out.push({ path: `shared/${wasShare}/entries/${id}`, document: null });
  if (e && nowShare) {
    out.push({ path: `shared/${nowShare}/entries/${id}`, document: {
      ws: s.id, who: s.ws.name || "Teammate", description: e.description, tags: JSON.stringify(tagNamesOf(s, e.tags)),
      billable: e.billable, start: e.start, end: e.end, at: NOW(),
    } });
  }
  return out;
}

function sharedRefused(s: Store, e: EntryInput | null, err: unknown): Error {
  const p = e ? projectOf(s, e.project) : undefined;
  if (p?.team) return new Error(`"${p.name}" is no longer shared with you, so time cannot be added to it. Pick another project.`);
  return err as Error;
}

export async function saveEntry(s: Store, e: EntryInput, prev?: Entry): Promise<string> {
  const id = prev?.id ?? randomId(20);
  const doc = { description: e.description, project: e.project, tags: e.tags, billable: e.billable, start: e.start, end: e.end, at: NOW() };
  putPending(id, { ...e, id });
  try {
    await setMany([{ path: `${s.base}/entries/${id}`, document: doc }, ...teamWrites(s, id, e, prev ?? null)] as never);
  } catch (err) {
    dropPending(id);
    throw sharedRefused(s, e, err);
  }
  return id;
}

export async function deleteEntry(s: Store, e: Entry) {
  putPending(e.id, null);
  try {
    await setMany([{ path: `${s.base}/entries/${e.id}`, document: null }, ...teamWrites(s, e.id, null, e)] as never);
  } catch (err) {
    dropPending(e.id);
    throw err;
  }
}

/** Puts a deleted entry back (Undo). */
export async function restoreEntry(s: Store, e: Entry) {
  const { id: _id, ...rest } = e;
  await saveEntry(s, rest);
}

// ---- Timer ----

export interface TimerInput { description: string; project: string; tags: string; billable: boolean }

function stopWrites(s: Store, t: Timer, end: number): { id: string; entry: EntryInput; writes: Write[] } {
  const id = t.entry || randomId(20);
  const entry: EntryInput = { description: t.description, project: t.project, tags: t.tags, billable: Boolean(t.billable), start: t.start, end: Math.max(t.start, end) };
  return {
    id, entry,
    writes: [
      { path: `${s.base}/entries/${id}`, document: { ...entry, at: NOW() } },
      ...teamWrites(s, id, entry, null),
    ],
  };
}

export async function startTimer(s: Store, input: TimerInput, start = nowSec()) {
  const prev = s.timer;
  const timer: Timer = { ...input, start, entry: randomId(20) };
  const writes: Write[] = [];
  let stopped: { id: string; entry: EntryInput } | null = null;
  if (prev?.start) {
    const w = stopWrites(s, prev, start);
    writes.push(...w.writes);
    stopped = w;
    putPending(w.id, { ...w.entry, id: w.id });
  }
  setLocalTimer(timer);
  try {
    await setMany([...writes, { path: `${s.base}/timer/now`, document: { ...timer, at: NOW() } }] as never);
  } catch (err) {
    setLocalTimer(prev);
    if (stopped) dropPending(stopped.id);
    throw sharedRefused(s, stopped?.entry ?? null, err);
  }
}

export async function stopTimer(s: Store, end = nowSec()) {
  const t = s.timer;
  if (!t?.start) return;
  const w = stopWrites(s, t, end);
  putPending(w.id, { ...w.entry, id: w.id });
  setLocalTimer(null);
  try {
    await setMany([...w.writes, { path: `${s.base}/timer/now`, document: { start: 0, entry: "", at: NOW() } }] as never);
  } catch (err) {
    setLocalTimer(t);
    dropPending(w.id);
    throw sharedRefused(s, w.entry, err);
  }
}

/** Changes the running timer (its description, project, tags, billable or start). */
export async function updateTimer(s: Store, patch: Partial<TimerInput & { start: number }>) {
  const t = s.timer;
  if (!t?.start) return;
  const next = { ...t, ...patch };
  setLocalTimer(next);
  try {
    await set(`${s.base}/timer/now`, { ...patch, at: NOW() });
  } catch (err) {
    setLocalTimer(t);
    throw err;
  }
}

/** Throws the running timer away without saving an entry. */
export async function discardTimer(s: Store) {
  const t = s.timer;
  setLocalTimer(null);
  try {
    await set(`${s.base}/timer/now`, { start: 0, entry: "", at: NOW() });
  } catch (err) {
    setLocalTimer(t);
    throw err;
  }
}

// ---- Projects, clients, tags ----

export interface ProjectInput { name: string; color: string; client: string; rate: number; billable: boolean }

export async function saveProject(s: Store, input: ProjectInput, prev?: Project): Promise<string> {
  const id = prev?.id ?? randomId(12);
  const writes: Write[] = [{
    path: `${s.base}/projects/${id}`,
    document: prev
      ? { ...input, at: NOW() }
      : { ...input, archived: false, share: "", team: false, owner: "", at: NOW() },
  }];
  if (prev?.share && !prev.team) {
    writes.push({ path: `shared/${prev.share}`, document: { name: input.name, color: input.color, client: clientOf(s, input.client)?.name ?? "", billable: input.billable, owner: s.ws.name } });
  }
  await setMany(writes as never);
  return id;
}

export async function setArchived(s: Store, p: Project, archived: boolean) {
  await set(`${s.base}/projects/${p.id}`, { archived, at: NOW() });
}

/** Deletes a project that has no time. Projects with time are archived instead. */
export async function deleteProject(s: Store, p: Project): Promise<boolean> {
  const res = await get(`${s.base}/entries`, { filter: { project: p.id }, limit: 1 });
  if (res?.data?.length) return false;
  await set(`${s.base}/projects/${p.id}`, null);
  return true;
}

export async function saveClient(s: Store, name: string, prev?: Client): Promise<string> {
  const id = prev?.id ?? randomId(12);
  const writes: Write[] = [{ path: `${s.base}/clients/${id}`, document: prev ? { name, at: NOW() } : { name, archived: false, at: NOW() } }];
  if (prev) {
    for (const p of s.projects.filter((x) => x.client === id && x.share && !x.team)) writes.push({ path: `shared/${p.share}`, document: { client: name } });
  }
  await setMany(writes as never);
  return id;
}

export async function deleteClient(s: Store, c: Client) {
  const writes: Write[] = [{ path: `${s.base}/clients/${c.id}`, document: null }];
  for (const p of s.projects.filter((x) => x.client === c.id)) {
    writes.push({ path: `${s.base}/projects/${p.id}`, document: { client: "", at: NOW() } });
    if (p.share && !p.team) writes.push({ path: `shared/${p.share}`, document: { client: "" } });
  }
  await setMany(writes as never);
}

export async function saveTag(s: Store, name: string, prev?: Tag): Promise<string> {
  const id = prev?.id ?? randomId(12);
  await set(`${s.base}/tags/${id}`, { name, at: NOW() });
  return id;
}

export async function deleteTag(s: Store, t: Tag) {
  await set(`${s.base}/tags/${t.id}`, null);
}

// ---- Workspace settings ----

export async function updateWorkspace(s: Store, patch: Partial<Workspace>) {
  const writes: Write[] = [{ path: s.base, document: { ...patch } }];
  if (patch.currency && patch.currency !== s.ws.currency) {
    // Rates are stored in the currency's smallest unit, so they move with its decimals. A rate sent along is already in the new currency.
    writes[0].document = { ...patch, rate: patch.rate ?? rescale(s.ws.rate, s.ws.currency, patch.currency) };
    for (const p of s.projects.filter((x) => x.rate > 0)) writes.push({ path: `${s.base}/projects/${p.id}`, document: { rate: rescale(p.rate, s.ws.currency, patch.currency), at: NOW() } });
  }
  if (patch.name !== undefined && patch.name !== s.ws.name) {
    for (const p of s.projects.filter((x) => x.share && !x.team)) writes.push({ path: `shared/${p.share}`, document: { owner: patch.name } });
  }
  await setMany(writes as never);
}

// ---- Sharing a project by link ----

export const joinUrl = (share: string) => `${location.origin}/join/${share}`;

export async function shareProject(s: Store, p: Project): Promise<string> {
  if (p.share && !p.team) {
    await set(`shared/${p.share}`, { closed: false });
    return p.share;
  }
  const share = randomId(20);
  const me = s.session.me.id;
  await setMany([
    { path: `shared/${share}`, document: { name: p.name, color: p.color, client: clientOf(s, p.client)?.name ?? "", billable: Boolean(p.billable), owner: s.ws.name, ws: s.id, closed: false, createdBy: me, createdAt: NOW() } },
    { path: `shared/${share}/owners/${me}`, document: { ws: s.id, at: NOW() } },
    { path: `${s.base}/projects/${p.id}`, document: { share, at: NOW() } },
  ] as never);
  return share;
}

export async function stopSharing(p: Project) {
  await set(`shared/${p.share}`, { closed: true });
}

export interface Teammate { ws: string; name: string; joins: string[] }
export interface TeamData { closed: boolean; people: Teammate[]; removed: { ws: string; name: string }[]; rows: Row[] }

/** The people on a project you shared, and their time in a range. */
export async function loadTeam(s: Store, p: Project, from: number, to: number): Promise<TeamData> {
  const shared = (await get(`shared/${p.share}`)) as { closed?: boolean } | null;
  const joins = await listAll<{ name: string; ws: string }>(`shared/${p.share}/joins`);
  const byWs = new Map<string, Teammate>();
  for (const j of joins) {
    if (j.ws === s.id) continue;
    const t = byWs.get(j.ws) ?? { ws: j.ws, name: j.name, joins: [] };
    t.joins.push(j.id);
    byWs.set(j.ws, t);
  }
  const removed = (await listAll<{ name: string }>(`shared/${p.share}/removed`)).map((r) => ({ ws: r.id, name: r.name }));
  return { closed: Boolean(shared?.closed), people: [...byWs.values()].sort((a, b) => a.name.localeCompare(b.name)), removed, rows: await teamRowsFor(s, p, from, to) };
}

async function teamRowsFor(s: Store, p: Project, from: number, to: number): Promise<Row[]> {
  const rows = await listAll<TeamEntry>(`shared/${p.share}/entries`, { start: { $gte: from, $lt: to } });
  return rows
    .filter((r) => r.ws !== s.id && r.start >= from && r.start < to)
    .map((r) => ({ id: r.id, description: r.description, project: p.id, tags: parseTags(r.tags), billable: Boolean(r.billable), start: r.start, end: r.end, who: r.who || "Teammate" }));
}

/** Removes a teammate's workspace (every device and assistant of theirs) from a shared project, until you let them back in. */
export async function removeTeammate(p: Project, t: Teammate) {
  await setMany([
    ...t.joins.map((u) => ({ path: `shared/${p.share}/joins/${u}`, document: null })),
    { path: `shared/${p.share}/removed/${t.ws}`, document: { name: t.name, at: NOW() } },
  ] as never);
}

/** Lets a removed teammate join again (they open the link again). */
export async function letBackIn(p: Project, ws: string) {
  await set(`shared/${p.share}/removed/${ws}`, null);
}

/** Joins someone's shared project and adds it to your projects. */
export async function joinShare(s: Store, share: string, name: string): Promise<Project> {
  if (name !== s.ws.name) await updateWorkspace(s, { name });
  try {
    await set(`shared/${share}/joins/${s.session.me.id}`, { name, ws: s.id, at: NOW() });
  } catch {
    throw new Error("You cannot join this project. Its owner may have stopped sharing it or removed you; ask them.");
  }
  const doc = (await get(`shared/${share}`)) as { name: string; color: string; owner: string; billable?: boolean } | null;
  if (!doc) throw new Error("This link does not work. Ask the project's owner for a new one.");
  const p: Project = { id: share, name: doc.name, color: doc.color || COLORS[0], client: "", rate: 0, billable: Boolean(doc.billable), archived: false, share, team: true, owner: doc.owner || "" };
  await set(`${s.base}/projects/${share}`, { name: p.name, color: p.color, client: "", rate: 0, billable: p.billable, archived: false, share, team: true, owner: p.owner, at: NOW() });
  return p;
}

export async function leaveShare(s: Store, p: Project) {
  await set(`shared/${p.share}/joins/${s.session.me.id}`, null).catch(() => null);
  await set(`${s.base}/projects/${p.id}`, { archived: true, at: NOW() });
}

/** Status of projects you joined, as their owners left them: renamed, recolored, or no longer taking time. */
export async function syncJoined(s: Store): Promise<Map<string, "closed" | "removed">> {
  const out = new Map<string, "closed" | "removed">();
  for (const p of s.projects.filter((x) => x.team && x.share && !x.archived)) {
    const doc = (await get(`shared/${p.share}`).catch(() => null)) as { name: string; color: string; owner: string; billable?: boolean; closed?: boolean } | null;
    if (!doc) { out.set(p.id, "removed"); continue; }
    if (doc.closed) out.set(p.id, "closed");
    if (doc.name !== p.name || doc.color !== p.color || (doc.owner || "") !== (p.owner || "") || Boolean(doc.billable) !== Boolean(p.billable)) {
      await set(`${s.base}/projects/${p.id}`, { name: doc.name, color: doc.color || p.color, owner: doc.owner || "", billable: Boolean(doc.billable), at: NOW() }).catch(() => null);
    }
  }
  return out;
}

// ---- Reports ----

/** Your entries in [from, to) plus teammates' time on projects you shared. */
export async function loadRows(s: Store, from: number, to: number): Promise<Row[]> {
  const own = await listAll<Entry>(`${s.base}/entries`, { start: { $gte: from, $lt: to } });
  const rows = own.filter((e) => e.start >= from && e.start < to).map((e) => toRow(s, e));
  for (const p of s.projects.filter((x) => x.share && !x.team)) {
    rows.push(...(await teamRowsFor(s, p, from, to).catch(() => [])));
  }
  return rows;
}

// ---- API keys ----

export interface ApiKey { id: string; label: string; hint: string; createdAt: number }

export async function createKey(s: Store, label: string): Promise<string> {
  const key = makeKey(s.id);
  const hash = await sha256Hex(key);
  await set(`${s.base}/apikeys/${hash}`, { label, hint: key.slice(-4), by: s.session.me.id, createdAt: NOW() });
  return key;
}

export async function revokeKey(s: Store, k: ApiKey) {
  await set(`${s.base}/apikeys/${k.id}`, null);
}

// ---- Time helpers for forms ----

export function timeText(s: Store, t: number): string {
  const m = minuteOf(t, s.tz);
  return `${String(Math.floor(m / 60)).padStart(2, "0")}:${String(m % 60).padStart(2, "0")}`;
}
export { addDays, durationOf };

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