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/Settings.tsx
// Settings: keeping time on every device (email sign-in), your name, currency, default rate and week
// start, workspace keys for AI assistants, installing the app, and exporting everything.
import { useEffect, useState } from "react";
import { useQuery } from "@bounded-sh/client";
import { CURRENCIES, toMinor, fromMinor, entriesCsv, MAX_RATE, type Entry } from "../functions/lib/time";
import { SITE_URL, APP_ID } from "../functions/lib/config";
import { useStore, updateWorkspace, createKey, revokeKey, toRow, type ApiKey } from "./lib/data";
import { keepOnAllDevices, listAll } from "./lib/session";
import { explain, signOut, SERVED_APP_ID } from "./lib/bounded";
import { Modal, toast, copy, download } from "./ui";
import { currencySymbol } from "./Pickers";
export function Settings() {
return (
<div className="page settings">
<h1 className="page-title">Settings</h1>
<Account />
<Details />
<ApiKeys />
<Install />
<Export />
<section className="card set-card">
<h2 className="h3">About Time</h2>
<p className="muted small">Time is a free time tracker from <a href="https://openapps.free/">OpenApps</a>, with no paid plan, no ads and no third-party trackers, and nothing is sold. The hosting counts visits. It is an OpenApp: its <a href="/__bounded/source">source is public</a> and it has its own agent. Assistants can read <a href="/llms.txt">llms.txt</a>.</p>
</section>
</div>
);
}
function Account() {
const s = useStore();
const m = s.session.me;
const [busy, setBusy] = useState(false);
if (!m.guest) {
const out = async () => {
if (!confirm("Sign out? Your time stays in your account. This browser starts fresh until you sign in again.")) return;
setBusy(true);
try {
try { localStorage.removeItem("time:timer"); } catch { /* ignore */ }
await signOut();
location.href = "/timer";
} catch (err) { toast(explain(err)); setBusy(false); }
};
return (
<section className="card set-card">
<h2 className="h3">Your account</h2>
<p className="muted small">Signed in{m.email ? ` as ${m.email}` : ""}. Your time is in your account, on every device you sign in on.</p>
<button type="button" className="btn btn-line btn-sm self-start" disabled={busy} onClick={out}>{busy ? "Signing out..." : "Sign out"}</button>
</section>
);
}
const keep = async () => {
setBusy(true);
try { await keepOnAllDevices(s.session); } catch (err) { toast(explain(err)); setBusy(false); }
};
return (
<section className="card set-card account-card">
<h2 className="h3">Keep my time on all devices</h2>
<p className="muted small">Your time is saved in this browser. Sign in with your email to have it on your phone, laptop and anywhere else. No password, and it stays free. If you already use Time on another device, this browser's time is added to it.</p>
<button type="button" className="btn btn-sm self-start" disabled={busy} onClick={keep}>{busy ? "Opening sign-in..." : "Continue with email"}</button>
</section>
);
}
function Details() {
const s = useStore();
const [name, setName] = useState(s.ws.name);
const [currency, setCurrency] = useState(s.ws.currency);
const [rate, setRate] = useState(s.ws.rate ? fromMinor(s.ws.rate, s.ws.currency) : "");
const [weekStart, setWeekStart] = useState(s.ws.weekStart);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
useEffect(() => { setName(s.ws.name); setCurrency(s.ws.currency); setRate(s.ws.rate ? fromMinor(s.ws.rate, s.ws.currency) : ""); setWeekStart(s.ws.weekStart); }, [s.ws]);
const save = async () => {
setError("");
const r = rate.trim() === "" ? 0 : toMinor(rate, currency);
if (!Number.isFinite(r) || r > MAX_RATE) return setError(`Enter the rate as an amount in ${currency}, like 80 or 80.50.`);
setBusy(true);
try {
await updateWorkspace(s, { name: name.trim().slice(0, 80), currency, weekStart, rate: r });
toast("Settings saved");
} catch (err) { setError(explain(err)); }
setBusy(false);
};
return (
<section className="card set-card">
<h2 className="h3">Your details</h2>
<form className="stack" onSubmit={(e) => { e.preventDefault(); void save(); }}>
<label className="field"><span>Your name or business</span><input className="input" value={name} maxLength={80} onChange={(e) => setName(e.target.value)} placeholder="Sam Rivera Design" /><small>Goes on invoices, and is shown to the owner of a project you join.</small></label>
<div className="two">
<label className="field"><span>Currency</span>
<select className="select" value={currency} onChange={(e) => setCurrency(e.target.value)}>{CURRENCIES.map((c) => <option key={c} value={c}>{c}</option>)}</select>
</label>
<label className="field"><span>Default hourly rate</span>
<span className="money-input"><span>{currencySymbol(currency)}</span><input className="input" inputMode="decimal" value={rate} onChange={(e) => setRate(e.target.value)} placeholder="0" /></span>
</label>
</div>
<label className="field"><span>Week starts on</span>
<select className="select" value={weekStart} onChange={(e) => setWeekStart(Number(e.target.value))}><option value={1}>Monday</option><option value={0}>Sunday</option></select>
</label>
<p className="hint">One currency per workspace. Changing it keeps the numbers (80 USD an hour becomes 80 EUR an hour); nothing is converted. Days follow this device's time zone ({s.tz}).</p>
{error && <p className="notice notice-error" role="alert">{error}</p>}
<button type="submit" className="btn btn-sm self-start" disabled={busy}>{busy ? "Saving..." : "Save"}</button>
</form>
</section>
);
}
function ApiKeys() {
const s = useStore();
const { data: keys } = useQuery(`${s.base}/apikeys`, { limit: 100 }) as { data: (ApiKey & { id: string })[] | undefined };
const [label, setLabel] = useState("My assistant");
const [made, setMade] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const base = `https://functions.openapps.xyz/apps/${SERVED_APP_ID || APP_ID || "APP_ID"}/api`;
const create = async () => {
setBusy(true);
try { setMade(await createKey(s, label.trim().slice(0, 40) || "Assistant")); } catch (err) { toast(explain(err)); }
setBusy(false);
};
const revoke = async (k: ApiKey) => {
if (!confirm(`Revoke "${k.label}"? Anything using it stops working right away.`)) return;
try { await revokeKey(s, k); toast("Key revoked"); } catch (err) { toast(explain(err)); }
};
const prompt = (key: string) => `Track my time with Time (${SITE_URL}). API base: ${base}. Send the header x-workspace-key: ${key} on every request. Docs: ${SITE_URL}/llms.txt`;
return (
<section className="card set-card">
<h2 className="h3">API key for assistants</h2>
<p className="muted small">Let an AI assistant start and stop your timer, add time and read your reports. The key works for this workspace only. Anyone holding it can read and change your time, so give it only to assistants you trust, and revoke it when you stop using one.</p>
<div className="row">
<input className="input input-grow" value={label} maxLength={40} onChange={(e) => setLabel(e.target.value)} aria-label="Key name" />
<button type="button" className="btn btn-sm" onClick={create} disabled={busy}>{busy ? "Creating..." : "Create a key"}</button>
</div>
{keys && keys.length > 0 && (
<ul className="keys">
{[...keys].sort((a, b) => b.createdAt - a.createdAt).map((k) => (
<li key={k.id}><span className="grow"><strong>{k.label}</strong> <span className="muted small mono">...{k.hint}</span></span><button type="button" className="btn btn-quiet btn-sm btn-danger" onClick={() => void revoke(k)}>Revoke</button></li>
))}
</ul>
)}
{made && (
<Modal title="Your new key" onClose={() => setMade(null)} footer={<><span className="grow" /><button type="button" className="btn" onClick={() => setMade(null)}>Done</button></>}>
<div className="stack">
<p>Copy it now. Time keeps only a fingerprint of it, so it cannot be shown again.</p>
<div className="link-row"><code className="link-code">{made}</code><button type="button" className="btn btn-sm" onClick={() => void copy(made, "Key copied")}>Copy</button></div>
<p className="small">Or paste this into your assistant:</p>
<pre className="code small-code">{prompt(made)}</pre>
<button type="button" className="btn btn-line btn-sm self-start" onClick={() => void copy(prompt(made), "Instructions copied")}>Copy instructions</button>
</div>
</Modal>
)}
</section>
);
}
type InstallPrompt = Event & { prompt(): Promise<void>; userChoice: Promise<{ outcome: string }> };
let early: InstallPrompt | null = null;
window.addEventListener("beforeinstallprompt", (e) => { e.preventDefault(); early = e as InstallPrompt; });
function Install() {
const [prompt, setPrompt] = useState<InstallPrompt | null>(early);
const [installed, setInstalled] = useState(() => matchMedia("(display-mode: standalone)").matches);
useEffect(() => {
const offer = (e: Event) => { e.preventDefault(); setPrompt(e as InstallPrompt); };
const done = () => { setInstalled(true); setPrompt(null); };
window.addEventListener("beforeinstallprompt", offer);
window.addEventListener("appinstalled", done);
return () => { window.removeEventListener("beforeinstallprompt", offer); window.removeEventListener("appinstalled", done); };
}, []);
if (installed) return null;
const ios = /iphone|ipad|ipod/i.test(navigator.userAgent);
return (
<section className="card set-card">
<h2 className="h3">Install Time</h2>
<p className="muted small">{prompt ? "Add Time to your home screen or dock. It opens like an app, straight to the timer." : ios ? "In Safari, tap the Share button, then Add to Home Screen. Time opens like an app, straight to the timer." : "Use your browser's menu (Install app, or Add to Home screen) to open Time like an app."}</p>
{prompt && <button type="button" className="btn btn-sm self-start" onClick={async () => { await prompt.prompt(); setPrompt(null); }}>Install</button>}
</section>
);
}
function Export() {
const s = useStore();
const [busy, setBusy] = useState(false);
const run = async () => {
setBusy(true);
try {
const all = await listAll<Entry>(`${s.base}/entries`);
if (!all.length) toast("No time to export yet.");
else download(`time-all-${new Date().toISOString().slice(0, 10)}.csv`, entriesCsv(all.map((e) => toRow(s, e)), s.projects, s.clients, { ...s.ws, tz: s.tz }));
} catch (err) { toast(explain(err)); }
setBusy(false);
};
return (
<section className="card set-card">
<h2 className="h3">Export</h2>
<p className="muted small">Download every entry you have tracked as a CSV file, for a spreadsheet or another app. Reports export any range.</p>
<button type="button" className="btn btn-line btn-sm self-start" onClick={run} disabled={busy}>{busy ? "Preparing..." : "Download all time (CSV)"}</button>
</section>
);
}
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