time-free.openapps.xyz

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.
Owned app · rev 4857f8a940b5
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.
← All files
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
4857f8a940 · 19 hours ago
Bind agent address constants in policy
62b1e6acb2 · 20 hours ago
Use functions.openapps.xyz alias and add OpenApp contribution strip
61d972193a · 20 hours ago
Bounded source sync
fc644b4327 · 1 day ago
Bounded source sync
22f511f3db · 1 day ago
Bounded source sync
351a8d754c · 1 day ago
Bounded source sync
0da238a77e · 1 day ago