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/TimerPage.tsx
// The timer: what you are working on, project, tags and billable, one tap to start or stop. Below it,
// your recent time by day; tap an entry to change it, or the play button to continue it.
import { useEffect, useMemo, useState } from "react";
import { clock, spoken, dayOf, weekOf, addDays, parseTimeOfDay, zoned, durationOf, type Entry } from "../functions/lib/time";
import { useStore, useEntries, startTimer, stopTimer, updateTimer, discardTimer, dayStart, today, timeText, tagNamesOf, nowSec } from "./lib/data";
import { explain } from "./lib/bounded";
import { toast, Popover, Play, Stop, Plus } from "./ui";
import { ProjectPicker, TagPicker, BillableToggle, ProjectLabel, currencySymbol } from "./Pickers";
import { get } from "@bounded-sh/client";
import { EntryForm } from "./EntryForm";
import { useNow } from "./lib/hooks";
import { takeLandingStart } from "./Start";

export function TimerBar() {
  const s = useStore();
  const t = s.timer;
  const running = Boolean(t?.start);
  const now = useNow(running);
  const [draft, setDraft] = useState({ description: "", project: "", tags: "[]", billable: false });
  // While the running timer's description is being typed, the typed text shows; otherwise the timer's own.
  const [typing, setTyping] = useState<string | null>(null);
  const [busy, setBusy] = useState(false);
  const [startOpen, setStartOpen] = useState(false);
  const desc = typing ?? t?.description ?? "";

  const fields = running && t ? { description: desc, project: t.project, tags: t.tags, billable: Boolean(t.billable) } : draft;
  const change = (p: Partial<typeof draft>) => {
    if (running) void updateTimer(s, p).catch((e) => toast(explain(e)));
    else setDraft((d) => ({ ...d, ...p }));
  };
  const saveDesc = () => {
    if (running && t && typing != null && typing.trim() !== t.description) change({ description: typing.trim() });
    setTyping(null);
  };

  const start = async () => {
    setBusy(true);
    try {
      await startTimer(s, { ...draft, description: draft.description.trim() });
      setDraft({ description: "", project: "", tags: "[]", billable: false });
    } catch (err) { toast(explain(err)); }
    setBusy(false);
  };
  const stop = async () => {
    setBusy(true);
    try {
      // A description still being typed goes into the saved entry.
      const typed = typing != null && t ? { ...s, timer: { ...t, description: typing.trim() } } : s;
      setTyping(null);
      await stopTimer(typed);
    } catch (err) { toast(explain(err)); }
    setBusy(false);
  };

  return (
    <section className={`timer-bar card${running ? " running" : ""}`} aria-label="Timer">
      <input
        className="timer-input"
        placeholder="What are you working on?"
        aria-label="What are you working on?"
        value={running ? desc : draft.description}
        maxLength={1000}
        onChange={(e) => (running ? setTyping(e.target.value) : setDraft({ ...draft, description: e.target.value }))}
        onBlur={saveDesc}
        onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); if (running) saveDesc(); else void start(); } }}
      />
      <div className="timer-controls">
        <ProjectPicker value={fields.project} onChange={(project, billable) => change({ project, billable: project ? billable : fields.billable })} />
        <TagPicker value={fields.tags} onChange={(tags) => change({ tags })} />
        <BillableToggle value={fields.billable} onChange={(billable) => change({ billable })} />
        <span className="grow" />
        {running && t ? (
          <div className="picker">
            <button type="button" className="timer-clock" onClick={() => setStartOpen((o) => !o)} aria-label={`Running for ${clock(now - t.start)}. Change start time`}>{clock(now - t.start)}</button>
            <Popover open={startOpen} onClose={() => setStartOpen(false)} align="right" label="Change start time">
              <StartEditor onDone={() => setStartOpen(false)} />
            </Popover>
          </div>
        ) : (
          <span className="timer-clock idle" aria-hidden="true">0:00:00</span>
        )}
        {running
          ? <button type="button" className="btn timer-go stop" onClick={stop} disabled={busy}><Stop size={16} /> Stop</button>
          : <button type="button" className="btn timer-go" onClick={start} disabled={busy}><Play size={16} /> Start</button>}
      </div>
    </section>
  );
}

function StartEditor({ onDone }: { onDone: () => void }) {
  const s = useStore();
  const t = s.timer!;
  const [v, setV] = useState(timeText(s, t.start));
  const apply = async () => {
    const m = parseTimeOfDay(v);
    if (m == null) return toast("Enter a time like 09:15.");
    let start = zoned(dayOf(t.start, s.tz), m, s.tz);
    if (start > nowSec()) start -= 86400;
    try { await updateTimer(s, { start }); onDone(); } catch (err) { toast(explain(err)); }
  };
  const discard = async () => {
    try { await discardTimer(s); onDone(); toast("Timer discarded"); } catch (err) { toast(explain(err)); }
  };
  return (
    <div className="stack" style={{ ["--gap" as string]: "10px" }}>
      <label className="field"><span>Started at</span><input className="input" type="time" value={v} onChange={(e) => setV(e.target.value)} autoFocus /></label>
      <div className="row"><button type="button" className="btn btn-sm" onClick={apply}>Save</button><button type="button" className="btn btn-sm btn-quiet btn-danger" onClick={discard}>Discard timer</button></div>
    </div>
  );
}

