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/Shell.tsx
// The app around every page: routes, the running timer in the header and the tab title, the tab bar
// on phones and the side navigation on wide screens.
import { useEffect, type ReactNode } from "react";
import { createPortal } from "react-dom";
import { clock } from "../functions/lib/time";
import { navigate } from "./lib/router";
import { takeLoginError, explain } from "./lib/bounded";
import { useSession, takeMoved } from "./lib/session";
import { useLiveStore, StoreProvider, useStore, stopTimer, syncJoined, updateWorkspace, projectOf } from "./lib/data";
import { setJoinedStatus } from "./joined";
import { toast, Clock, Grid, Chart, Folder, Gear, Stop } from "./ui";
import { TimerPage } from "./TimerPage";
import { useNow } from "./lib/hooks";
import { Timesheet } from "./Timesheet";
import { Reports } from "./Reports";
import { Projects } from "./Projects";
import { Settings } from "./Settings";
import { Join } from "./Join";

export type Route =
  | { name: "timer" } | { name: "timesheet" } | { name: "reports" } | { name: "projects" } | { name: "settings" }
  | { name: "join"; share: string };

export function routeOf(path: string): Route | null {
  const p = path.replace(/\/+$/, "").split("/").filter(Boolean);
  if (!p.length) return null;
  if (p[0] === "join" && /^[A-Za-z0-9]{20}$/.test(p[1] || "")) return { name: "join", share: p[1] };
  if (p[0] === "timesheet" || p[0] === "reports" || p[0] === "projects" || p[0] === "settings") return { name: p[0] };
  return { name: "timer" };
}

const NAV = [
  { href: "/timer", label: "Timer", Icon: Clock, name: "timer" },
  { href: "/timesheet", label: "Timesheet", Icon: Grid, name: "timesheet" },
  { href: "/reports", label: "Reports", Icon: Chart, name: "reports" },
  { href: "/projects", label: "Projects", Icon: Folder, name: "projects" },
  { href: "/settings", label: "Settings", Icon: Gear, name: "settings" },
];
const TITLES: Record<Route["name"], string> = { timer: "Timer", timesheet: "Timesheet", reports: "Reports", projects: "Projects", settings: "Settings", join: "Join a project" };

function go(e: React.MouseEvent, href: string) {
  e.preventDefault();
  navigate(href);
}

function Nav({ route, className }: { route: Route; className: string }) {
  return (
    <nav className={className} aria-label="Time">
      {NAV.map(({ href, label, Icon, name }) => (
        <a key={href} href={href} onClick={(e) => go(e, href)} aria-current={route.name === name ? "page" : undefined}>
          <Icon /> <span>{label}</span>
        </a>
      ))}
    </nav>
  );
}

/** The running timer in the header (and the tab title), on every page. */
function HeadTimer({ route }: { route: Route }) {
  const s = useStore();
  const t = s.timer;
  const now = useNow(Boolean(t));
  const label = t ? t.description || projectOf(s, t.project)?.name || "Timer running" : "";
  useEffect(() => {
    document.title = t ? `${clock(now - t.start)} ${label} | Time` : `${TITLES[route.name]} | Time`;
  }, [t, now, label, route.name]);
  const slot = document.getElementById("head-timer");
  if (!slot || !t) return null;
  return createPortal(
    <div className="head-timer">
      <a href="/timer" onClick={(e) => go(e, "/timer")} className="head-clock" aria-label={`Timer running: ${clock(now - t.start)}, ${label}`}>
        <span className="pulse" aria-hidden="true" />
        <span className="head-time">{clock(now - t.start)}</span>
        <span className="head-label">{label}</span>
      </a>
      <button type="button" className="icon-btn head-stop" aria-label="Stop the timer" onClick={() => void stopTimer(s).catch((e) => toast(explain(e)))}><Stop size={14} /></button>
    </div>,
    slot,
  );
}

/** Once per visit: days follow this device's time zone (the API uses it too), and joined projects are checked. */
function Upkeep() {
  const s = useStore();
  useEffect(() => {
    if (s.ws.tz !== s.tz) void updateWorkspace(s, { tz: s.tz }).catch(() => null);
    void syncJoined(s).then(setJoinedStatus).catch(() => null);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [s.id]);
  return null;
}

function Loaded({ route }: { route: Route }) {
  let page: ReactNode;
  if (route.name === "timesheet") page = <Timesheet />;
  else if (route.name === "reports") page = <Reports />;
  else if (route.name === "projects") page = <Projects />;
  else if (route.name === "settings") page = <Settings />;
  else if (route.name === "join") page = <Join share={route.share} />;
  else page = <TimerPage />;
  return (
    <div className="shell">
      <HeadTimer route={route} />
      <Upkeep />
      <Nav route={route} className="side-nav" />
      <div className="shell-main" id="main">{page}</div>
      <Nav route={route} className="tab-bar" />
    </div>
  );
}

function Live({ route, session }: { route: Route; session: NonNullable<ReturnType<typeof useSession>["session"]> }) {
  const { store, missing } = useLiveStore(session);
  if (missing) return <div className="wrap page-pad"><p className="notice notice-error">This workspace could not be opened. Reload the page to try again.</p></div>;
  if (!store) return <Opening />;
  return <StoreProvider value={store}><Loaded route={route} /></StoreProvider>;
}

const Opening = () => <div className="wrap page-pad"><div className="loading"><span className="spinner" /> Opening Time...</div></div>;

export function Shell({ route }: { route: Route }) {
  const { session, error } = useSession();
  useEffect(() => {
    if (!session) return;
    const moved = takeMoved();
    if (moved === "adopted") toast("Your time is now in your account, on every device you sign in on.");
    if (moved === "copied") toast("This browser's time was added to your account.");
    const err = takeLoginError();
    if (err) toast(err);
  }, [session]);
  if (error) return <div className="wrap page-pad"><p className="notice notice-error">{explain(error)} <button type="button" className="link-btn" onClick={() => location.reload()}>Try again</button></p></div>;
  if (!session) return <Opening />;
  return <Live route={route} session={session} />;
}

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