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 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.
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
Bind agent address constants in policy
Use functions.openapps.xyz alias and add OpenApp contribution strip
Bounded source sync
Bounded source sync
Bounded source sync
Bounded source sync