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/lib/time.ts
// Time's shared logic: durations, days in a time zone, money in minor units, reports, CSV and invoice
// lines. The web app and the public API both use it, so their numbers always agree.
// Money is an integer in the currency's minor unit (cents for USD). A billable entry earns
// seconds x hourly rate / 3600, rounded half up once per entry; every total is a sum of entries.
export interface Workspace { name: string; currency: string; rate: number; weekStart: number; tz: string }
export interface Client { id: string; name: string; archived?: boolean }
export interface Project {
id: string;
name: string;
color: string;
/** Client id, or "". */
client: string;
/** Hourly rate in minor units; 0 uses the workspace's default rate. */
rate: number;
/** Whether new entries on this project start billable. */
billable: boolean;
archived?: boolean;
/** The shared project's id when this project is shared (by you) or joined (from someone else). */
share?: string;
/** True when this is someone else's project that you joined by link. */
team?: boolean;
/** For a joined project: the owner's name. */
owner?: string;
}
export interface Tag { id: string; name: string }
export interface Entry {
id: string;
description: string;
project: string;
/** JSON list of tag ids. */
tags: string;
billable: boolean;
/** Unix seconds. */
start: number;
end: number;
}
export interface Timer { description: string; project: string; tags: string; billable: boolean; start: number; entry: string }
/** A teammate's entry on a shared project, as its owner reads it. tags is a JSON list of tag names. */
export interface TeamEntry { id: string; ws: string; who: string; description: string; tags: string; billable: boolean; start: number; end: number }
/** One entry as reports see it: tag names, and who logged it ("" is you). */
export interface Row { id: string; description: string; project: string; tags: string[]; billable: boolean; start: number; end: number; who: string }
export const MAX_SECONDS = 3_600_000; // 1,000 hours: the longest single entry (the policy enforces the same)
export const MAX_RATE = 100_000_000; // in minor units per hour
export const MAX_TEXT = 1000;
export const NO_PROJECT_COLOR = "#8a8e96";
/** Project colors: checked for color-blind separation against the paper background. */
export const COLORS = ["#2e5ec4", "#d0652b", "#2f9466", "#b8860b", "#cf5f8c", "#6a55c8", "#00939e", "#c2453f"];
// ---- Ids and keys ----
const ALPHA = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
export function randomId(n = 20): string {
const bytes = crypto.getRandomValues(new Uint8Array(n));
let s = "";
for (const b of bytes) s += ALPHA[b % 62];
return s;
}
/** Workspace keys name their workspace: tk_<workspace id>_<secret>. Only the SHA-256 hash is stored. */
export const KEY_RE = /^tk_([A-Za-z0-9]{20})_([A-Za-z0-9]{32})$/;
export const makeKey = (ws: string) => `tk_${ws}_${randomId(32)}`;
export async function sha256Hex(text: string): Promise<string> {
const buf = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text));
return Array.from(new Uint8Array(buf), (b) => b.toString(16).padStart(2, "0")).join("");
}
export function parseTags(json: unknown): string[] {
try {
const v = JSON.parse(String(json || "[]"));
return Array.isArray(v) ? v.map(String).filter(Boolean) : [];
} catch {
return [];
}
}
// ---- Durations ----
/** "1:30", "1:30:15", "1.5", "1,5", "90m", "1h 30m", "1h30", "2 hours", "45 min" or a number of hours -> seconds. */
export function parseDuration(input: unknown): number | null {
if (typeof input === "number") return Number.isFinite(input) && input >= 0 ? Math.round(input * 3600) : null;
const s = String(input ?? "").trim().toLowerCase().replace(/\s+/g, " ");
if (!s) return null;
let m = s.match(/^(\d{1,4}):([0-5]?\d)(?::([0-5]?\d))?$/);
if (m) return Number(m[1]) * 3600 + Number(m[2]) * 60 + Number(m[3] || 0);
m = s.match(/^(\d{0,4})(?:[.,](\d{1,4}))?$/);
if (m && (m[1] || m[2])) return Math.round(Number(`${m[1] || "0"}.${m[2] || "0"}`) * 3600);
m = s.match(/^(\d{1,4})\s?h\s?(\d{1,2})$/);
if (m) return Number(m[1]) * 3600 + Number(m[2]) * 60;
m = s.match(/^(?:(\d{1,4}(?:[.,]\d+)?)\s?h(?:ours?|rs?|r)?)?\s?(?:(\d{1,5})\s?m(?:in(?:ute)?s?)?)?\s?(?:(\d{1,6})\s?s(?:ec(?:ond)?s?)?)?$/);
if (m && (m[1] || m[2] || m[3])) return Math.round(Number((m[1] || "0").replace(",", ".")) * 3600) + Number(m[2] || 0) * 60 + Number(m[3] || 0);
return null;
}
const pad = (n: number) => String(n).padStart(2, "0");
/** 3909 -> "1:05:09" */
export function clock(sec: number): string {
const s = Math.max(0, Math.floor(sec));
return `${Math.floor(s / 3600)}:${pad(Math.floor((s % 3600) / 60))}:${pad(s % 60)}`;
}
/** 3909 -> "1:05" (rounded down to the minute) */
export function hm(sec: number): string {
const m = Math.max(0, Math.floor(sec / 60));
return `${Math.floor(m / 60)}:${pad(m % 60)}`;
}
/** 3909 -> "1.09" */
export function hours(sec: number, digits = 2): string {
return (Math.max(0, sec) / 3600).toFixed(digits);
}
/** 3909 -> "1h 5m", 600 -> "10m" */
export function spoken(sec: number): string {
const m = Math.max(0, Math.round(sec / 60));
const h = Math.floor(m / 60);
if (!h) return `${m}m`;
return m % 60 ? `${h}h ${m % 60}m` : `${h}h`;
}
/** "9", "9:30", "0930", "9.30", "9:30 pm", "21:30" -> minutes after midnight, or null. */
export function parseTimeOfDay(input: unknown): number | null {
const s = String(input ?? "").trim().toLowerCase();
const m = s.match(/^(\d{1,2})(?:[:.]?(\d{2}))?\s?(am|pm|a|p)?$/);
if (!m) return null;
let h = Number(m[1]);
const mi = Number(m[2] || 0);
if (mi > 59) return null;
if (m[3]) {
if (h < 1 || h > 12) return null;
h = (h % 12) + (m[3].startsWith("p") ? 12 : 0);
}
return h > 23 ? null : h * 60 + mi;
}
// ---- Days in a time zone ----
const formats = new Map<string, Intl.DateTimeFormat>();
function parts(t: number, tz: string) {
let f = formats.get(tz);
if (!f) {
f = new Intl.DateTimeFormat("en-US", { timeZone: tz, hourCycle: "h23", year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit" });
formats.set(tz, f);
}
const o: Record<string, string> = {};
for (const p of f.formatToParts(new Date(t * 1000))) o[p.type] = p.value;
return { y: Number(o.year), m: Number(o.month), d: Number(o.day), h: Number(o.hour) % 24, mi: Number(o.minute), s: Number(o.second) };
}
export function validTz(tz: unknown): tz is string {
if (typeof tz !== "string" || !tz) return false;
try {
new Intl.DateTimeFormat("en-US", { timeZone: tz });
return true;
} catch {
return false;
}
}
export function localTz(): string {
try {
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
return validTz(tz) ? tz : "UTC";
} catch {
return "UTC";
}
}
export const isDay = (s: unknown): s is string => typeof s === "string" && /^\d{4}-\d{2}-\d{2}$/.test(s) && Number.isFinite(Date.parse(`${s}T00:00:00Z`));
/** The calendar day (YYYY-MM-DD) a moment falls on in a time zone. */
export function dayOf(t: number, tz: string): string {
const p = parts(t, tz);
return `${p.y}-${pad(p.m)}-${pad(p.d)}`;
}
/** Minutes after local midnight. */
export function minuteOf(t: number, tz: string): number {
const p = parts(t, tz);
return p.h * 60 + p.mi;
}
function offsetAt(t: number, tz: string): number {
const p = parts(t, tz);
return Date.UTC(p.y, p.m - 1, p.d, p.h, p.mi, p.s) / 1000 - t;
}
/** A wall-clock time on a day in a time zone -> unix seconds. */
export function zoned(day: string, minutes: number, tz: string): number {
const [y, m, d] = day.split("-").map(Number);
const guess = Date.UTC(y, m - 1, d, 0, minutes) / 1000;
const first = guess - offsetAt(guess, tz);
return guess - offsetAt(first, tz);
}
export function addDays(day: string, n: number): string {
const t = Date.parse(`${day}T00:00:00Z`) + n * 86_400_000;
return new Date(t).toISOString().slice(0, 10);
}
/** 0 is Sunday. */
export const weekday = (day: string) => new Date(`${day}T00:00:00Z`).getUTCDay();
/** The first day of the week holding `day`; weekStart 1 is Monday, 0 is Sunday. */
export function weekOf(day: string, weekStart: number): string {
return addDays(day, -((weekday(day) - weekStart + 7) % 7));
}
export const monthOf = (day: string) => `${day.slice(0, 7)}-01`;
export function addMonths(day: string, n: number): string {
const [y, m] = day.split("-").map(Number);
const d = new Date(Date.UTC(y, m - 1 + n, 1));
return d.toISOString().slice(0, 10);
}
export function daysBetween(from: string, to: string): number {
return Math.round((Date.parse(`${to}T00:00:00Z`) - Date.parse(`${from}T00:00:00Z`)) / 86_400_000);
}
export const PERIODS = ["today", "yesterday", "week", "last-week", "month", "last-month", "year"] as const;
export type Period = (typeof PERIODS)[number];
/** Inclusive first and last day of a named period. */
export function periodRange(p: Period, today: string, weekStart: number): { from: string; to: string } {
const wk = weekOf(today, weekStart);
switch (p) {
case "today": return { from: today, to: today };
case "yesterday": return { from: addDays(today, -1), to: addDays(today, -1) };
case "week": return { from: wk, to: addDays(wk, 6) };
case "last-week": return { from: addDays(wk, -7), to: addDays(wk, -1) };
case "month": return { from: monthOf(today), to: addDays(addMonths(today, 1), -1) };
case "last-month": return { from: addMonths(today, -1), to: addDays(monthOf(today), -1) };
case "year": return { from: `${today.slice(0, 4)}-01-01`, to: `${today.slice(0, 4)}-12-31` };
}
}
// ---- Money ----
export const CURRENCIES = [
"USD", "EUR", "GBP", "CAD", "AUD", "NZD", "JPY", "CHF", "SEK", "NOK", "DKK", "PLN", "CZK", "HUF",
"INR", "SGD", "HKD", "CNY", "KRW", "MXN", "BRL", "ARS", "CLP", "COP", "ZAR", "AED", "ILS", "TRY", "THB", "IDR", "PHP", "VND",
];
export function minorDigits(currency: string): number {
try {
return new Intl.NumberFormat("en", { style: "currency", currency }).resolvedOptions().maximumFractionDigits ?? 2;
} catch {
return 2;
}
}
/** "80", "80.5", 80.5 in USD -> 8050. NaN for anything that is not a non-negative amount with at most the currency's decimals. */
export function toMinor(value: unknown, currency: string): number {
const digits = minorDigits(currency);
const text = String(value ?? "").trim().replace(/[\s,]/g, "");
if (!/^\d+(\.\d+)?$|^\.\d+$/.test(text)) return NaN;
const [whole, frac = ""] = text.split(".");
if (frac.length > digits) return NaN;
const n = Number(whole || "0") * 10 ** digits + Number((frac + "0".repeat(digits)).slice(0, digits) || "0");
return Number.isSafeInteger(n) ? n : NaN;
}
export function fromMinor(minor: number, currency: string): string {
const digits = minorDigits(currency);
const sign = minor < 0 ? "-" : "";
const abs = Math.abs(minor);
if (digits === 0) return `${sign}${abs}`;
const s = String(abs).padStart(digits + 1, "0");
return `${sign}${s.slice(0, -digits)}.${s.slice(-digits)}`;
}
export function formatMoney(minor: number, currency: string, locale?: string): string {
try {
return new Intl.NumberFormat(locale, { style: "currency", currency }).format(minor / 10 ** minorDigits(currency));
} catch {
return `${fromMinor(minor, currency)} ${currency}`;
}
}
/** Rates move with the currency's decimals when the currency changes (USD 80.00 -> JPY 80). */
export function rescale(minor: number, from: string, to: string): number {
const d = minorDigits(to) - minorDigits(from);
return d >= 0 ? minor * 10 ** d : Math.round(minor / 10 ** -d);
}
export const rateOf = (p: Project | undefined, defaultRate: number) => (p && p.rate > 0 ? p.rate : defaultRate);
/** seconds x rate / 3600, rounded half up. */
export const amountFor = (seconds: number, rate: number) => Math.floor((seconds * rate + 1800) / 3600);
// ---- Reports ----
export interface Group { key: string; name: string; color?: string; seconds: number; billableSeconds: number; amount: number; entries: number }
export interface Summary {
seconds: number;
billableSeconds: number;
amount: number;
entries: number;
projects: Group[];
clients: Group[];
tags: Group[];
people: Group[];
days: { day: string; seconds: number }[];
}
export const durationOf = (r: { start: number; end: number }) => Math.max(0, r.end - r.start);
export function rowAmount(r: Row, projects: Map<string, Project>, defaultRate: number): number {
return r.billable ? amountFor(durationOf(r), rateOf(projects.get(r.project), defaultRate)) : 0;
}
/** Totals by project, client, tag, person and day. Tag totals overlap when an entry has more than one tag. */
export function summarize(rows: Row[], projectList: Project[], clientList: Client[], defaultRate: number, tz: string): Summary {
const projects = new Map(projectList.map((p) => [p.id, p]));
const clients = new Map(clientList.map((c) => [c.id, c]));
const out: Summary = { seconds: 0, billableSeconds: 0, amount: 0, entries: 0, projects: [], clients: [], tags: [], people: [], days: [] };
const groups = { projects: new Map<string, Group>(), clients: new Map<string, Group>(), tags: new Map<string, Group>(), people: new Map<string, Group>() };
const days = new Map<string, number>();
const add = (map: Map<string, Group>, key: string, name: string, sec: number, bill: number, amt: number, color?: string) => {
let g = map.get(key);
if (!g) map.set(key, (g = { key, name, color, seconds: 0, billableSeconds: 0, amount: 0, entries: 0 }));
g.seconds += sec;
g.billableSeconds += bill;
g.amount += amt;
g.entries += 1;
};
for (const r of rows) {
const sec = durationOf(r);
const bill = r.billable ? sec : 0;
const amt = rowAmount(r, projects, defaultRate);
const p = projects.get(r.project);
out.seconds += sec;
out.billableSeconds += bill;
out.amount += amt;
out.entries += 1;
add(groups.projects, p ? p.id : "", p ? p.name : "No project", sec, bill, amt, p ? p.color : NO_PROJECT_COLOR);
const c = p?.client ? clients.get(p.client) : undefined;
add(groups.clients, c ? c.id : "", c ? c.name : "No client", sec, bill, amt);
if (r.tags.length) for (const t of r.tags) add(groups.tags, t.toLowerCase(), t, sec, bill, amt);
else add(groups.tags, "", "No tag", sec, bill, amt);
add(groups.people, r.who, r.who || "You", sec, bill, amt);
const d = dayOf(r.start, tz);
days.set(d, (days.get(d) || 0) + sec);
}
const sorted = (m: Map<string, Group>) => [...m.values()].sort((a, b) => b.seconds - a.seconds || a.name.localeCompare(b.name));
out.projects = sorted(groups.projects);
out.clients = sorted(groups.clients);
out.tags = sorted(groups.tags);
out.people = sorted(groups.people);
out.days = [...days.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([day, seconds]) => ({ day, seconds }));
return out;
}
// ---- CSV ----
/** Quotes a cell, and keeps spreadsheet apps from running text that starts like a formula. */
function cell(v: string | number, text = false): string {
let s = String(v);
if (text && /^[=+\-@\t\r]/.test(s)) s = `'${s}`;
return /[",\n\r]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
}
export function entriesCsv(rows: Row[], projectList: Project[], clientList: Client[], ws: Workspace): string {
const projects = new Map(projectList.map((p) => [p.id, p]));
const clients = new Map(clientList.map((c) => [c.id, c]));
const cur = ws.currency;
const head = ["Date", "Start", "End", "Duration", "Hours", "Description", "Project", "Client", "Tags", "Billable", `Rate (${cur}/h)`, `Amount (${cur})`, "Person"];
const lines = [head.join(",")];
const time = (t: number) => { const m = minuteOf(t, ws.tz); return `${pad(Math.floor(m / 60))}:${pad(m % 60)}`; };
for (const r of [...rows].sort((a, b) => a.start - b.start)) {
const p = projects.get(r.project);
const c = p?.client ? clients.get(p.client) : undefined;
const sec = durationOf(r);
const rate = rateOf(p, ws.rate);
lines.push([
cell(dayOf(r.start, ws.tz)), cell(time(r.start)), cell(time(r.end)), cell(clock(sec)), cell(hours(sec)),
cell(r.description, true), cell(p?.name ?? "", true), cell(c?.name ?? "", true), cell(r.tags.join("; "), true),
r.billable ? "Yes" : "No", cell(r.billable ? fromMinor(rate, cur) : ""), cell(r.billable ? fromMinor(amountFor(sec, rate), cur) : ""),
cell(r.who || "You", true),
].join(","));
}
return lines.join("\r\n") + "\r\n";
}
// ---- Invoice lines (for Invoice, the free invoice app) ----
export type InvoiceGrouping = "project" | "entry";
export interface InvoiceLine { description: string; quantity: string; unitPrice: string; amount: number }
/** Hours to 2 decimals, as hundredths: 0.01 hour is 36 seconds. */
const hundredths = (sec: number) => Math.floor((sec + 18) / 36);
/** Billable time as invoice lines: hours (2 decimals) x hourly rate. Totals match what Invoice computes. */
export function invoiceLines(rows: Row[], projectList: Project[], defaultRate: number, currency: string, grouping: InvoiceGrouping, tz: string): InvoiceLine[] {
const projects = new Map(projectList.map((p) => [p.id, p]));
const lines: { description: string; seconds: number; rate: number }[] = [];
const billable = rows.filter((r) => r.billable && rateOf(projects.get(r.project), defaultRate) > 0).sort((a, b) => a.start - b.start);
if (grouping === "project") {
const byKey = new Map<string, { description: string; seconds: number; rate: number }>();
for (const r of billable) {
const p = projects.get(r.project);
const rate = rateOf(p, defaultRate);
const key = `${r.project}|${rate}`;
let l = byKey.get(key);
if (!l) byKey.set(key, (l = { description: `${p?.name ?? "Other work"} (hours)`, seconds: 0, rate }));
l.seconds += durationOf(r);
}
lines.push(...byKey.values());
} else {
for (const r of billable) {
const p = projects.get(r.project);
const what = [r.description, p?.name].filter(Boolean).join(", ") || "Work";
lines.push({ description: `${dayOf(r.start, tz)}: ${what}${r.who ? ` (${r.who})` : ""}`, seconds: durationOf(r), rate: rateOf(p, defaultRate) });
}
}
return lines
.map((l) => {
const q = hundredths(l.seconds);
return { description: l.description.slice(0, 300), quantity: (q / 100).toFixed(2), unitPrice: fromMinor(l.rate, currency), amount: Math.floor((q * l.rate + 50) / 100) };
})
.filter((l) => Number(l.quantity) > 0);
}
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