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/TimerPage.tsx
// The timer: what you are working on, project, tags and billable, one tap to start or stop. Below it,
// your recent time by day; tap an entry to change it, or the play button to continue it.
import { useEffect, useMemo, useState } from "react";
import { clock, spoken, dayOf, weekOf, addDays, parseTimeOfDay, zoned, durationOf, type Entry } from "../functions/lib/time";
import { useStore, useEntries, startTimer, stopTimer, updateTimer, discardTimer, dayStart, today, timeText, tagNamesOf, nowSec } from "./lib/data";
import { explain } from "./lib/bounded";
import { toast, Popover, Play, Stop, Plus } from "./ui";
import { ProjectPicker, TagPicker, BillableToggle, ProjectLabel, currencySymbol } from "./Pickers";
import { get } from "@bounded-sh/client";
import { EntryForm } from "./EntryForm";
import { useNow } from "./lib/hooks";
import { takeLandingStart } from "./Start";
export function TimerBar() {
const s = useStore();
const t = s.timer;
const running = Boolean(t?.start);
const now = useNow(running);
const [draft, setDraft] = useState({ description: "", project: "", tags: "[]", billable: false });
// While the running timer's description is being typed, the typed text shows; otherwise the timer's own.
const [typing, setTyping] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [startOpen, setStartOpen] = useState(false);
const desc = typing ?? t?.description ?? "";
const fields = running && t ? { description: desc, project: t.project, tags: t.tags, billable: Boolean(t.billable) } : draft;
const change = (p: Partial<typeof draft>) => {
if (running) void updateTimer(s, p).catch((e) => toast(explain(e)));
else setDraft((d) => ({ ...d, ...p }));
};
const saveDesc = () => {
if (running && t && typing != null && typing.trim() !== t.description) change({ description: typing.trim() });
setTyping(null);
};
const start = async () => {
setBusy(true);
try {
await startTimer(s, { ...draft, description: draft.description.trim() });
setDraft({ description: "", project: "", tags: "[]", billable: false });
} catch (err) { toast(explain(err)); }
setBusy(false);
};
const stop = async () => {
setBusy(true);
try {
// A description still being typed goes into the saved entry.
const typed = typing != null && t ? { ...s, timer: { ...t, description: typing.trim() } } : s;
setTyping(null);
await stopTimer(typed);
} catch (err) { toast(explain(err)); }
setBusy(false);
};
return (
<section className={`timer-bar card${running ? " running" : ""}`} aria-label="Timer">
<input
className="timer-input"
placeholder="What are you working on?"
aria-label="What are you working on?"
value={running ? desc : draft.description}
maxLength={1000}
onChange={(e) => (running ? setTyping(e.target.value) : setDraft({ ...draft, description: e.target.value }))}
onBlur={saveDesc}
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); if (running) saveDesc(); else void start(); } }}
/>
<div className="timer-controls">
<ProjectPicker value={fields.project} onChange={(project, billable) => change({ project, billable: project ? billable : fields.billable })} />
<TagPicker value={fields.tags} onChange={(tags) => change({ tags })} />
<BillableToggle value={fields.billable} onChange={(billable) => change({ billable })} />
<span className="grow" />
{running && t ? (
<div className="picker">
<button type="button" className="timer-clock" onClick={() => setStartOpen((o) => !o)} aria-label={`Running for ${clock(now - t.start)}. Change start time`}>{clock(now - t.start)}</button>
<Popover open={startOpen} onClose={() => setStartOpen(false)} align="right" label="Change start time">
<StartEditor onDone={() => setStartOpen(false)} />
</Popover>
</div>
) : (
<span className="timer-clock idle" aria-hidden="true">0:00:00</span>
)}
{running
? <button type="button" className="btn timer-go stop" onClick={stop} disabled={busy}><Stop size={16} /> Stop</button>
: <button type="button" className="btn timer-go" onClick={start} disabled={busy}><Play size={16} /> Start</button>}
</div>
</section>
);
}
function StartEditor({ onDone }: { onDone: () => void }) {
const s = useStore();
const t = s.timer!;
const [v, setV] = useState(timeText(s, t.start));
const apply = async () => {
const m = parseTimeOfDay(v);
if (m == null) return toast("Enter a time like 09:15.");
let start = zoned(dayOf(t.start, s.tz), m, s.tz);
if (start > nowSec()) start -= 86400;
try { await updateTimer(s, { start }); onDone(); } catch (err) { toast(explain(err)); }
};
const discard = async () => {
try { await discardTimer(s); onDone(); toast("Timer discarded"); } catch (err) { toast(explain(err)); }
};
return (
<div className="stack" style={{ ["--gap" as string]: "10px" }}>
<label className="field"><span>Started at</span><input className="input" type="time" value={v} onChange={(e) => setV(e.target.value)} autoFocus /></label>
<div className="row"><button type="button" className="btn btn-sm" onClick={apply}>Save</button><button type="button" className="btn btn-sm btn-quiet btn-danger" onClick={discard}>Discard timer</button></div>
</div>
);
}
function dayLabel(day: string, todayDay: string): string {
if (day === todayDay) return "Today";
if (day === addDays(todayDay, -1)) return "Yesterday";
return new Date(`${day}T12:00:00Z`).toLocaleDateString(undefined, { weekday: "long", month: "short", day: "numeric", timeZone: "UTC" });
}
export function EntryRow({ e, onEdit }: { e: Entry; onEdit: (e: Entry) => void }) {
const s = useStore();
const tags = tagNamesOf(s, e.tags);
const cont = async () => {
try {
await startTimer(s, { description: e.description, project: e.project, tags: e.tags, billable: Boolean(e.billable) });
toast("Timer started");
} catch (err) { toast(explain(err)); }
};
return (
<li className="entry">
<button type="button" className="entry-main" onClick={() => onEdit(e)} aria-label={`Edit ${e.description || "entry"}, ${spoken(durationOf(e))}`}>
<span className="entry-what">
<span className={`entry-desc${e.description ? "" : " muted"}`}>{e.description || "No description"}</span>
<span className="entry-meta">
<ProjectLabel id={e.project} />
{tags.map((t) => <span key={t} className="tag-chip">{t}</span>)}
</span>
</span>
<span className="entry-side">
{e.billable && <span className="bill-mark" title="Billable">{currencySymbol(s.ws.currency)}</span>}
<span className="entry-span">{timeText(s, e.start)} to {timeText(s, e.end)}</span>
<span className="entry-dur">{clock(durationOf(e))}</span>
</span>
</button>
<button type="button" className="icon-btn entry-play" onClick={cont} aria-label={`Continue ${e.description || "this entry"}`}><Play size={16} /></button>
</li>
);
}
export function TimerPage() {
const s = useStore();
const [weeks, setWeeks] = useState(4);
const [editing, setEditing] = useState<Entry | "new" | null>(null);
const todayDay = today(s);
const wk = weekOf(todayDay, s.ws.weekStart);
const from = dayStart(s, addDays(wk, -7 * (weeks - 1)));
const to = dayStart(s, addDays(todayDay, 400));
const entries = useEntries(s, from, to);
const [older, setOlder] = useState(false);
useEffect(() => {
let dead = false;
get(`${s.base}/entries`, { filter: { start: { $lt: from } }, limit: 1 }).then((r) => { if (!dead) setOlder(Boolean(r?.data?.length)); }, () => null);
return () => { dead = true; };
}, [s.base, from]);
const running = Boolean(s.timer?.start);
const now = useNow(running);
useEffect(() => {
const start = takeLandingStart();
if (start == null || s.timer) return;
void startTimer(s, { description: start, project: "", tags: "[]", billable: false }).catch((err) => toast(explain(err)));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const days = useMemo(() => {
const map = new Map<string, Entry[]>();
for (const e of entries || []) {
const d = dayOf(e.start, s.tz);
map.set(d, [...(map.get(d) || []), e]);
}
return [...map.entries()].sort(([a], [b]) => b.localeCompare(a));
}, [entries, s.tz]);
const live = running && s.timer ? now - s.timer.start : 0;
const sumFrom = (day: string) => (entries || []).filter((e) => dayOf(e.start, s.tz) >= day).reduce((a, e) => a + durationOf(e), 0);
const todayTotal = (entries || []).filter((e) => dayOf(e.start, s.tz) === todayDay).reduce((a, e) => a + durationOf(e), 0) + live;
const weekTotal = sumFrom(wk) + live;
return (
<div className="page">
<TimerBar />
<div className="timer-sub">
<button type="button" className="btn btn-line btn-sm" onClick={() => setEditing("new")}><Plus size={16} /> Add time manually</button>
<p className="totals"><span>Today <strong>{clock(todayTotal)}</strong></span><span>This week <strong>{clock(weekTotal)}</strong></span></p>
</div>
{!entries ? (
<div className="loading"><span className="spinner" /> Loading your time...</div>
) : !days.length ? (
<div className="empty">
<img className="art empty-art" src="/art/card-time.webp" alt="" width="200" height="155" />
<h2 className="h3">No time yet</h2>
<p className="muted">Type what you are working on and press Start. Or add time you already worked.</p>
</div>
) : (
<div className="days">
{days.map(([day, list]) => (
<section key={day} className="day">
<h2 className="day-label"><span>{dayLabel(day, todayDay)}</span><span>{clock(list.reduce((a, e) => a + durationOf(e), 0))}</span></h2>
<ul className="entry-list">{list.map((e) => <EntryRow key={e.id} e={e} onEdit={setEditing} />)}</ul>
</section>
))}
{older && <button type="button" className="btn btn-line btn-block" onClick={() => setWeeks((w) => w + 4)}>Show older time</button>}
</div>
)}
{editing && <EntryForm entry={editing === "new" ? undefined : editing} onClose={() => setEditing(null)} />}
</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