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/Reports.tsx
// Reports for any range: totals, billable hours and amounts, hours over time, and breakdowns by
// project, client, tag and person. Export as CSV, or turn billable time into an invoice.
import { useEffect, useMemo, useState } from "react";
import {
  summarize, entriesCsv, clock, hm, spoken, hours, formatMoney, dayOf, weekOf, addDays, addMonths, monthOf, daysBetween,
  periodRange, durationOf, rowAmount, type Row, type Group, type Period,
} from "../functions/lib/time";
import { useStore, loadRows, dayStart, today, projectOf, clientOf, usePendingVersion } from "./lib/data";
import { explain } from "./lib/bounded";
import { toast, download, Left, Right } from "./ui";
import { InvoiceModal } from "./Invoice";

type Preset = Period | "custom";
const PRESETS: [Preset, string][] = [
  ["today", "Today"], ["yesterday", "Yesterday"], ["week", "This week"], ["last-week", "Last week"],
  ["month", "This month"], ["last-month", "Last month"], ["year", "This year"], ["custom", "Custom range"],
];
type Tab = "projects" | "clients" | "tags" | "people";
const fmtDay = (day: string, opts: Intl.DateTimeFormatOptions) => new Date(`${day}T12:00:00Z`).toLocaleDateString(undefined, { ...opts, timeZone: "UTC" });

