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/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
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