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/Timesheet.tsx
// The week as a timesheet: a row per project, a column per day. Type a total into a cell and Time adds,
// changes or removes the entry behind it. On phones, one day at a time.
import { useMemo, useState } from "react";
import { hm, dayOf, weekOf, addDays, parseDuration, zoned, durationOf, MAX_SECONDS, type Entry } from "../functions/lib/time";
import { useStore, useEntries, saveEntry, deleteEntry, restoreEntry, dayStart, today, projectOf } from "./lib/data";
import { explain } from "./lib/bounded";
import { toast, Left, Right } from "./ui";
import { ProjectLabel, ProjectPicker } from "./Pickers";
const short = (day: string, opts: Intl.DateTimeFormatOptions) => new Date(`${day}T12:00:00Z`).toLocaleDateString(undefined, { ...opts, timeZone: "UTC" });
export function Timesheet() {
const s = useStore();
const todayDay = today(s);
const [week, setWeek] = useState(() => weekOf(todayDay, s.ws.weekStart));
const [added, setAdded] = useState<string[]>([]);
const [focusDay, setFocusDay] = useState(todayDay);
const days = useMemo(() => Array.from({ length: 7 }, (_, i) => addDays(week, i)), [week]);
const entries = useEntries(s, dayStart(s, week), dayStart(s, addDays(week, 7)));
const cells = useMemo(() => {
const map = new Map<string, Entry[]>();
for (const e of entries || []) {
const k = `${e.project}|${dayOf(e.start, s.tz)}`;
map.set(k, [...(map.get(k) || []), e]);
}
return map;
}, [entries, s.tz]);
const rows = useMemo(() => {
const ids = new Set<string>([...(entries || []).map((e) => e.project), ...added]);
return [...ids].sort((a, b) => (a === "" ? 1 : b === "" ? -1 : (projectOf(s, a)?.name ?? "").localeCompare(projectOf(s, b)?.name ?? "")));
}, [entries, added, s]);
const total = (list: Entry[] | undefined) => (list || []).reduce((a, e) => a + durationOf(e), 0);
const dayTotal = (day: string) => rows.reduce((a, p) => a + total(cells.get(`${p}|${day}`)), 0);
const rowTotal = (p: string) => days.reduce((a, d) => a + total(cells.get(`${p}|${d}`)), 0);
const weekTotal = rows.reduce((a, p) => a + rowTotal(p), 0);
const shift = (n: number) => { const w = addDays(week, 7 * n); setWeek(w); setFocusDay(addDays(w, days.indexOf(focusDay) >= 0 ? days.indexOf(focusDay) : 0)); };
const setCell = async (project: string, day: string, text: string): Promise<boolean> => {
const list = cells.get(`${project}|${day}`) || [];
const current = total(list);
const sec = text.trim() === "" ? 0 : parseDuration(text);
if (sec == null) { toast("Enter time like 1:30, 1.5 or 90m."); return false; }
if (sec === current) return true;
if (sec > MAX_SECONDS) { toast("That is more than 1,000 hours."); return false; }
const p = projectOf(s, project);
try {
if (!list.length) {
const start = zoned(day, 9 * 60, s.tz);
await saveEntry(s, { description: "", project, tags: "[]", billable: Boolean(p?.billable), start, end: start + sec });
} else if (list.length === 1) {
const e = list[0];
if (sec === 0) {
await deleteEntry(s, e);
toast("Entry deleted", { label: "Undo", run: () => void restoreEntry(s, e).catch((err) => toast(explain(err))) });
} else {
const { id: _id, ...rest } = e;
await saveEntry(s, { ...rest, end: e.start + sec }, e);
}
} else if (sec > current) {
const start = Math.max(...list.map((e) => e.end));
await saveEntry(s, { description: "", project, tags: "[]", billable: Boolean(p?.billable), start, end: start + sec - current });
} else {
toast(`This day has ${list.length} entries for this project. Change them on the Timer page.`);
return false;
}
return true;
} catch (err) {
toast(explain(err));
return false;
}
};
const range = `${short(days[0], { month: "short", day: "numeric" })} to ${short(days[6], { month: "short", day: "numeric", year: "numeric" })}`;
const thisWeek = weekOf(todayDay, s.ws.weekStart);
return (
<div className="page">
<div className="page-head">
<h1 className="page-title">Timesheet</h1>
<div className="week-nav">
<button type="button" className="icon-btn" onClick={() => shift(-1)} aria-label="Previous week"><Left /></button>
<span className="week-range">{range}</span>
<button type="button" className="icon-btn" onClick={() => shift(1)} aria-label="Next week"><Right /></button>
{week !== thisWeek && <button type="button" className="btn btn-line btn-sm" onClick={() => { setWeek(thisWeek); setFocusDay(todayDay); }}>This week</button>}
</div>
</div>
<p className="muted small sheet-total">Week total <strong>{hm(weekTotal)}</strong></p>
{!entries ? <div className="loading"><span className="spinner" /> Loading the week...</div> : (
<>
<div className="sheet-wide table-wrap">
<table className="table sheet">
<thead>
<tr>
<th scope="col">Project</th>
{days.map((d) => <th key={d} scope="col" className={`num${d === todayDay ? " is-today" : ""}`}>{short(d, { weekday: "short" })}<span>{short(d, { day: "numeric" })}</span></th>)}
<th scope="col" className="num">Total</th>
</tr>
</thead>
<tbody>
{rows.map((p) => (
<tr key={p || "none"}>
<th scope="row"><ProjectLabel id={p} /></th>
{days.map((d) => <td key={d} className="num"><Cell value={total(cells.get(`${p}|${d}`))} count={(cells.get(`${p}|${d}`) || []).length} label={`${projectOf(s, p)?.name ?? "No project"}, ${short(d, { weekday: "long" })}`} onSave={(v) => setCell(p, d, v)} /></td>)}
<td className="num strong">{hm(rowTotal(p))}</td>
</tr>
))}
<tr className="sheet-add">
<th scope="row" colSpan={9}><AddRow onAdd={(id) => setAdded((a) => [...a, id])} exclude={rows} /></th>
</tr>
</tbody>
<tfoot>
<tr>
<th scope="row">Total</th>
{days.map((d) => <td key={d} className="num">{hm(dayTotal(d))}</td>)}
<td className="num strong">{hm(weekTotal)}</td>
</tr>
</tfoot>
</table>
</div>
<div className="sheet-narrow">
<div className="day-tabs" role="tablist" aria-label="Day">
{days.map((d) => (
<button key={d} type="button" role="tab" aria-selected={d === focusDay} className={d === todayDay ? "is-today" : ""} onClick={() => setFocusDay(d)}>
<span>{short(d, { weekday: "narrow" })}</span><strong>{short(d, { day: "numeric" })}</strong><small>{hm(dayTotal(d))}</small>
</button>
))}
</div>
<ul className="sheet-list">
{rows.map((p) => (
<li key={p || "none"}>
<ProjectLabel id={p} />
<Cell value={total(cells.get(`${p}|${focusDay}`))} count={(cells.get(`${p}|${focusDay}`) || []).length} label={`${projectOf(s, p)?.name ?? "No project"}, ${short(focusDay, { weekday: "long" })}`} onSave={(v) => setCell(p, focusDay, v)} />
</li>
))}
<li className="sheet-add"><AddRow onAdd={(id) => setAdded((a) => [...a, id])} exclude={rows} /></li>
</ul>
<p className="muted small">{short(focusDay, { weekday: "long", month: "long", day: "numeric" })}: <strong>{hm(dayTotal(focusDay))}</strong></p>
</div>
<p className="hint">Type a day's total, like 1:30 or 1.5. An empty cell gets an entry starting at 9:00. Clearing a cell deletes its entry.</p>
</>
)}
</div>
);
}
function Cell({ value, count, label, onSave }: { value: number; count: number; label: string; onSave: (v: string) => Promise<boolean> }) {
const [text, setText] = useState<string | null>(null);
const shown = text ?? (value ? hm(value) : "");
const commit = async () => {
if (text == null) return;
await onSave(text);
setText(null);
};
return (
<input
className={`cell${count > 1 ? " multi" : ""}`}
value={shown}
inputMode="decimal"
placeholder="0:00"
aria-label={label}
title={count > 1 ? `${count} entries` : undefined}
onFocus={(e) => e.currentTarget.select()}
onChange={(e) => setText(e.target.value)}
onBlur={() => void commit()}
onKeyDown={(e) => { if (e.key === "Enter") (e.currentTarget as HTMLInputElement).blur(); if (e.key === "Escape") { setText(null); (e.currentTarget as HTMLInputElement).blur(); } }}
/>
);
}
function AddRow({ onAdd, exclude }: { onAdd: (id: string) => void; exclude: string[] }) {
return (
<div className="add-row">
<ProjectPicker value="" onChange={(id) => { if (!exclude.includes(id)) onAdd(id); }} />
<span className="muted small">Add a project row</span>
</div>
);
}
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