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/Timesheet.tsx
// The week as a timesheet: a row per project, a column per day. Type a total into a cell and Time adds,
// changes or removes the entry behind it. On phones, one day at a time.
import { useMemo, useState } from "react";
import { hm, dayOf, weekOf, addDays, parseDuration, zoned, durationOf, MAX_SECONDS, type Entry } from "../functions/lib/time";
import { useStore, useEntries, saveEntry, deleteEntry, restoreEntry, dayStart, today, projectOf } from "./lib/data";
import { explain } from "./lib/bounded";
import { toast, Left, Right } from "./ui";
import { ProjectLabel, ProjectPicker } from "./Pickers";

const short = (day: string, opts: Intl.DateTimeFormatOptions) => new Date(`${day}T12:00:00Z`).toLocaleDateString(undefined, { ...opts, timeZone: "UTC" });

export function Timesheet() {
  const s = useStore();
  const todayDay = today(s);
  const [week, setWeek] = useState(() => weekOf(todayDay, s.ws.weekStart));
  const [added, setAdded] = useState<string[]>([]);
  const [focusDay, setFocusDay] = useState(todayDay);
  const days = useMemo(() => Array.from({ length: 7 }, (_, i) => addDays(week, i)), [week]);
  const entries = useEntries(s, dayStart(s, week), dayStart(s, addDays(week, 7)));
  const cells = useMemo(() => {
    const map = new Map<string, Entry[]>();
    for (const e of entries || []) {
      const k = `${e.project}|${dayOf(e.start, s.tz)}`;
      map.set(k, [...(map.get(k) || []), e]);
    }
    return map;
  }, [entries, s.tz]);
  const rows = useMemo(() => {
    const ids = new Set<string>([...(entries || []).map((e) => e.project), ...added]);
    return [...ids].sort((a, b) => (a === "" ? 1 : b === "" ? -1 : (projectOf(s, a)?.name ?? "").localeCompare(projectOf(s, b)?.name ?? "")));
  }, [entries, added, s]);
  const total = (list: Entry[] | undefined) => (list || []).reduce((a, e) => a + durationOf(e), 0);
  const dayTotal = (day: string) => rows.reduce((a, p) => a + total(cells.get(`${p}|${day}`)), 0);
  const rowTotal = (p: string) => days.reduce((a, d) => a + total(cells.get(`${p}|${d}`)), 0);
  const weekTotal = rows.reduce((a, p) => a + rowTotal(p), 0);
  const shift = (n: number) => { const w = addDays(week, 7 * n); setWeek(w); setFocusDay(addDays(w, days.indexOf(focusDay) >= 0 ? days.indexOf(focusDay) : 0)); };

  const setCell = async (project: string, day: string, text: string): Promise<boolean> => {
    const list = cells.get(`${project}|${day}`) || [];
    const current = total(list);
    const sec = text.trim() === "" ? 0 : parseDuration(text);
    if (sec == null) { toast("Enter time like 1:30, 1.5 or 90m."); return false; }
    if (sec === current) return true;
    if (sec > MAX_SECONDS) { toast("That is more than 1,000 hours."); return false; }
    const p = projectOf(s, project);
    try {
      if (!list.length) {
        const start = zoned(day, 9 * 60, s.tz);
        await saveEntry(s, { description: "", project, tags: "[]", billable: Boolean(p?.billable), start, end: start + sec });
      } else if (list.length === 1) {
        const e = list[0];
        if (sec === 0) {
          await deleteEntry(s, e);
          toast("Entry deleted", { label: "Undo", run: () => void restoreEntry(s, e).catch((err) => toast(explain(err))) });
        } else {
          const { id: _id, ...rest } = e;
          await saveEntry(s, { ...rest, end: e.start + sec }, e);
        }
      } else if (sec > current) {
        const start = Math.max(...list.map((e) => e.end));
        await saveEntry(s, { description: "", project, tags: "[]", billable: Boolean(p?.billable), start, end: start + sec - current });
      } else {
        toast(`This day has ${list.length} entries for this project. Change them on the Timer page.`);
        return false;
      }
      return true;
    } catch (err) {
      toast(explain(err));
      return false;
    }
  };

  const range = `${short(days[0], { month: "short", day: "numeric" })} to ${short(days[6], { month: "short", day: "numeric", year: "numeric" })}`;
  const thisWeek = weekOf(todayDay, s.ws.weekStart);

  return (
    <div className="page">
      <div className="page-head">
        <h1 className="page-title">Timesheet</h1>
        <div className="week-nav">
          <button type="button" className="icon-btn" onClick={() => shift(-1)} aria-label="Previous week"><Left /></button>
          <span className="week-range">{range}</span>
          <button type="button" className="icon-btn" onClick={() => shift(1)} aria-label="Next week"><Right /></button>
          {week !== thisWeek && <button type="button" className="btn btn-line btn-sm" onClick={() => { setWeek(thisWeek); setFocusDay(todayDay); }}>This week</button>}
        </div>
      </div>
      <p className="muted small sheet-total">Week total <strong>{hm(weekTotal)}</strong></p>

      {!entries ? <div className="loading"><span className="spinner" /> Loading the week...</div> : (
        <>
          <div className="sheet-wide table-wrap">
            <table className="table sheet">
              <thead>
                <tr>
                  <th scope="col">Project</th>
                  {days.map((d) => <th key={d} scope="col" className={`num${d === todayDay ? " is-today" : ""}`}>{short(d, { weekday: "short" })}<span>{short(d, { day: "numeric" })}</span></th>)}
                  <th scope="col" className="num">Total</th>
                </tr>
              </thead>
              <tbody>
                {rows.map((p) => (
                  <tr key={p || "none"}>
                    <th scope="row"><ProjectLabel id={p} /></th>
                    {days.map((d) => <td key={d} className="num"><Cell value={total(cells.get(`${p}|${d}`))} count={(cells.get(`${p}|${d}`) || []).length} label={`${projectOf(s, p)?.name ?? "No project"}, ${short(d, { weekday: "long" })}`} onSave={(v) => setCell(p, d, v)} /></td>)}
                    <td className="num strong">{hm(rowTotal(p))}</td>
                  </tr>
                ))}
                <tr className="sheet-add">
                  <th scope="row" colSpan={9}><AddRow onAdd={(id) => setAdded((a) => [...a, id])} exclude={rows} /></th>
                </tr>
              </tbody>
              <tfoot>
                <tr>
                  <th scope="row">Total</th>
                  {days.map((d) => <td key={d} className="num">{hm(dayTotal(d))}</td>)}
                  <td className="num strong">{hm(weekTotal)}</td>
                </tr>
              </tfoot>
            </table>
          </div>

          <div className="sheet-narrow">
            <div className="day-tabs" role="tablist" aria-label="Day">
              {days.map((d) => (
                <button key={d} type="button" role="tab" aria-selected={d === focusDay} className={d === todayDay ? "is-today" : ""} onClick={() => setFocusDay(d)}>
                  <span>{short(d, { weekday: "narrow" })}</span><strong>{short(d, { day: "numeric" })}</strong><small>{hm(dayTotal(d))}</small>
                </button>
              ))}
            </div>
            <ul className="sheet-list">
              {rows.map((p) => (
                <li key={p || "none"}>
                  <ProjectLabel id={p} />
                  <Cell value={total(cells.get(`${p}|${focusDay}`))} count={(cells.get(`${p}|${focusDay}`) || []).length} label={`${projectOf(s, p)?.name ?? "No project"}, ${short(focusDay, { weekday: "long" })}`} onSave={(v) => setCell(p, focusDay, v)} />
                </li>
              ))}
              <li className="sheet-add"><AddRow onAdd={(id) => setAdded((a) => [...a, id])} exclude={rows} /></li>
            </ul>
            <p className="muted small">{short(focusDay, { weekday: "long", month: "long", day: "numeric" })}: <strong>{hm(dayTotal(focusDay))}</strong></p>
          </div>
          <p className="hint">Type a day's total, like 1:30 or 1.5. An empty cell gets an entry starting at 9:00. Clearing a cell deletes its entry.</p>
        </>
      )}
    </div>
  );
}

function Cell({ value, count, label, onSave }: { value: number; count: number; label: string; onSave: (v: string) => Promise<boolean> }) {
  const [text, setText] = useState<string | null>(null);
  const shown = text ?? (value ? hm(value) : "");
  const commit = async () => {
    if (text == null) return;
    await onSave(text);
    setText(null);
  };
  return (
    <input
      className={`cell${count > 1 ? " multi" : ""}`}
      value={shown}
      inputMode="decimal"
      placeholder="0:00"
      aria-label={label}
      title={count > 1 ? `${count} entries` : undefined}
      onFocus={(e) => e.currentTarget.select()}
      onChange={(e) => setText(e.target.value)}
      onBlur={() => void commit()}
      onKeyDown={(e) => { if (e.key === "Enter") (e.currentTarget as HTMLInputElement).blur(); if (e.key === "Escape") { setText(null); (e.currentTarget as HTMLInputElement).blur(); } }}
    />
  );
}

function AddRow({ onAdd, exclude }: { onAdd: (id: string) => void; exclude: string[] }) {
  return (
    <div className="add-row">
      <ProjectPicker value="" onChange={(id) => { if (!exclude.includes(id)) onAdd(id); }} />
      <span className="muted small">Add a project row</span>
    </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