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/EntryForm.tsx
// Add or change one time entry: what, project, tags, billable, the day, start and end (or a duration).
import { useState } from "react";
import { parseDuration, parseTimeOfDay, clock, zoned, dayOf, MAX_SECONDS, type Entry } from "../functions/lib/time";
import { useStore, saveEntry, deleteEntry, restoreEntry, timeText, nowSec } from "./lib/data";
import { explain } from "./lib/bounded";
import { Modal, toast } from "./ui";
import { ProjectPicker, TagPicker, BillableToggle } from "./Pickers";

export interface Draft { description: string; project: string; tags: string; billable: boolean; day: string; start: string; end: string }

export function EntryForm({ entry, initial, onClose }: { entry?: Entry; initial?: Partial<Draft>; onClose: () => void }) {
  const s = useStore();
  const now = nowSec();
  const [d, setD] = useState<Draft>(() => entry
    ? { description: entry.description, project: entry.project, tags: entry.tags, billable: Boolean(entry.billable), day: dayOf(entry.start, s.tz), start: timeText(s, entry.start), end: timeText(s, entry.end) }
    : { description: "", project: "", tags: "[]", billable: false, day: dayOf(now, s.tz), start: timeText(s, now - 3600), end: timeText(s, now), ...initial });
  const [dur, setDur] = useState(() => clock(spanOf(d) ?? 0));
  const [busy, setBusy] = useState(false);
  const [error, setError] = useState("");

  function spanOf(x: Draft): number | null {
    const a = parseTimeOfDay(x.start);
    const b = parseTimeOfDay(x.end);
    if (a == null || b == null) return null;
    return ((b - a + 1440) % 1440) * 60;
  }
  const patch = (p: Partial<Draft>) => {
    const next = { ...d, ...p };
    setD(next);
    if ("start" in p || "end" in p) { const sp = spanOf(next); if (sp != null) setDur(clock(sp)); }
  };
  const onDuration = (v: string) => {
    setDur(v);
    const sec = parseDuration(v);
    const a = parseTimeOfDay(d.start);
    if (sec == null || a == null || sec >= 86400) return;
    const m = Math.round(a + sec / 60) % 1440;
    setD({ ...d, end: `${String(Math.floor(m / 60)).padStart(2, "0")}:${String(m % 60).padStart(2, "0")}` });
  };

  const save = async () => {
    setError("");
    const a = parseTimeOfDay(d.start);
    if (!d.day || a == null) return setError("Enter the day and a start time, like 09:00.");
    const start = zoned(d.day, a, s.tz);
    let sec = parseDuration(dur);
    const b = parseTimeOfDay(d.end);
    if (sec == null && b != null) sec = spanOf(d);
    if (sec == null || sec <= 0) return setError("Enter an end time or a duration, like 1:30.");
    if (sec > MAX_SECONDS) return setError("One entry can be at most 1,000 hours.");
    setBusy(true);
    try {
      await saveEntry(s, { description: d.description.trim(), project: d.project, tags: d.tags, billable: d.billable, start, end: start + sec }, entry);
      toast(entry ? "Entry saved" : "Time added");
      onClose();
    } catch (err) {
      setError(explain(err));
      setBusy(false);
    }
  };
  const remove = async () => {
    if (!entry) return;
    setBusy(true);
    try {
      await deleteEntry(s, entry);
      toast("Entry deleted", { label: "Undo", run: () => void restoreEntry(s, entry).catch((e) => toast(explain(e))) });
      onClose();
    } catch (err) {
      setError(explain(err));
      setBusy(false);
    }
  };

  return (
    <Modal title={entry ? "Edit time" : "Add time"} onClose={onClose} footer={
      <>
        {entry && <button type="button" className="btn btn-quiet btn-danger" onClick={remove} disabled={busy}>Delete</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>What did you work on?</span>
          <input className="input" value={d.description} maxLength={1000} placeholder="Description (optional)" onChange={(e) => patch({ description: e.target.value })} autoFocus={!entry} />
        </label>
        <div className="entry-picks">
          <ProjectPicker value={d.project} onChange={(id, billable) => patch({ project: id, billable: id ? billable : d.billable })} />
          <TagPicker value={d.tags} onChange={(tags) => patch({ tags })} />
          <BillableToggle value={d.billable} onChange={(billable) => patch({ billable })} />
        </div>
        <div className="time-grid">
          <label className="field"><span>Day</span><input className="input" type="date" value={d.day} onChange={(e) => patch({ day: e.target.value })} required /></label>
          <label className="field"><span>Start</span><input className="input" type="time" value={d.start} onChange={(e) => patch({ start: e.target.value })} required /></label>
          <label className="field"><span>End</span><input className="input" type="time" value={d.end} onChange={(e) => patch({ end: e.target.value })} /></label>
          <label className="field"><span>Duration</span><input className="input" value={dur} inputMode="text" placeholder="1:30" onChange={(e) => onDuration(e.target.value)} /></label>
        </div>
        <p className="hint">An end time before the start means the next day. Duration takes 1:30, 1.5, 90m or 1h 30m.</p>
        {error && <p className="notice notice-error" role="alert">{error}</p>}
        <button type="submit" hidden />
      </form>
    </Modal>
  );
}

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