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