export function Reports() {
  const s = useStore();
  const todayDay = today(s);
  const [preset, setPreset] = useState<Preset>("week");
  const [showDates, setShowDates] = useState(false);
  const [range, setRange] = useState(() => periodRange("week", todayDay, s.ws.weekStart));
  const [rows, setRows] = useState<Row[] | null>(null);
  const [error, setError] = useState("");
  const [project, setProject] = useState("all");
  const [client, setClient] = useState("all");
  const [billable, setBillable] = useState<"all" | "yes" | "no">("all");
  const [tab, setTab] = useState<Tab>("projects");
  const [invoice, setInvoice] = useState(false);
  const [nonce, setNonce] = useState(0);
  const version = usePendingVersion();

  useEffect(() => {
    let dead = false;
    setError("");
    loadRows(s, dayStart(s, range.from), dayStart(s, addDays(range.to, 1)))
      .then((r) => { if (!dead) setRows(r); })
      .catch((err) => { if (!dead) setError(explain(err)); });
    return () => { dead = true; };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [range.from, range.to, nonce, version, s.id, s.projects.length]);
  useEffect(() => {
    const onFocus = () => setNonce((n) => n + 1);
    window.addEventListener("focus", onFocus);
    return () => window.removeEventListener("focus", onFocus);
  }, []);

  const filtered = useMemo(() => (rows || []).filter((r) => {
    if (project !== "all" && r.project !== project) return false;
    if (client !== "all" && (projectOf(s, r.project)?.client || "") !== client) return false;
    if (billable === "yes" && !r.billable) return false;
    if (billable === "no" && r.billable) return false;
    return true;
  }), [rows, project, client, billable, s]);
  const sum = useMemo(() => summarize(filtered, s.projects, s.clients, s.ws.rate, s.tz), [filtered, s]);
  const hasTeam = (rows || []).some((r) => r.who);

  const choose = (p: Preset) => {
    setPreset(p);
    setShowDates(p === "custom");
    if (p !== "custom") setRange(periodRange(p, todayDay, s.ws.weekStart));
  };
  const shift = (n: number) => {
    const { from, to } = range;
    if (from.slice(5) === "01-01" && to.slice(5) === "12-31" && from.slice(0, 4) === to.slice(0, 4)) {
      const y = Number(from.slice(0, 4)) + n;
      setRange({ from: `${y}-01-01`, to: `${y}-12-31` });
    } else if (from === monthOf(from) && to === addDays(addMonths(from, 1), -1)) {
      const f = addMonths(from, n);
      setRange({ from: f, to: addDays(addMonths(f, 1), -1) });
    } else {
      const len = daysBetween(from, to) + 1;
      setRange({ from: addDays(from, n * len), to: addDays(to, n * len) });
    }
    setPreset("custom");
  };
  const exportCsv = () => {
    if (!filtered.length) return toast("No time in this range to export.");
    download(`time-${range.from}-to-${range.to}.csv`, entriesCsv(filtered, s.projects, s.clients, { ...s.ws, tz: s.tz }));
  };
  const cur = s.ws.currency;
  const label = range.from === range.to ? fmtDay(range.from, { weekday: "short", month: "short", day: "numeric", year: "numeric" })
    : `${fmtDay(range.from, { month: "short", day: "numeric" })} to ${fmtDay(range.to, { month: "short", day: "numeric", year: "numeric" })}`;
  const groups: Record<Tab, Group[]> = { projects: sum.projects, clients: sum.clients, tags: sum.tags, people: sum.people };
  const tabs: [Tab, string][] = [["projects", "Projects"], ["clients", "Clients"], ["tags", "Tags"], ...(hasTeam ? [["people", "People"] as [Tab, string]] : [])];

  return (
    <div className="page">
      <div className="page-head">
        <h1 className="page-title">Reports</h1>
        <div className="row report-actions">
          <button type="button" className="btn btn-line btn-sm" onClick={exportCsv}>Export CSV</button>
          <button type="button" className="btn btn-sm" onClick={() => setInvoice(true)}>Create an invoice</button>
        </div>
      </div>

      <div className="filters">
        <div className="range-nav">
          <button type="button" className="icon-btn" onClick={() => shift(-1)} aria-label="Earlier"><Left /></button>
          <select className="select select-inline" value={preset} onChange={(e) => choose(e.target.value as Preset)} aria-label="Range">
            {PRESETS.map(([v, l]) => <option key={v} value={v}>{l}</option>)}
          </select>
          <button type="button" className="icon-btn" onClick={() => shift(1)} aria-label="Later"><Right /></button>
        </div>
        {showDates && (
          <div className="custom-range">
            <input className="input input-sm" type="date" value={range.from} aria-label="From" onChange={(e) => e.target.value && setRange({ from: e.target.value, to: e.target.value > range.to ? e.target.value : range.to })} />
            <span className="muted">to</span>
            <input className="input input-sm" type="date" value={range.to} aria-label="To" onChange={(e) => e.target.value && setRange({ from: e.target.value < range.from ? e.target.value : range.from, to: e.target.value })} />
          </div>
        )}
        <select className="select select-inline" value={project} onChange={(e) => setProject(e.target.value)} aria-label="Project">
          <option value="all">All projects</option>
          <option value="">No project</option>
          {s.projects.map((p) => <option key={p.id} value={p.id}>{p.name}{p.archived ? " (archived)" : ""}</option>)}
        </select>
        <select className="select select-inline" value={client} onChange={(e) => setClient(e.target.value)} aria-label="Client">
          <option value="all">All clients</option>
          <option value="">No client</option>
          {s.clients.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
        </select>
        <select className="select select-inline" value={billable} onChange={(e) => setBillable(e.target.value as "all" | "yes" | "no")} aria-label="Billable">
          <option value="all">Billable and not</option>
          <option value="yes">Billable only</option>
          <option value="no">Not billable</option>
        </select>
      </div>
      <p className="range-label">{label}</p>

      {error ? <p className="notice notice-error">{error}</p> : !rows ? <div className="loading"><span className="spinner" /> Adding up your time...</div> : (
        <>
          <div className="stats">
            <div className="stat-card"><span>Total</span><strong>{spoken(sum.seconds)}</strong><small>{hours(sum.seconds)} hours, {sum.entries} {sum.entries === 1 ? "entry" : "entries"}</small></div>
            <div className="stat-card"><span>Billable</span><strong>{spoken(sum.billableSeconds)}</strong><small>{sum.seconds ? Math.round((sum.billableSeconds / sum.seconds) * 100) : 0}% of the time</small></div>
            <div className="stat-card"><span>Amount</span><strong>{formatMoney(sum.amount, cur)}</strong><small>{s.ws.rate || s.projects.some((p) => p.rate) ? "Billable hours x rate" : "Set a rate in Settings or on a project"}</small></div>
          </div>

          {sum.entries > 0 ? (
            <>
              <HoursChart rows={filtered} from={range.from} to={range.to} />
              <div className="tabs" role="tablist" aria-label="Group by">
                {tabs.map(([k, l]) => <button key={k} type="button" role="tab" aria-selected={tab === k} onClick={() => setTab(k)}>{l}</button>)}
              </div>
              <Breakdown groups={groups[tab]} total={sum.seconds} tab={tab} />
              <EntriesTable rows={filtered} team={hasTeam} />
            </>
          ) : (
            <div className="empty">
              <img className="art empty-art" src="/art/card-time.webp" alt="" width="200" height="155" />
              <h2 className="h3">No time in this range</h2>
              <p className="muted">Pick another range, or track some time first.</p>
            </div>
          )}
        </>
      )}
      {invoice && rows && <InvoiceModal rows={filtered} from={range.from} to={range.to} client={client === "all" ? null : client} onClose={() => setInvoice(false)} />}
    </div>
  );
}

function bucketOf(day: string, mode: "day" | "week" | "month", weekStart: number) {
  return mode === "day" ? day : mode === "week" ? weekOf(day, weekStart) : monthOf(day);
}

function HoursChart({ rows, from, to }: { rows: Row[]; from: string; to: string }) {
  const s = useStore();
  const span = daysBetween(from, to) + 1;
  const mode: "day" | "week" | "month" = span <= 35 ? "day" : span <= 190 ? "week" : "month";
  const buckets = useMemo(() => {
    const keys: string[] = [];
    let k = bucketOf(from, mode, s.ws.weekStart);
    while (k <= to) {
      keys.push(k);
      k = mode === "day" ? addDays(k, 1) : mode === "week" ? addDays(k, 7) : addMonths(k, 1);
    }
    const map = new Map<string, Map<string, number>>(keys.map((x) => [x, new Map()]));
    for (const r of rows) {
      const b = map.get(bucketOf(dayOf(r.start, s.tz), mode, s.ws.weekStart));
      if (b) b.set(r.project, (b.get(r.project) || 0) + durationOf(r));
    }
    return keys.map((key) => {
      const byProject = [...(map.get(key) || new Map()).entries()].sort((a, b) => b[1] - a[1]);
      return { key, seconds: byProject.reduce((a, [, v]) => a + v, 0), byProject };
    });
  }, [rows, from, to, mode, s]);
  const max = Math.max(3600, ...buckets.map((b) => b.seconds));
  const top = Math.ceil(max / 3600 / (max > 36000 ? 5 : 1)) * (max > 36000 ? 5 : 1) * 3600;
  const label = (key: string) => mode === "month" ? fmtDay(key, { month: "short" }) : mode === "week" ? fmtDay(key, { month: "short", day: "numeric" }) : span <= 7 ? fmtDay(key, { weekday: "short" }) : fmtDay(key, { day: "numeric" });
  const every = Math.ceil(buckets.length / 12);
  const name = (id: string) => projectOf(s, id)?.name ?? "No project";
  return (
    <figure className="chart card" aria-label={`Hours per ${mode}`}>
      <figcaption className="chart-title">Hours per {mode}</figcaption>
      <div className="chart-plot">
        <div className="chart-grid" aria-hidden="true">
          {[1, 0.5, 0].map((f) => <span key={f} style={{ bottom: `${f * 100}%` }}><em>{Math.round((top * f) / 3600)}h</em></span>)}
        </div>
        <div className="chart-bars">
          {buckets.map((b, i) => (
            <div key={b.key} className="bar-slot">
              <button type="button" className="bar-hit" aria-label={`${label(b.key)}: ${spoken(b.seconds)}`}>
                <span className="bar" style={{ height: `${(b.seconds / top) * 100}%` }} />
                <span className="bar-tip" role="tooltip">
                  <strong>{mode === "day" ? fmtDay(b.key, { weekday: "short", month: "short", day: "numeric" }) : mode === "week" ? `Week of ${fmtDay(b.key, { month: "short", day: "numeric" })}` : fmtDay(b.key, { month: "long", year: "numeric" })}: {hm(b.seconds)}</strong>
                  {b.byProject.slice(0, 5).map(([p, sec]) => <span key={p}><i style={{ background: projectOf(s, p)?.color ?? "#8a8e96" }} />{name(p)} {hm(sec)}</span>)}
                </span>
              </button>
              <span className="bar-label" aria-hidden="true">{i % every === 0 ? label(b.key) : ""}</span>
            </div>
          ))}
        </div>
      </div>
      <table className="sr-only">
        <caption>Hours per {mode}</caption>
        <tbody>{buckets.map((b) => <tr key={b.key}><th scope="row">{b.key}</th><td>{hm(b.seconds)}</td></tr>)}</tbody>
      </table>
    </figure>
  );
}

function Breakdown({ groups, total, tab }: { groups: Group[]; total: number; tab: Tab }) {
  const s = useStore();
  const cur = s.ws.currency;
  return (
    <div className="table-wrap">
      <table className="table breakdown">
        <thead><tr><th scope="col">{tab === "projects" ? "Project" : tab === "clients" ? "Client" : tab === "tags" ? "Tag" : "Person"}</th><th scope="col" className="num">Hours</th><th scope="col" className="share-col"><span className="sr-only">Share</span></th><th scope="col" className="num hide-sm">Billable</th><th scope="col" className="num">Amount</th></tr></thead>
        <tbody>
          {groups.map((g) => {
            const pct = total ? (g.seconds / total) * 100 : 0;
            const client = tab === "projects" ? clientOf(s, projectOf(s, g.key)?.client ?? "")?.name : undefined;
            return (
              <tr key={g.key || "none"}>
                <th scope="row">
                  <span className="proj">{tab === "projects" && <span className="dot" style={{ background: g.color }} />}<span className="proj-name">{g.name}</span>{client && <span className="proj-client">{client}</span>}</span>
                </th>
                <td className="num">{hm(g.seconds)}</td>
                <td className="share-col"><span className="share-bar"><span style={{ width: `${pct}%`, background: tab === "projects" ? g.color : "var(--cobalt)" }} /></span><small>{Math.round(pct)}%</small></td>
                <td className="num hide-sm">{hm(g.billableSeconds)}</td>
                <td className="num">{g.amount ? formatMoney(g.amount, cur) : "-"}</td>
              </tr>
            );
          })}
        </tbody>
      </table>
      {tab === "tags" && <p className="hint table-note">An entry with several tags counts under each tag.</p>}
    </div>
  );
}

function EntriesTable({ rows, team }: { rows: Row[]; team: boolean }) {
  const s = useStore();
  const projects = useMemo(() => new Map(s.projects.map((p) => [p.id, p])), [s.projects]);
  const sorted = useMemo(() => [...rows].sort((a, b) => b.start - a.start), [rows]);
  const shown = sorted.slice(0, 500);
  return (
    <details className="entries-detail">
      <summary>All entries ({rows.length})</summary>
      <div className="table-wrap">
        <table className="table detail">
          <thead><tr><th scope="col">Date</th><th scope="col">Description</th><th scope="col" className="hide-sm">Project</th>{team && <th scope="col" className="hide-sm">Person</th>}<th scope="col" className="num">Time</th><th scope="col" className="num hide-sm">Amount</th></tr></thead>
          <tbody>
            {shown.map((r) => {
              const amt = rowAmount(r, projects, s.ws.rate);
              return (
                <tr key={`${r.who}|${r.id}`}>
                  <td className="nowrap">{fmtDay(dayOf(r.start, s.tz), { month: "short", day: "numeric" })}</td>
                  <td className="clip">{r.description || <span className="muted">No description</span>}</td>
                  <td className="hide-sm">{projectOf(s, r.project)?.name ?? <span className="muted">No project</span>}</td>
                  {team && <td className="hide-sm">{r.who || "You"}</td>}
                  <td className="num">{clock(durationOf(r))}</td>
                  <td className="num hide-sm">{amt ? formatMoney(amt, s.ws.currency) : "-"}</td>
                </tr>
              );
            })}
          </tbody>
        </table>
      </div>
      {rows.length > shown.length && <p className="hint table-note">Showing the newest 500. Export CSV for all {rows.length}.</p>}
    </details>
  );
}

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