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.
functions/api.ts
// Time's public HTTP API for AI assistants. Every route but GET / needs a workspace key that the
// workspace's owner created in Settings, sent as the x-workspace-key header. The key names its
// workspace; only its SHA-256 hash is stored, and this function checks it before it reads or writes
// anything, because every caller of this route shares one identity. Writes go through the same
// policy rules as the web app.
import { SITE_URL } from "./lib/config";
import {
KEY_RE, sha256Hex, randomId, parseTags, parseDuration, parseTimeOfDay, clock, hours, spoken, dayOf, minuteOf, zoned, isDay,
periodRange, PERIODS, addDays, daysBetween, summarize, rateOf, amountFor, durationOf, fromMinor, formatMoney, toMinor,
MAX_SECONDS, MAX_RATE, MAX_TEXT, COLORS,
type Workspace, type Project, type Client, type Tag, type Entry, type Timer, type TeamEntry, type Row, type Period, type Group,
} from "./lib/time";
const NOW = { operation: "time", value: "now" };
const ID_RE = /^[A-Za-z0-9]{8,40}$/;
const cors = {
"access-control-allow-origin": "*",
"access-control-allow-methods": "GET, POST, DELETE, OPTIONS",
"access-control-allow-headers": "content-type, x-workspace-key",
};
const json = (body: unknown, status = 200) =>
new Response(JSON.stringify(body, null, 2), { status, headers: { "content-type": "application/json; charset=utf-8", ...cors } });
const fail = (status: number, error: string) => json({ error }, status);
class HttpError extends Error { constructor(public status: number, message: string) { super(message); } }
async function body(req: Request, optional = false): Promise<Record<string, any>> {
const text = await req.text();
if (optional && !text.trim()) return {};
try {
const parsed = JSON.parse(text);
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed;
} catch { /* fall through */ }
throw new HttpError(400, "Send a JSON object body.");
}
const str = (v: unknown) => (v == null ? "" : String(v).trim());
const norm = (v: unknown) => str(v).toLowerCase();
const bareId = (r: any) => String(r.id ?? String(r._id ?? r.pathId ?? "").split("/").pop());
async function listAll(ctx: any, path: string, filter?: Record<string, unknown>): Promise<any[]> {
const rows: any[] = [];
let cursor: string | undefined;
for (let page = 0; page < 40; page++) {
const res = await ctx.bounded.get(path, { limit: 500, ...(filter ? { filter } : {}), ...(cursor ? { cursor } : {}) });
for (const r of res?.data || []) rows.push({ ...r, id: bareId(r) });
cursor = res?.nextCursor;
if (!cursor) break;
}
return rows;
}
interface Opened { id: string; base: string; ws: Workspace; projects: Project[]; clients: Client[]; tags: Tag[] }
/** Checks the key, joins the workspace as this route if needed, then loads projects, clients and tags. */
async function open(ctx: any, req: Request): Promise<Opened> {
const key = str(req.headers.get("x-workspace-key"));
if (!key) throw new HttpError(401, `Send a workspace key in the x-workspace-key header. The person creates one in Time under Settings, API key (${SITE_URL}/settings).`);
const m = key.match(KEY_RE);
if (!m) throw new HttpError(401, "That does not look like a Time workspace key. Keys look like tk_ followed by 20 characters, an underscore and 32 more.");
const id = m[1];
const hash = await sha256Hex(key);
const stored = await ctx.bounded.get(`ws/${id}/apikeys/${hash}`);
if (!stored) throw new HttpError(401, "That key is not valid. It may have been revoked; the person can create a new one in Time under Settings.");
const me = ctx.user.id;
const base = `ws/${id}`;
if (!(await ctx.bounded.get(`${base}/members/${me}`))) {
await ctx.bounded.set(`${base}/members/${me}`, { role: "api", invite: hash, at: NOW });
}
const doc = await ctx.bounded.get(base);
if (!doc) throw new HttpError(404, "This workspace no longer exists.");
const [projects, clients, tags] = await Promise.all([listAll(ctx, `${base}/projects`), listAll(ctx, `${base}/clients`), listAll(ctx, `${base}/tags`)]);
const ws: Workspace = { name: str(doc.name), currency: str(doc.currency) || "USD", rate: Number(doc.rate) || 0, weekStart: Number(doc.weekStart) === 0 ? 0 : 1, tz: str(doc.tz) || "UTC" };
try { dayOf(0, ws.tz); } catch { ws.tz = "UTC"; }
return { id, base, ws, projects: projects as Project[], clients: clients as Client[], tags: tags as Tag[] };
}
// ---- Lookups ----
function findProject(o: Opened, ref: unknown): Project | null {
if (ref == null || str(ref) === "" || norm(ref) === "none") return null;
const q = norm(ref);
const byId = o.projects.find((p) => p.id === str(ref));
if (byId) return byId;
const named = o.projects.filter((p) => norm(p.name) === q);
const exact = named.filter((p) => !p.archived);
if (exact.length === 1 || (!exact.length && named.length === 1)) return exact[0] || named[0];
const live = o.projects.filter((p) => !p.archived);
const partial = live.filter((p) => norm(p.name).includes(q));
if (!exact.length && partial.length === 1) return partial[0];
const names = live.map((p) => p.name).join(", ") || "none yet";
if (exact.length > 1 || partial.length > 1) throw new HttpError(400, `"${str(ref)}" matches more than one project: ${(exact.length > 1 ? exact : partial).map((p) => `${p.name} (${p.id})`).join(", ")}. Send the project id instead.`);
throw new HttpError(400, `No project called "${str(ref)}". Projects: ${names}. Create one with POST /projects, or leave project out.`);
}
const clientName = (o: Opened, id: string) => o.clients.find((c) => c.id === id)?.name ?? null;
const tagNames = (o: Opened, json: string) => parseTags(json).map((id) => o.tags.find((t) => t.id === id)?.name).filter((n): n is string => Boolean(n));
/** Tag names (a list or "a, b") -> tag ids, creating tags that do not exist yet. */
function resolveTags(o: Opened, input: unknown): { ids: string[]; names: string[]; writes: { path: string; document: Record<string, unknown> }[] } {
if (input == null || input === "") return { ids: [], names: [], writes: [] };
const list = (Array.isArray(input) ? input : String(input).split(",")).map(str).filter(Boolean);
if (list.length > 20) throw new HttpError(400, "Send at most 20 tags.");
const ids: string[] = [];
const names: string[] = [];
const writes: { path: string; document: Record<string, unknown> }[] = [];
for (const name of list) {
if (name.length > 60) throw new HttpError(400, "A tag name can be at most 60 characters.");
let t = o.tags.find((x) => norm(x.name) === norm(name) || x.id === name);
if (!t) {
t = { id: randomId(12), name };
o.tags.push(t);
writes.push({ path: `${o.base}/tags/${t.id}`, document: { name, at: NOW } });
}
if (!ids.includes(t.id)) { ids.push(t.id); names.push(t.name); }
}
return { ids, names, writes };
}
/** "2026-10-18T09:30:00Z", "2026-10-18T09:30+02:00", "2026-10-18 09:30" (workspace time zone), "09:30" (today) or unix seconds. */
function parseMoment(o: Opened, value: unknown, label: string): number {
if (typeof value === "number" && Number.isFinite(value) && value > 0) return Math.floor(value > 1e12 ? value / 1000 : value);
const s = str(value);
const local = s.match(/^(\d{4}-\d{2}-\d{2})[T ](\d{1,2}:\d{2})(?::\d{2})?$/);
if (local && isDay(local[1])) {
const min = parseTimeOfDay(local[2]);
if (min != null) return zoned(local[1], min, o.ws.tz);
}
if (/^\d{4}-\d{2}-\d{2}T.*(Z|[+-]\d{2}:?\d{2})$/i.test(s)) {
const t = Date.parse(s);
if (Number.isFinite(t)) return Math.floor(t / 1000);
}
const min = parseTimeOfDay(s);
if (min != null) return zoned(dayOf(nowSec(), o.ws.tz), min, o.ws.tz);
throw new HttpError(400, `${label} must be a time like "2026-10-18T09:30:00Z", "2026-10-18 09:30" (in the workspace's time zone, ${o.ws.tz}) or "09:30" (today).`);
}
const nowSec = () => Math.floor(Date.now() / 1000);
const localTime = (o: Opened, t: number) => {
const m = minuteOf(t, o.ws.tz);
return `${dayOf(t, o.ws.tz)} ${String(Math.floor(m / 60)).padStart(2, "0")}:${String(m % 60).padStart(2, "0")}`;
};
function text(v: unknown, label: string, max = MAX_TEXT): string {
const s = str(v);
if (s.length > max) throw new HttpError(400, `${label} can be at most ${max} characters.`);
return s;
}
// ---- Output shapes ----
function entryOut(o: Opened, e: Entry) {
const p = o.projects.find((x) => x.id === e.project);
const sec = durationOf(e);
const rate = rateOf(p, o.ws.rate);
return {
id: e.id,
description: e.description,
project: p?.name ?? null,
projectId: p?.id ?? null,
client: p?.client ? clientName(o, p.client) : null,
tags: tagNames(o, e.tags),
billable: Boolean(e.billable),
date: dayOf(e.start, o.ws.tz),
start: new Date(e.start * 1000).toISOString(),
end: new Date(e.end * 1000).toISOString(),
startLocal: localTime(o, e.start),
endLocal: localTime(o, e.end),
duration: clock(sec),
hours: hours(sec),
amount: e.billable && rate > 0 ? fromMinor(amountFor(sec, rate), o.ws.currency) : null,
};
}
function timerOut(o: Opened, t: Timer | null) {
if (!t || !t.start) return { running: false };
const p = o.projects.find((x) => x.id === t.project);
const sec = Math.max(0, nowSec() - t.start);
return {
running: true,
description: t.description,
project: p?.name ?? null,
projectId: p?.id ?? null,
tags: tagNames(o, t.tags),
billable: Boolean(t.billable),
start: new Date(t.start * 1000).toISOString(),
startLocal: localTime(o, t.start),
elapsed: clock(sec),
elapsedSeconds: sec,
};
}
function projectOut(o: Opened, p: Project) {
return {
id: p.id,
name: p.name,
client: p.client ? clientName(o, p.client) : null,
color: p.color,
hourlyRate: p.rate > 0 ? fromMinor(p.rate, o.ws.currency) : null,
billableByDefault: Boolean(p.billable),
archived: Boolean(p.archived),
sharedByLink: Boolean(p.share && !p.team),
joinedFrom: p.team ? p.owner || "someone" : null,
};
}
// ---- Writes ----
type Write = { path: string; document: Record<string, unknown> | null };
const isTeam = (p: Project | null | undefined): p is Project => Boolean(p?.team && p.share && !p.archived);
const notShared = (p: Project) => new HttpError(409, `"${p.name}" is no longer shared with this workspace, so time cannot be added to it. Use another project.`);
/** Whether someone else's project, joined by link, still takes this workspace's time: its owner has not stopped sharing it or removed this workspace. This route joins it too (as the same workspace). */
async function takesTime(ctx: any, o: Opened, p: Project): Promise<boolean> {
const me = ctx.user.id;
if (await ctx.bounded.get(`shared/${p.share}/removed/${o.id}`)) return false;
if (!(await ctx.bounded.get(`shared/${p.share}/joins/${me}`))) {
try {
await ctx.bounded.set(`shared/${p.share}/joins/${me}`, { name: o.ws.name || "Teammate", ws: o.id, at: NOW });
} catch {
return false;
}
}
const shared = await ctx.bounded.get(`shared/${p.share}`);
return Boolean(shared && !shared.closed);
}
/** New time on someone else's project needs the project to still take this workspace's time. */
async function ensureTeam(ctx: any, o: Opened, p: Project) {
if (!(await takesTime(ctx, o, p))) throw notShared(p);
}
/** A teammate's entry on someone else's project also goes to the shared project, so its owner sees it. Once the project no longer takes this workspace's time, what was logged stays with its owner and nothing more goes there. */
async function teamCopy(ctx: any, o: Opened, p: Project | null, id: string, e: Omit<Entry, "id">, tags: string[]): Promise<Write[]> {
if (!isTeam(p) || !(await takesTime(ctx, o, p))) return [];
return [{ path: `shared/${p.share}/entries/${id}`, document: { ws: o.id, who: o.ws.name || "Teammate", description: e.description, tags: JSON.stringify(tags), billable: e.billable, start: e.start, end: e.end, at: NOW } }];
}
async function saveEntry(ctx: any, o: Opened, id: string, e: Omit<Entry, "id">, extra: Write[] = []): Promise<Entry> {
const p = o.projects.find((x) => x.id === e.project) ?? null;
const team = await teamCopy(ctx, o, p, id, e, tagNames(o, e.tags));
try {
await ctx.bounded.setMany([...extra, { path: `${o.base}/entries/${id}`, document: { ...e, at: NOW } }, ...team]);
} catch (err) {
if (team.length && p) throw notShared(p);
throw err;
}
return { id, ...e };
}
function checkSpan(start: number, end: number) {
if (start < 946684800) throw new HttpError(400, "start must be after the year 2000.");
if (end < start) throw new HttpError(400, "end must be after start.");
if (end - start > MAX_SECONDS) throw new HttpError(400, "One entry can be at most 1,000 hours.");
}
function billableFor(input: Record<string, any>, p: Project | null): boolean {
if ("billable" in input && input.billable != null) {
if (typeof input.billable !== "boolean") throw new HttpError(400, "billable must be true or false.");
return input.billable;
}
return Boolean(p?.billable);
}
// ---- Reports ----
function rangeOf(o: Opened, url: URL, fallback: Period): { from: string; to: string } {
const today = dayOf(nowSec(), o.ws.tz);
const period = str(url.searchParams.get("period"));
const from = str(url.searchParams.get("from"));
const to = str(url.searchParams.get("to"));
if (period) {
if (!(PERIODS as readonly string[]).includes(period)) throw new HttpError(400, `period must be one of ${PERIODS.join(", ")}.`);
return periodRange(period as Period, today, o.ws.weekStart);
}
if (from || to) {
if (!isDay(from) || !isDay(to || from)) throw new HttpError(400, "from and to must be dates like 2026-10-01.");
const end = to || from;
if (end < from) throw new HttpError(400, "to must be on or after from.");
if (daysBetween(from, end) > 400) throw new HttpError(400, "A range can be at most 400 days.");
return { from, to: end };
}
return periodRange(fallback, today, o.ws.weekStart);
}
async function entriesIn(ctx: any, o: Opened, from: string, to: string): Promise<Entry[]> {
const lo = zoned(from, 0, o.ws.tz);
const hi = zoned(addDays(to, 1), 0, o.ws.tz);
const rows = await listAll(ctx, `${o.base}/entries`, { start: { $gte: lo, $lt: hi } });
return (rows as Entry[]).filter((e) => e.start >= lo && e.start < hi);
}
/** Teammates' time on projects this workspace shared by link. */
async function teamRows(ctx: any, o: Opened, from: string, to: string): Promise<Row[]> {
const me = ctx.user.id;
const lo = zoned(from, 0, o.ws.tz);
const hi = zoned(addDays(to, 1), 0, o.ws.tz);
const out: Row[] = [];
for (const p of o.projects.filter((x) => x.share && !x.team)) {
try {
if (!(await ctx.bounded.get(`shared/${p.share}/owners/${me}`))) {
await ctx.bounded.set(`shared/${p.share}/owners/${me}`, { ws: o.id, at: NOW });
}
const shared = await ctx.bounded.get(`shared/${p.share}`);
if (!shared || shared.ws !== o.id) continue;
const rows = (await listAll(ctx, `shared/${p.share}/entries`, { start: { $gte: lo, $lt: hi } })) as TeamEntry[];
for (const r of rows) {
if (r.ws === o.id || r.start < lo || r.start >= hi) continue;
out.push({ 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" });
}
} catch { /* this project is not readable as an owner from here; its own time still counts */ }
}
return out;
}
function groupOut(o: Opened, g: Group) {
return {
name: g.name,
duration: clock(g.seconds),
hours: hours(g.seconds),
billableHours: hours(g.billableSeconds),
amount: fromMinor(g.amount, o.ws.currency),
entries: g.entries,
};
}
// ---- Routes ----
const ROUTES = {
name: "Time by OpenApps",
about: "Free time tracker with timesheets and reports. Start and stop a timer, add time, list entries and read reports for one workspace. Every route but this one needs the workspace key the person creates in Time under Settings, sent as the x-workspace-key header.",
docs: `${SITE_URL}/llms.txt`,
openapi: `${SITE_URL}/openapi.json`,
routes: [
"GET /timer -> the running timer, or {running: false}",
"POST /timer/start {description?, project?, tags?, billable?, start?} -> starts a timer (a running one is stopped and saved first)",
"POST /timer/stop {end?} -> stops the timer and returns the saved entry",
"GET /entries?from=YYYY-MM-DD&to=YYYY-MM-DD (or ?period=today|yesterday|week|last-week|month|last-month|year) -> entries, newest first",
"POST /entries {description?, project?, tags?, billable?, start, end} or {duration, date?, startTime?} -> adds an entry",
"DELETE /entries/:id -> deletes an entry",
"GET /projects -> projects with clients and hourly rates",
"POST /projects {name, client?, hourlyRate?, billable?, color?} -> adds a project (returns the existing one if the name is taken)",
"GET /report?period=week (or from and to) -> totals by project, client, tag, person and day, with billable amounts",
],
};
export default async function api(req: Request, ctx: any) {
if (req.method === "OPTIONS") return new Response(null, { status: 204, headers: cors });
const url = new URL(req.url);
const parts = url.pathname.split("/").filter(Boolean);
// Accept both /timer and /api/timer so a copied path works either way.
if (parts[0] === "api") parts.shift();
try {
if (!parts.length && req.method === "GET") return json(ROUTES);
const route = `${req.method} /${parts.join("/")}`;
const known = ["timer", "entries", "projects", "report"];
if (!known.includes(parts[0])) return fail(404, "Unknown route. GET / lists the routes.");
const o = await open(ctx, req);
if (route === "GET /timer") {
return json(timerOut(o, await ctx.bounded.get(`${o.base}/timer/now`)));
}
if (route === "POST /timer/start") {
const input = await body(req, true);
const description = text(input.description, "description");
const p = findProject(o, input.project);
if (isTeam(p)) await ensureTeam(ctx, o, p);
const tags = resolveTags(o, input.tags);
const start = input.start == null || input.start === "" ? nowSec() : parseMoment(o, input.start, "start");
if (start > nowSec() + 60) throw new HttpError(400, "start cannot be in the future.");
checkSpan(start, Math.max(start, nowSec()));
const running = (await ctx.bounded.get(`${o.base}/timer/now`)) as Timer | null;
let stopped: Entry | null = null;
if (running?.start) {
const end = Math.max(running.start, Math.min(nowSec(), Math.max(start, running.start)));
stopped = await saveEntry(ctx, o, running.entry || randomId(20), {
description: running.description, project: running.project, tags: running.tags, billable: Boolean(running.billable), start: running.start, end,
}, tags.writes);
tags.writes = [];
}
const timer: Timer = { description, project: p?.id ?? "", tags: JSON.stringify(tags.ids), billable: billableFor(input, p), start, entry: randomId(20) };
await ctx.bounded.setMany([...tags.writes, { path: `${o.base}/timer/now`, document: { ...timer, at: NOW } }]);
return json({ timer: timerOut(o, timer), stopped: stopped ? entryOut(o, stopped) : null }, 201);
}
if (route === "POST /timer/stop") {
const input = await body(req, true);
const running = (await ctx.bounded.get(`${o.base}/timer/now`)) as Timer | null;
if (!running?.start) throw new HttpError(409, "No timer is running.");
const end = input.end == null || input.end === "" ? nowSec() : parseMoment(o, input.end, "end");
checkSpan(running.start, end);
const entry = await saveEntry(ctx, o, running.entry || randomId(20), {
description: running.description, project: running.project, tags: running.tags, billable: Boolean(running.billable), start: running.start, end,
}, [{ path: `${o.base}/timer/now`, document: { start: 0, entry: "", at: NOW } }]);
return json({ entry: entryOut(o, entry) });
}
if (route === "GET /entries") {
const { from, to } = rangeOf(o, url, "week");
let list = await entriesIn(ctx, o, from, to);
const pref = url.searchParams.get("project");
if (pref) {
const p = findProject(o, pref);
list = list.filter((e) => e.project === (p?.id ?? ""));
}
list.sort((a, b) => b.start - a.start);
const seconds = list.reduce((a, e) => a + durationOf(e), 0);
return json({ from, to, timeZone: o.ws.tz, total: { duration: clock(seconds), hours: hours(seconds) }, count: list.length, entries: list.map((e) => entryOut(o, e)) });
}
if (route === "POST /entries") {
const input = await body(req);
const description = text(input.description, "description");
const p = findProject(o, input.project);
if (isTeam(p)) await ensureTeam(ctx, o, p);
const tags = resolveTags(o, input.tags);
let start: number;
let end: number;
const hasDuration = input.duration != null && input.duration !== "";
if (input.start != null && input.start !== "" && input.end != null && input.end !== "") {
start = parseMoment(o, input.start, "start");
end = parseMoment(o, input.end, "end");
} else if (hasDuration) {
const sec = parseDuration(input.duration);
if (sec == null || sec <= 0) throw new HttpError(400, 'duration must look like "1h30m", "1:30", "90m" or 1.5 (hours).');
const today = dayOf(nowSec(), o.ws.tz);
const date = input.date == null || input.date === "" ? today : str(input.date);
if (!isDay(date)) throw new HttpError(400, "date must look like 2026-10-18.");
if (input.start != null && input.start !== "") start = parseMoment(o, input.start, "start");
else if (input.startTime != null && input.startTime !== "") {
const min = parseTimeOfDay(input.startTime);
if (min == null) throw new HttpError(400, 'startTime must look like "09:30" or "2:15 pm".');
start = zoned(date, min, o.ws.tz);
} else if (date === today) start = nowSec() - sec;
else start = zoned(date, 9 * 60, o.ws.tz);
end = start + sec;
} else {
throw new HttpError(400, 'Send start and end, or a duration like "1h30m" (with an optional date and startTime).');
}
checkSpan(start, end);
const entry = await saveEntry(ctx, o, randomId(20), { description, project: p?.id ?? "", tags: JSON.stringify(tags.ids), billable: billableFor(input, p), start, end }, tags.writes);
return json({ entry: entryOut(o, entry) }, 201);
}
if (req.method === "DELETE" && parts[0] === "entries" && parts.length === 2) {
const id = parts[1];
if (!ID_RE.test(id)) throw new HttpError(404, "No entry with that id.");
const e = (await ctx.bounded.get(`${o.base}/entries/${id}`)) as Entry | null;
if (!e) throw new HttpError(404, "No entry with that id.");
const p = o.projects.find((x) => x.id === e.project);
const writes: Write[] = [{ path: `${o.base}/entries/${id}`, document: null }];
if (isTeam(p) && (await takesTime(ctx, o, p))) writes.push({ path: `shared/${p.share}/entries/${id}`, document: null });
await ctx.bounded.setMany(writes);
return json({ deleted: true, entry: entryOut(o, { ...e, id }) });
}
if (route === "GET /projects") {
const projects = [...o.projects].sort((a, b) => Number(Boolean(a.archived)) - Number(Boolean(b.archived)) || a.name.localeCompare(b.name));
return json({
currency: o.ws.currency,
defaultHourlyRate: o.ws.rate > 0 ? fromMinor(o.ws.rate, o.ws.currency) : null,
projects: projects.map((p) => projectOut(o, p)),
clients: o.clients.filter((c) => !c.archived).map((c) => ({ id: c.id, name: c.name })),
tags: o.tags.map((t) => t.name).sort(),
});
}
if (route === "POST /projects") {
const input = await body(req);
const name = text(input.name, "name", 100);
if (!name) throw new HttpError(400, "name is required.");
const same = o.projects.find((p) => norm(p.name) === norm(name) && !p.team);
if (same) return json({ project: projectOut(o, same), existing: true });
const writes: Write[] = [];
let clientId = "";
const cname = text(input.client, "client", 100);
if (cname) {
let c = o.clients.find((x) => norm(x.name) === norm(cname) || x.id === cname);
if (!c) {
c = { id: randomId(12), name: cname };
o.clients.push(c);
writes.push({ path: `${o.base}/clients/${c.id}`, document: { name: cname, archived: false, at: NOW } });
}
clientId = c.id;
}
let rate = 0;
if (input.hourlyRate != null && input.hourlyRate !== "") {
rate = toMinor(input.hourlyRate, o.ws.currency);
if (!Number.isFinite(rate) || rate > MAX_RATE) throw new HttpError(400, `hourlyRate must be an amount in ${o.ws.currency}, like "80" or "80.50".`);
}
const color = COLORS.includes(str(input.color)) ? str(input.color) : COLORS[o.projects.length % COLORS.length];
const billable = input.billable == null ? rate > 0 || o.ws.rate > 0 : Boolean(input.billable);
const p: Project = { id: randomId(12), name, color, client: clientId, rate, billable, archived: false, share: "", team: false, owner: "" };
writes.push({ path: `${o.base}/projects/${p.id}`, document: { name, color, client: clientId, rate, billable, archived: false, share: "", team: false, owner: "", at: NOW } });
await ctx.bounded.setMany(writes);
o.projects.push(p);
return json({ project: projectOut(o, p) }, 201);
}
if (route === "GET /report") {
const { from, to } = rangeOf(o, url, "week");
const own = await entriesIn(ctx, o, from, to);
const rows: Row[] = own.map((e) => ({ id: e.id, description: e.description, project: e.project, tags: tagNames(o, e.tags), billable: Boolean(e.billable), start: e.start, end: e.end, who: "" }));
const team = await teamRows(ctx, o, from, to);
const s = summarize([...rows, ...team], o.projects, o.clients, o.ws.rate, o.ws.tz);
const cur = o.ws.currency;
return json({
from, to, timeZone: o.ws.tz, currency: cur,
total: {
duration: clock(s.seconds), hours: hours(s.seconds), spoken: spoken(s.seconds), billableHours: hours(s.billableSeconds),
amount: fromMinor(s.amount, cur), amountDisplay: formatMoney(s.amount, cur, "en-US"), entries: s.entries,
},
projects: s.projects.map((g) => ({ ...groupOut(o, g), client: o.projects.find((p) => p.id === g.key)?.client ? clientName(o, o.projects.find((p) => p.id === g.key)!.client) : null })),
clients: s.clients.map((g) => groupOut(o, g)),
tags: s.tags.map((g) => groupOut(o, g)),
people: team.length ? s.people.map((g) => groupOut(o, g)) : undefined,
days: s.days.map((d) => ({ date: d.day, duration: clock(d.seconds), hours: hours(d.seconds) })),
notes: [
"Amounts are billable hours x hourly rate (a project's own rate, else the default), in the workspace currency.",
"An entry with several tags counts under each tag.",
...(team.length ? ["Includes teammates' time on projects this workspace shared by link (see people)."] : []),
],
});
}
return fail(404, "Unknown route. GET / lists the routes.");
} catch (err) {
if (err instanceof HttpError) return fail(err.status, err.message);
const message = err instanceof Error ? err.message : String(err);
return fail(500, `Something went wrong: ${message}`);
}
}
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