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/Pickers.tsx
// Pickers for a time entry or the running timer: project (search, grouped by client, create on the
// spot), tags (several, create on the spot) and the billable toggle.
import { useMemo, useState } from "react";
import { parseTags, NO_PROJECT_COLOR } from "../functions/lib/time";
import { useStore, saveProject, saveTag, nextColor, clientOf, projectOf } from "./lib/data";
import { explain } from "./lib/bounded";
import { Popover, toast, Folder, Tag as TagIcon, Plus, Check } from "./ui";
import { useJoinedStatus } from "./joined";

export function currencySymbol(cur: string): string {
  try {
    return new Intl.NumberFormat(undefined, { style: "currency", currency: cur, currencyDisplay: "narrowSymbol" }).formatToParts(0).find((p) => p.type === "currency")?.value || cur;
  } catch {
    return cur;
  }
}

export function ProjectLabel({ id, empty = "No project" }: { id: string; empty?: string }) {
  const s = useStore();
  const p = projectOf(s, id);
  const c = p?.client ? clientOf(s, p.client) : undefined;
  if (!p) return <span className="proj muted"><span className="dot" style={{ background: NO_PROJECT_COLOR }} />{empty}</span>;
  return (
    <span className="proj">
      <span className="dot" style={{ background: p.color }} />
      <span className="proj-name">{p.name}</span>
      {c && <span className="proj-client">{c.name}</span>}
      {p.team && <span className="proj-client">from {p.owner || "a teammate"}</span>}
    </span>
  );
}

export function ProjectPicker({ value, onChange }: { value: string; onChange: (id: string, billable: boolean) => void }) {
  const s = useStore();
  const status = useJoinedStatus();
  const [open, setOpen] = useState(false);
  const [q, setQ] = useState("");
  const groups = useMemo(() => {
    const live = s.projects.filter((p) => !p.archived && !(p.team && status.get(p.id)) && p.name.toLowerCase().includes(q.trim().toLowerCase()));
    const map = new Map<string, typeof live>();
    for (const p of live) {
      const key = p.team ? `Shared by ${p.owner || "a teammate"}` : clientOf(s, p.client)?.name ?? "";
      map.set(key, [...(map.get(key) || []), p]);
    }
    return [...map.entries()].sort(([a], [b]) => (a === "" ? -1 : b === "" ? 1 : a.localeCompare(b)));
  }, [s, q, status]);
  const exact = s.projects.some((p) => p.name.toLowerCase() === q.trim().toLowerCase());
  const pick = (id: string, billable: boolean) => { onChange(id, billable); setOpen(false); setQ(""); };
  const create = async () => {
    const name = q.trim();
    if (!name) return;
    try {
      const id = await saveProject(s, { name, color: nextColor(s), client: "", rate: 0, billable: s.ws.rate > 0 });
      pick(id, s.ws.rate > 0);
      toast(`Project "${name}" added`);
    } catch (err) {
      toast(explain(err));
    }
  };
  const p = projectOf(s, value);
  return (
    <div className="picker">
      <button type="button" className={`pick-btn${p ? " on" : ""}`} onClick={() => setOpen((o) => !o)} aria-haspopup="dialog" aria-expanded={open} aria-label={p ? `Project: ${p.name}. Change project` : "Pick a project"}>
        {p ? <><span className="dot" style={{ background: p.color }} /><span className="pick-text">{p.name}</span></> : <><Folder size={18} /><span className="pick-text pick-label">Project</span></>}
      </button>
      <Popover open={open} onClose={() => { setOpen(false); setQ(""); }} label="Pick a project">
        <input className="input pop-search" placeholder="Find or add a project" value={q} onChange={(e) => setQ(e.target.value)} autoFocus
          onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); const first = groups[0]?.[1][0]; if (first && q.trim()) pick(first.id, first.billable); else if (q.trim() && !exact) void create(); } }} />
        <ul className="pop-list">
          <li><button type="button" className="pop-item" onClick={() => pick("", false)}><span className="dot" style={{ background: NO_PROJECT_COLOR }} />No project{!value && <Check />}</button></li>
          {groups.map(([client, list]) => (
            <li key={client}>
              {client && <p className="pop-group">{client}</p>}
              <ul>
                {list.map((x) => (
                  <li key={x.id}><button type="button" className="pop-item" onClick={() => pick(x.id, x.billable)}><span className="dot" style={{ background: x.color }} /><span className="grow">{x.name}</span>{x.id === value && <Check />}</button></li>
                ))}
              </ul>
            </li>
          ))}
        </ul>
        {q.trim() && !exact && <button type="button" className="pop-create" onClick={create}><Plus size={16} /> Add project "{q.trim()}"</button>}
      </Popover>
    </div>
  );
}

export function TagPicker({ value, onChange }: { value: string; onChange: (json: string) => void }) {
  const s = useStore();
  const [open, setOpen] = useState(false);
  const [q, setQ] = useState("");
  const ids = parseTags(value).filter((id) => s.tags.some((t) => t.id === id));
  const list = s.tags.filter((t) => t.name.toLowerCase().includes(q.trim().toLowerCase()));
  const exact = s.tags.some((t) => t.name.toLowerCase() === q.trim().toLowerCase());
  const toggle = (id: string) => onChange(JSON.stringify(ids.includes(id) ? ids.filter((x) => x !== id) : [...ids, id]));
  const create = async () => {
    const name = q.trim();
    if (!name) return;
    try {
      const id = await saveTag(s, name.slice(0, 60));
      onChange(JSON.stringify([...ids, id]));
      setQ("");
    } catch (err) {
      toast(explain(err));
    }
  };
  const names = ids.map((id) => s.tags.find((t) => t.id === id)?.name).filter(Boolean);
  return (
    <div className="picker">
      <button type="button" className={`pick-btn${ids.length ? " on" : ""}`} onClick={() => setOpen((o) => !o)} aria-haspopup="dialog" aria-expanded={open} aria-label={ids.length ? `Tags: ${names.join(", ")}. Change tags` : "Add tags"}>
        <TagIcon size={18} />{ids.length ? <span className="pick-text">{names.join(", ")}</span> : <span className="pick-text pick-label">Tags</span>}
      </button>
      <Popover open={open} onClose={() => { setOpen(false); setQ(""); }} label="Tags">
        <input className="input pop-search" placeholder="Find or add a tag" value={q} onChange={(e) => setQ(e.target.value)} autoFocus
          onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); if (q.trim() && !exact) void create(); else if (list[0]) toggle(list[0].id); } }} />
        <ul className="pop-list">
          {list.map((t) => (
            <li key={t.id}><label className="pop-item"><input type="checkbox" checked={ids.includes(t.id)} onChange={() => toggle(t.id)} /><span className="grow">{t.name}</span></label></li>
          ))}
          {!list.length && !q.trim() && <li className="pop-empty">No tags yet. Type one to add it.</li>}
        </ul>
        {q.trim() && !exact && <button type="button" className="pop-create" onClick={create}><Plus size={16} /> Add tag "{q.trim()}"</button>}
      </Popover>
    </div>
  );
}

export function BillableToggle({ value, onChange }: { value: boolean; onChange: (v: boolean) => void }) {
  const s = useStore();
  return (
    <button type="button" className={`pick-btn bill${value ? " on" : ""}`} aria-pressed={value} onClick={() => onChange(!value)} title={value ? "Billable" : "Not billable"} aria-label="Billable">
      <span className="bill-sym">{currencySymbol(s.ws.currency)}</span>
    </button>
  );
}

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