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
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
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