function dayLabel(day: string, todayDay: string): string {
  if (day === todayDay) return "Today";
  if (day === addDays(todayDay, -1)) return "Yesterday";
  return new Date(`${day}T12:00:00Z`).toLocaleDateString(undefined, { weekday: "long", month: "short", day: "numeric", timeZone: "UTC" });
}

export function EntryRow({ e, onEdit }: { e: Entry; onEdit: (e: Entry) => void }) {
  const s = useStore();
  const tags = tagNamesOf(s, e.tags);
  const cont = async () => {
    try {
      await startTimer(s, { description: e.description, project: e.project, tags: e.tags, billable: Boolean(e.billable) });
      toast("Timer started");
    } catch (err) { toast(explain(err)); }
  };
  return (
    <li className="entry">
      <button type="button" className="entry-main" onClick={() => onEdit(e)} aria-label={`Edit ${e.description || "entry"}, ${spoken(durationOf(e))}`}>
        <span className="entry-what">
          <span className={`entry-desc${e.description ? "" : " muted"}`}>{e.description || "No description"}</span>
          <span className="entry-meta">
            <ProjectLabel id={e.project} />
            {tags.map((t) => <span key={t} className="tag-chip">{t}</span>)}
          </span>
        </span>
        <span className="entry-side">
          {e.billable && <span className="bill-mark" title="Billable">{currencySymbol(s.ws.currency)}</span>}
          <span className="entry-span">{timeText(s, e.start)} to {timeText(s, e.end)}</span>
          <span className="entry-dur">{clock(durationOf(e))}</span>
        </span>
      </button>
      <button type="button" className="icon-btn entry-play" onClick={cont} aria-label={`Continue ${e.description || "this entry"}`}><Play size={16} /></button>
    </li>
  );
}

export function TimerPage() {
  const s = useStore();
  const [weeks, setWeeks] = useState(4);
  const [editing, setEditing] = useState<Entry | "new" | null>(null);
  const todayDay = today(s);
  const wk = weekOf(todayDay, s.ws.weekStart);
  const from = dayStart(s, addDays(wk, -7 * (weeks - 1)));
  const to = dayStart(s, addDays(todayDay, 400));
  const entries = useEntries(s, from, to);
  const [older, setOlder] = useState(false);
  useEffect(() => {
    let dead = false;
    get(`${s.base}/entries`, { filter: { start: { $lt: from } }, limit: 1 }).then((r) => { if (!dead) setOlder(Boolean(r?.data?.length)); }, () => null);
    return () => { dead = true; };
  }, [s.base, from]);
  const running = Boolean(s.timer?.start);
  const now = useNow(running);
  useEffect(() => {
    const start = takeLandingStart();
    if (start == null || s.timer) return;
    void startTimer(s, { description: start, project: "", tags: "[]", billable: false }).catch((err) => toast(explain(err)));
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  const days = useMemo(() => {
    const map = new Map<string, Entry[]>();
    for (const e of entries || []) {
      const d = dayOf(e.start, s.tz);
      map.set(d, [...(map.get(d) || []), e]);
    }
    return [...map.entries()].sort(([a], [b]) => b.localeCompare(a));
  }, [entries, s.tz]);
  const live = running && s.timer ? now - s.timer.start : 0;
  const sumFrom = (day: string) => (entries || []).filter((e) => dayOf(e.start, s.tz) >= day).reduce((a, e) => a + durationOf(e), 0);
  const todayTotal = (entries || []).filter((e) => dayOf(e.start, s.tz) === todayDay).reduce((a, e) => a + durationOf(e), 0) + live;
  const weekTotal = sumFrom(wk) + live;

  return (
    <div className="page">
      <TimerBar />
      <div className="timer-sub">
        <button type="button" className="btn btn-line btn-sm" onClick={() => setEditing("new")}><Plus size={16} /> Add time manually</button>
        <p className="totals"><span>Today <strong>{clock(todayTotal)}</strong></span><span>This week <strong>{clock(weekTotal)}</strong></span></p>
      </div>
      {!entries ? (
        <div className="loading"><span className="spinner" /> Loading your time...</div>
      ) : !days.length ? (
        <div className="empty">
          <img className="art empty-art" src="/art/card-time.webp" alt="" width="200" height="155" />
          <h2 className="h3">No time yet</h2>
          <p className="muted">Type what you are working on and press Start. Or add time you already worked.</p>
        </div>
      ) : (
        <div className="days">
          {days.map(([day, list]) => (
            <section key={day} className="day">
              <h2 className="day-label"><span>{dayLabel(day, todayDay)}</span><span>{clock(list.reduce((a, e) => a + durationOf(e), 0))}</span></h2>
              <ul className="entry-list">{list.map((e) => <EntryRow key={e.id} e={e} onEdit={setEditing} />)}</ul>
            </section>
          ))}
          {older && <button type="button" className="btn btn-line btn-block" onClick={() => setWeeks((w) => w + 4)}>Show older time</button>}
        </div>
      )}
      {editing && <EntryForm entry={editing === "new" ? undefined : editing} onClose={() => setEditing(null)} />}
    </div>
  );
}

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