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/Projects.tsx
// Projects (color, client, hourly rate, billable by default, sharing by link), projects others shared
// with you, clients and tags.
import { useEffect, useState } from "react";
import { COLORS, toMinor, fromMinor, formatMoney, hm, monthOf, addMonths, summarize, MAX_RATE, type Project, type Client, type Tag } from "../functions/lib/time";
import {
useStore, saveProject, setArchived, deleteProject, saveClient, deleteClient, saveTag, deleteTag, nextColor, clientOf,
shareProject, stopSharing, loadTeam, removeTeammate, letBackIn, leaveShare, joinUrl, dayStart, today, type TeamData, type Teammate,
} from "./lib/data";
import { explain } from "./lib/bounded";
import { Modal, toast, share as shareLink, Plus, Users, Pencil } from "./ui";
import { currencySymbol } from "./Pickers";
import { useJoinedStatus } from "./joined";
export function Projects() {
const s = useStore();
const status = useJoinedStatus();
const [edit, setEdit] = useState<Project | "new" | null>(null);
const [team, setTeam] = useState<Project | null>(null);
const [showArchived, setShowArchived] = useState(false);
const own = s.projects.filter((p) => !p.team);
const live = own.filter((p) => !p.archived);
const archived = own.filter((p) => p.archived);
const joined = s.projects.filter((p) => p.team && !p.archived);
const cur = s.ws.currency;
const row = (p: Project) => {
const c = clientOf(s, p.client);
return (
<li key={p.id} className="proj-row">
<button type="button" className="proj-main" onClick={() => setEdit(p)} aria-label={`Edit ${p.name}`}>
<span className="dot dot-lg" style={{ background: p.color }} />
<span className="proj-text">
<strong>{p.name}</strong>
<span className="muted small">{[c?.name, p.rate ? `${formatMoney(p.rate, cur)}/h` : s.ws.rate ? `${formatMoney(s.ws.rate, cur)}/h (default)` : null, p.billable ? "billable" : null].filter(Boolean).join(" · ") || "No client"}</span>
</span>
</button>
{p.archived ? (
<button type="button" className="btn btn-line btn-sm" onClick={() => void setArchived(s, p, false).catch((e) => toast(explain(e)))}>Restore</button>
) : (
<button type="button" className={`btn btn-sm ${p.share ? "btn-line" : "btn-quiet"}`} onClick={() => setTeam(p)}><Users size={16} /> {p.share ? "Team" : "Share"}</button>
)}
</li>
);
};
return (
<div className="page">
<div className="page-head">
<h1 className="page-title">Projects</h1>
<button type="button" className="btn btn-sm" onClick={() => setEdit("new")}><Plus size={16} /> New project</button>
</div>
{live.length ? <ul className="proj-list card">{live.map(row)}</ul> : (
<div className="empty">
<h2 className="h3">No projects yet</h2>
<p className="muted">Projects group your time. Give each one a color, a client and an hourly rate.</p>
<button type="button" className="btn" onClick={() => setEdit("new")}>Add a project</button>
</div>
)}
{archived.length > 0 && (
<div className="section-sm">
<button type="button" className="btn btn-quiet btn-sm" onClick={() => setShowArchived((v) => !v)}>{showArchived ? "Hide" : "Show"} archived ({archived.length})</button>
{showArchived && <ul className="proj-list card">{archived.map(row)}</ul>}
</div>
)}
{joined.length > 0 && (
<section className="section-sm">
<h2 className="h3">Shared with you</h2>
<p className="muted small">Time you log to these goes to their owners too. They see it, your name and nothing else.</p>
<ul className="proj-list card">
{joined.map((p) => (
<li key={p.id} className="proj-row">
<span className="proj-main static">
<span className="dot dot-lg" style={{ background: p.color }} />
<span className="proj-text">
<strong>{p.name}</strong>
<span className="muted small">From {p.owner || "a teammate"}{status.get(p.id) === "closed" ? ". Sharing stopped: new time is not accepted." : status.get(p.id) === "removed" ? ". You were removed from this project." : ""}</span>
</span>
</span>
<button type="button" className="btn btn-quiet btn-sm" onClick={async () => {
if (!confirm(`Leave "${p.name}"? Your time stays in your reports; its owner keeps what you already logged.`)) return;
try { await leaveShare(s, p); toast(`You left ${p.name}`); } catch (err) { toast(explain(err)); }
}}>Leave</button>
</li>
))}
</ul>
</section>
)}
<Clients />
<Tags />
{edit && <ProjectForm project={edit === "new" ? undefined : edit} onClose={() => setEdit(null)} />}
{team && <TeamModal project={s.projects.find((p) => p.id === team.id) ?? team} onClose={() => setTeam(null)} />}
</div>
);
}
function ProjectForm({ project, onClose }: { project?: Project; onClose: () => void }) {
const s = useStore();
const cur = s.ws.currency;
const [name, setName] = useState(project?.name ?? "");
const [color, setColor] = useState(project?.color ?? nextColor(s));
const [client, setClient] = useState(project?.client ?? "");
const [newClient, setNewClient] = useState("");
const [rate, setRate] = useState(project?.rate ? fromMinor(project.rate, cur) : "");
const [billable, setBillable] = useState(project ? Boolean(project.billable) : s.ws.rate > 0);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const save = async () => {
setError("");
if (!name.trim()) return setError("Give the project a name.");
const r = rate.trim() === "" ? 0 : toMinor(rate, cur);
if (!Number.isFinite(r) || r > MAX_RATE) return setError(`Enter the hourly rate as an amount in ${cur}, like 80 or 80.50.`);
setBusy(true);
try {
let clientId = client;
if (client === "+new") {
if (!newClient.trim()) { setBusy(false); return setError("Name the new client."); }
clientId = await saveClient(s, newClient.trim().slice(0, 100));
}
await saveProject(s, { name: name.trim().slice(0, 100), color, client: clientId, rate: r, billable }, project);
toast(project ? "Project saved" : "Project added");
onClose();
} catch (err) {
setError(explain(err));
setBusy(false);
}
};
const archive = async () => {
if (!project) return;
setBusy(true);
try {
await setArchived(s, project, true);
toast(`${project.name} archived. Its time stays in your reports.`);
onClose();
} catch (err) { setError(explain(err)); setBusy(false); }
};
const remove = async () => {
if (!project) return;
setBusy(true);
try {
if (await deleteProject(s, project)) { toast("Project deleted"); onClose(); }
else { setError("This project has time, so it can be archived but not deleted. Archived projects keep their time in reports."); setBusy(false); }
} catch (err) { setError(explain(err)); setBusy(false); }
};
return (
<Modal title={project ? "Edit project" : "New project"} onClose={onClose} footer={
<>
{project && <button type="button" className="btn btn-quiet btn-danger" onClick={remove} disabled={busy}>Delete</button>}
{project && !project.archived && <button type="button" className="btn btn-quiet" onClick={archive} disabled={busy}>Archive</button>}
<span className="grow" />
<button type="button" className="btn btn-line" onClick={onClose}>Cancel</button>
<button type="button" className="btn" onClick={save} disabled={busy}>{busy ? "Saving..." : "Save"}</button>
</>
}>
<form className="stack" onSubmit={(e) => { e.preventDefault(); void save(); }}>
<label className="field"><span>Name</span><input className="input" value={name} maxLength={100} onChange={(e) => setName(e.target.value)} placeholder="Website redesign" autoFocus /></label>
<fieldset className="field plain">
<legend className="label">Color</legend>
<div className="swatches">
{COLORS.map((c) => <button key={c} type="button" className="swatch" style={{ background: c }} aria-label={`Color ${COLORS.indexOf(c) + 1}`} aria-pressed={color === c} onClick={() => setColor(c)} />)}
</div>
</fieldset>
<label className="field"><span>Client</span>
<select className="select" value={client} onChange={(e) => setClient(e.target.value)}>
<option value="">No client</option>
{s.clients.filter((c) => !c.archived || c.id === client).map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
<option value="+new">New client...</option>
</select>
</label>
{client === "+new" && <label className="field"><span>New client's name</span><input className="input" value={newClient} maxLength={100} onChange={(e) => setNewClient(e.target.value)} placeholder="Acme Inc." /></label>}
<label className="field"><span>Hourly rate</span>
<span className="money-input"><span>{currencySymbol(cur)}</span><input className="input" inputMode="decimal" value={rate} onChange={(e) => setRate(e.target.value)} placeholder={s.ws.rate ? `${fromMinor(s.ws.rate, cur)} (your default)` : "Optional"} /></span>
<small>Leave empty to use your default rate from Settings.</small>
</label>
<label className="check"><input type="checkbox" checked={billable} onChange={(e) => setBillable(e.target.checked)} /> New time on this project is billable</label>
{project?.share && <p className="hint">This project is shared by link. Its name, color and client name are shown to the teammates who joined.</p>}
{error && <p className="notice notice-error" role="alert">{error}</p>}
<button type="submit" hidden />
</form>
</Modal>
);
}
function TeamModal({ project, onClose }: { project: Project; onClose: () => void }) {
const s = useStore();
const [data, setData] = useState<TeamData | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [nonce, setNonce] = useState(0);
const month = monthOf(today(s));
useEffect(() => {
if (!project.share) return;
let dead = false;
loadTeam(s, project, dayStart(s, month), dayStart(s, addMonths(month, 1)))
.then((d) => { if (!dead) setData(d); })
.catch((err) => { if (!dead) setError(explain(err)); });
return () => { dead = true; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [project.share, nonce]);
const url = project.share ? joinUrl(project.share) : "";
const start = async () => {
setBusy(true);
try { await shareProject(s, project); setNonce((n) => n + 1); } catch (err) { setError(explain(err)); }
setBusy(false);
};
const stop = async () => {
if (!confirm("Stop sharing? Teammates can no longer add time to this project. The time they already logged stays here.")) return;
setBusy(true);
try { await stopSharing(project); setNonce((n) => n + 1); } catch (err) { setError(explain(err)); }
setBusy(false);
};
const remove = async (t: Teammate) => {
if (!confirm(`Remove ${t.name}? Their workspace and its assistants can no longer join or add time to ${project.name}. Their time so far stays here. Anyone else with the link can still join.`)) return;
try { await removeTeammate(project, t); setNonce((n) => n + 1); toast(`${t.name} removed`); } catch (err) { toast(explain(err)); }
};
const back = async (ws: string, name: string) => {
try { await letBackIn(project, ws); setNonce((n) => n + 1); toast(`${name} can join again with the link`); } catch (err) { toast(explain(err)); }
};
const sum = data ? summarize(data.rows, s.projects, s.clients, s.ws.rate, s.tz) : null;
const hoursOf = (name: string) => sum?.people.find((g) => g.name === name)?.seconds ?? 0;
const monthName = new Date(`${month}T12:00:00Z`).toLocaleDateString(undefined, { month: "long", timeZone: "UTC" });
return (
<Modal title={project.share ? `Team: ${project.name}` : `Share ${project.name}`} onClose={onClose}>
{!project.share ? (
<div className="stack">
<p>Share this project by link. Teammates who open the link can log their time to it, with no sign-up. You see everyone's time on this project in Reports, and the amounts at your rate.</p>
<ul className="plain-list">
<li>Teammates see the project's name, color, client name and your name. Not your rate, your other projects or anyone else's time.</li>
<li>You see their name and the time they log to this project. Nothing else from their account.</li>
<li>You can remove anyone, or stop sharing, at any time.</li>
</ul>
{error && <p className="notice notice-error">{error}</p>}
<button type="button" className="btn" onClick={start} disabled={busy}>{busy ? "Making the link..." : "Make a link"}</button>
</div>
) : (
<div className="stack">
<div className="link-row">
<code className="link-code">{url}</code>
<button type="button" className="btn btn-sm" onClick={() => void shareLink(url, `Log time to ${project.name}`, "Link copied. Send it to your teammates.")}>Share</button>
</div>
{data?.closed ? (
<p className="notice">Sharing is stopped: teammates cannot add time. <button type="button" className="link-btn" onClick={start} disabled={busy}>Share again</button></p>
) : (
<p className="hint">Anyone with this link can join and log time to {project.name}. Send it only to your team. Removing someone keeps their workspace out, but the link still works from a new browser; stopping sharing closes the link for everyone.</p>
)}
<h3 className="h3">People</h3>
{error ? <p className="notice notice-error">{error}</p> : !data ? <div className="loading"><span className="spinner" /> Loading...</div> : !data.people.length ? (
<p className="muted">Nobody has joined yet.</p>
) : (
<ul className="people">
{data.people.map((t) => (
<li key={t.ws}>
<span className="grow"><strong>{t.name}</strong><span className="muted small"> {hm(hoursOf(t.name))} in {monthName}</span></span>
<button type="button" className="btn btn-quiet btn-sm btn-danger" onClick={() => void remove(t)}>Remove</button>
</li>
))}
</ul>
)}
{data && data.removed.length > 0 && (
<>
<h3 className="h3">Removed</h3>
<ul className="people">
{data.removed.map((r) => (
<li key={r.ws}>
<span className="grow muted">{r.name || "Someone"}</span>
<button type="button" className="btn btn-quiet btn-sm" onClick={() => void back(r.ws, r.name || "They")}>Let back in</button>
</li>
))}
</ul>
</>
)}
<p className="hint">Everyone's time on this project is in Reports, with a People tab.</p>
{!data?.closed && <button type="button" className="btn btn-line btn-sm self-start" onClick={stop} disabled={busy}>Stop sharing</button>}
</div>
)}
</Modal>
);
}
function Clients() {
const s = useStore();
const [edit, setEdit] = useState<Client | "new" | null>(null);
const [name, setName] = useState("");
const open = (c: Client | "new") => { setEdit(c); setName(c === "new" ? "" : c.name); };
const save = async () => {
if (!name.trim()) return;
try { await saveClient(s, name.trim().slice(0, 100), edit === "new" ? undefined : edit ?? undefined); setEdit(null); } catch (err) { toast(explain(err)); }
};
const remove = async (c: Client) => {
const n = s.projects.filter((p) => p.client === c.id).length;
if (!confirm(n ? `Delete ${c.name}? Its ${n} ${n === 1 ? "project keeps" : "projects keep"} their time and become "No client".` : `Delete ${c.name}?`)) return;
try { await deleteClient(s, c); setEdit(null); } catch (err) { toast(explain(err)); }
};
return (
<section className="section-sm">
<div className="spread"><h2 className="h3">Clients</h2><button type="button" className="btn btn-quiet btn-sm" onClick={() => open("new")}><Plus size={16} /> New client</button></div>
{s.clients.length ? (
<ul className="chip-list">
{s.clients.map((c) => <li key={c.id}><button type="button" className="chip-btn" onClick={() => open(c)}>{c.name} <Pencil size={14} /></button></li>)}
</ul>
) : <p className="muted small">No clients yet. Add one here or while making a project.</p>}
{edit && (
<Modal title={edit === "new" ? "New client" : "Edit client"} onClose={() => setEdit(null)} footer={
<>
{edit !== "new" && <button type="button" className="btn btn-quiet btn-danger" onClick={() => void remove(edit)}>Delete</button>}
<span className="grow" />
<button type="button" className="btn" onClick={save}>Save</button>
</>
}>
<form onSubmit={(e) => { e.preventDefault(); void save(); }}>
<label className="field"><span>Client name</span><input className="input" value={name} maxLength={100} onChange={(e) => setName(e.target.value)} autoFocus /></label>
</form>
</Modal>
)}
</section>
);
}
function Tags() {
const s = useStore();
const [edit, setEdit] = useState<Tag | "new" | null>(null);
const [name, setName] = useState("");
const open = (t: Tag | "new") => { setEdit(t); setName(t === "new" ? "" : t.name); };
const save = async () => {
if (!name.trim()) return;
try { await saveTag(s, name.trim().slice(0, 60), edit === "new" ? undefined : edit ?? undefined); setEdit(null); } catch (err) { toast(explain(err)); }
};
const remove = async (t: Tag) => {
if (!confirm(`Delete the tag "${t.name}"? Entries keep their time; they just lose this tag.`)) return;
try { await deleteTag(s, t); setEdit(null); } catch (err) { toast(explain(err)); }
};
return (
<section className="section-sm">
<div className="spread"><h2 className="h3">Tags</h2><button type="button" className="btn btn-quiet btn-sm" onClick={() => open("new")}><Plus size={16} /> New tag</button></div>
{s.tags.length ? (
<ul className="chip-list">
{s.tags.map((t) => <li key={t.id}><button type="button" className="chip-btn" onClick={() => open(t)}>{t.name} <Pencil size={14} /></button></li>)}
</ul>
) : <p className="muted small">No tags yet. Tags mark kinds of work across projects, like "meeting" or "design".</p>}
{edit && (
<Modal title={edit === "new" ? "New tag" : "Edit tag"} onClose={() => setEdit(null)} footer={
<>
{edit !== "new" && <button type="button" className="btn btn-quiet btn-danger" onClick={() => void remove(edit)}>Delete</button>}
<span className="grow" />
<button type="button" className="btn" onClick={save}>Save</button>
</>
}>
<form onSubmit={(e) => { e.preventDefault(); void save(); }}>
<label className="field"><span>Tag name</span><input className="input" value={name} maxLength={60} onChange={(e) => setName(e.target.value)} autoFocus /></label>
</form>
</Modal>
)}
</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