ui

tcmts.examples.reminders.ui · #j775d54x · tsx file

// tcm: namespace reminders
// The app: React components, in the browser (they keep state, and read the device's database in effects). The
// list a page shows is its path (`/today`, `/lists/<id>`), so the back button, a shortcut on the home screen and a
// link all open the list they say, and a static build has a page for each smart list. On a phone it's a page at a time, the lists and then a list; on a wider screen, both.
import type { Color, List, Reminder, Smart } from "#codebase/reminders";
import {
  COLORS, counts, dayOf, deleteList, deleteReminder, dueLabel, isOverdue, isSmart, loadAll, onChange, quickAdd,
  remindersIn, saveList, saveReminder, SMART, stamp, styles, toggleDone,
} from "#codebase/reminders";
import { href, match, navigate, Stylesheet, useAppLinks, useAppUpdate, useLocation } from "#codebase/reminders/lib/react";
import { useEffect, useState } from "react";

/** What the device has: its lists and reminders (undefined until they're read). */
type Device = { lists: List[]; reminders: Reminder[] };

/**
 * Reminders, kept on this device: the lists (the smart ones, and yours), and the list the URL names. Every change is
 * shown at once and saved to the device's database; another tab or window of the app shows it too.
 */
export function App() {
  useAppLinks();
  const view = viewAt(useLocation().path);
  const [device, setDevice] = useDevice();
  const today = useToday();
  const change = {
    reminder(r: Reminder) {
      setDevice((d) => d && { ...d, reminders: [...d.reminders.filter((x) => x.id !== r.id), r] });
      void saveReminder(r);
    },
    removeReminder(id: string) {
      setDevice((d) => d && { ...d, reminders: d.reminders.filter((x) => x.id !== id) });
      void deleteReminder(id);
    },
    list(l: List) {
      setDevice((d) => d && { ...d, lists: [...d.lists.filter((x) => x.id !== l.id), l].sort((a, b) => a.created - b.created) });
      void saveList(l);
    },
    removeList(id: string) {
      setDevice((d) => d && { lists: d.lists.filter((x) => x.id !== id), reminders: d.reminders.filter((r) => r.list !== id) });
      void deleteList(id, device?.reminders ?? []);
      navigate("/");
    },
  };
  // (a wide screen shows a list beside the lists: Today, if the URL names none)
  const shown = view ?? "today";
  return (
    <div className="app" data-open={view ? "list" : "home"}>
      <Stylesheet css={styles} />
      <Home device={device} today={today} view={shown} onNewList={change.list} />
      <ListView key={shown} view={shown} device={device} today={today} change={change} />
      <Status />
    </div>
  );
}

/** The list a path shows: a smart list's (`/today`), or one of yours (`/lists/<id>`); none at `/`. */
function viewAt(path: string): string | undefined {
  const smart = SMART.find((s) => path === `/${s.id}`);
  return smart?.id ?? match("/lists/:id", path)?.id;
}

/** The device's lists and reminders, read again when another tab or window changes them. */
function useDevice() {
  const [device, setDevice] = useState<Device | undefined>(undefined);
  useEffect(() => {
    let live = true;
    const read = () => void loadAll().then((d) => live && setDevice(d));
    read();
    const stop = onChange(read);
    return () => {
      live = false;
      stop();
    };
  }, []);
  return [device, setDevice] as const;
}

/** Today, where the device is: again when the app comes back on screen (an installed app stays open overnight). */
function useToday(): string {
  const [today, setToday] = useState(() => dayOf(new Date()));
  useEffect(() => {
    const check = () => setToday(dayOf(new Date()));
    document.addEventListener("visibilitychange", check);
    const timer = setInterval(check, 60_000);
    return () => {
      document.removeEventListener("visibilitychange", check);
      clearInterval(timer);
    };
  }, []);
  return today;
}

/** The lists: the smart ones as tiles, then yours, each with how many reminders it has. */
function Home({ device, today, view, onNewList }: { device?: Device; today: string; view: string; onNewList: (l: List) => void }) {
  const n = device ? counts(device.reminders, device.lists, today) : {};
  const addList = () => {
    const used = new Set(device?.lists.map((l) => l.color));
    const list: List = { ...stamp(), name: "New List", color: COLORS.find((c) => !used.has(c)) ?? "blue" };
    onNewList(list);
    navigate(`/lists/${list.id}`);
  };
  return (
    <nav className="home" aria-label="Lists">
      <div className="tiles">
        {SMART.map((s) => (
          <a key={s.id} className={`tile ${s.color}`} href={href(`/${s.id}`)} aria-current={view === s.id ? "page" : undefined}>
            <span className={`icon ${s.color}`}>
              <Glyph name={s.id} />
            </span>
            <span className="count">{device ? (s.id === "completed" ? "" : n[s.id]) : ""}</span>
            <span className="name">{s.name}</span>
          </a>
        ))}
      </div>
      <h2>My Lists</h2>
      <ul className="group">
        {device?.lists.map((l) => (
          <li key={l.id}>
            <a href={href(`/lists/${l.id}`)} aria-current={view === l.id ? "page" : undefined}>
              <span className={`icon ${l.color}`}>
                <Glyph name="list" />
              </span>
              <span className="name">{l.name}</span>
              <span className="count">{n[l.id]}</span>
              <span className="chevron" aria-hidden="true">›</span>
            </a>
          </li>
        ))}
      </ul>
      <div className="bar">
        <button className="text" onClick={addList} disabled={!device}>
          Add List
        </button>
      </div>
    </nav>
  );
}

/** What changes the device's data (and saves it). */
type Change = {
  reminder(r: Reminder): void;
  removeReminder(id: string): void;
  list(l: List): void;
  removeList(id: string): void;
};

/**
 * A list's reminders, with a field for a new one. A reminder ticked off stays where it was, done, until the list is
 * left, as Apple's does: so a tick by mistake is untaken at once.
 */
function ListView({ view, device, today, change }: { view: string; device?: Device; today: string; change: Change }) {
  const [ticked, setTicked] = useState<ReadonlySet<string>>(new Set());
  const smart = SMART.find((s) => s.id === view);
  const list = device?.lists.find((l) => l.id === view);
  const color: Color = smart?.color ?? list?.color ?? "blue";
  if (device && !smart && !list)
    return (
      <main className="list">
        <Back />
        <p className="empty">This list isn't here anymore.</p>
      </main>
    );
  const all = device?.reminders ?? [];
  // (as if not done, the ones ticked here: they stay until the list is left)
  const shown = remindersIn(view, all.map((r) => (ticked.has(r.id) ? { ...r, done: undefined } : r)), today).map(
    (r) => all.find((x) => x.id === r.id)!,
  );
  const listOf = (r: Reminder) => device?.lists.find((l) => l.id === r.list);
  const tick = (r: Reminder) => {
    if (!r.done && view !== "completed") setTicked((t) => new Set(t).add(r.id));
    change.reminder(toggleDone(r));
  };
  return (
    <main className={`list ${color}`}>
      <Back />
      <header>
        {list ? (
          <input
            className="title"
            aria-label="List name"
            value={list.name}
            onChange={(e) => change.list({ ...list, name: e.target.value })}
          />
        ) : (
          <h1 className="title">{smart?.name}</h1>
        )}
        <span className="count">{device ? shown.filter((r) => !r.done).length || "" : ""}</span>
      </header>
      {list && (
        <fieldset className="colors">
          <legend>Color</legend>
          {COLORS.map((c) => (
            <input
              key={c}
              type="radio"
              name="color"
              className={`swatch ${c}`}
              checked={list.color === c}
              aria-label={c}
              onChange={() => change.list({ ...list, color: c })}
            />
          ))}
          <button className="text danger" onClick={() => change.removeList(list.id)}>
            Delete List
          </button>
        </fieldset>
      )}
      <ul className="reminders">
        {shown.map((r) => (
          <Row
            key={r.id}
            r={r}
            today={today}
            color={listOf(r)?.color ?? color}
            listName={smart ? listOf(r)?.name : undefined}
            onTick={() => tick(r)}
            onChange={change.reminder}
            onDelete={() => change.removeReminder(r.id)}
          />
        ))}
        {device && !shown.length && view !== "completed" && <li className="empty">No Reminders</li>}
        {device && !shown.length && view === "completed" && <li className="empty">Nothing done yet</li>}
      </ul>
      {device && view !== "completed" && (
        <NewReminder
          view={view}
          today={today}
          list={list?.id ?? device.lists[0]?.id}
          onAdd={(r) => change.reminder(r)}
        />
      )}
    </main>
  );
}

/** Back to the lists: on a phone, where a list is a page of its own. */
function Back() {
  return (
    <a className="back" href={href("/")}>
      ‹ Lists
    </a>
  );
}

/** A reminder: its tick, its title and notes (edited where they are), when it's due, and whether it's flagged. */
function Row({
  r, today, color, listName, onTick, onChange, onDelete,
}: {
  r: Reminder;
  today: string;
  color: Color;
  listName?: string;
  onTick: () => void;
  onChange: (r: Reminder) => void;
  onDelete: () => void;
}) {
  return (
    <li className={`row${r.done ? " done" : ""}`}>
      <input type="checkbox" className={`tick ${color}`} checked={!!r.done} aria-label={`Done: ${r.title}`} onChange={onTick} />
      <div className="body">
        <input className="title" aria-label="Title" value={r.title} onChange={(e) => onChange({ ...r, title: e.target.value })} />
        <input className="notes" aria-label="Notes" placeholder="Notes" value={r.notes} onChange={(e) => onChange({ ...r, notes: e.target.value })} />
        {(r.due || listName) && (
          <div className="meta">
            {r.due && <span className={isOverdue(r, today) ? "overdue" : undefined}>{dueLabel(r.due, today)}</span>}
            {listName && <span>{listName}</span>}
          </div>
        )}
      </div>
      <label className="due" title="Due">
        <Glyph name="scheduled" />
        <input type="date" aria-label="Due" value={r.due ?? ""} onChange={(e) => onChange({ ...r, ...(e.target.value ? { due: e.target.value } : { due: undefined }) })} />
      </label>
      <button className={`flag${r.flagged ? " on" : ""}`} aria-pressed={r.flagged} aria-label="Flagged" onClick={() => onChange({ ...r, flagged: !r.flagged })}>
        <Glyph name="flagged" />
      </button>
      <button className="delete" aria-label="Delete" onClick={onDelete}>
        ×
      </button>
    </li>
  );
}

/**
 * The field a new reminder is typed in: a day at its end and `!` are read ({@link quickAdd}). In a smart list, it
 * goes in the first of your lists, due today in Today and Scheduled, flagged in Flagged.
 */
function NewReminder({ view, today, list, onAdd }: { view: string; today: string; list?: string; onAdd: (r: Reminder) => void }) {
  const [text, setText] = useState("");
  const read = quickAdd(text, today);
  const smart: Smart | undefined = isSmart(view) ? view : undefined;
  const add = () => {
    if (!read.title || !list) return;
    const due = read.due ?? (smart === "today" || smart === "scheduled" ? today : undefined);
    onAdd({
      ...stamp(),
      list,
      title: read.title,
      notes: "",
      ...(due ? { due } : {}),
      flagged: read.flagged || smart === "flagged",
    });
    setText("");
  };
  return (
    <form
      className="new"
      onSubmit={(e) => {
        e.preventDefault();
        add();
      }}
    >
      <span className="plus" aria-hidden="true">
        +
      </span>
      <input aria-label="New Reminder" placeholder="New Reminder" value={text} onChange={(e) => setText(e.target.value)} />
      {(read.due || read.flagged) && (
        <span className="hint">
          {read.due && dueLabel(read.due, today)}
          {read.flagged && " ⚑"}
        </span>
      )}
    </form>
  );
}

/** What the app says about itself: that it's offline (and works), that a new version is ready, that it installs. */
function Status() {
  const update = useAppUpdate();
  const online = useOnline();
  const install = useInstall();
  return (
    <output className="status">
      {!online && <p className="pill">Offline · everything still works</p>}
      {update.ready && (
        <p className="pill">
          A new version is ready <button onClick={update.reload}>Reload</button>
        </p>
      )}
      {install && (
        <button className="pill" onClick={install}>
          Install Reminders
        </button>
      )}
    </output>
  );
}

/** Whether the device is online. */
function useOnline(): boolean {
  const [online, setOnline] = useState(true);
  useEffect(() => {
    const check = () => setOnline(navigator.onLine);
    check();
    addEventListener("online", check);
    addEventListener("offline", check);
    return () => {
      removeEventListener("online", check);
      removeEventListener("offline", check);
    };
  }, []);
  return online;
}

/** Install the app, where the browser offers it to the page (Chrome's `beforeinstallprompt`): undefined elsewhere. */
function useInstall(): (() => void) | undefined {
  const [offer, setOffer] = useState<{ prompt(): Promise<unknown> } | undefined>(undefined);
  useEffect(() => {
    const offered = (e: Event) => {
      e.preventDefault();
      setOffer(e as unknown as { prompt(): Promise<unknown> });
    };
    const installed = () => setOffer(undefined);
    addEventListener("beforeinstallprompt", offered);
    addEventListener("appinstalled", installed);
    return () => {
      removeEventListener("beforeinstallprompt", offered);
      removeEventListener("appinstalled", installed);
    };
  }, []);
  return offer && (() => void offer.prompt().then(() => setOffer(undefined)));
}

/** The app's icons, drawn as lines: the smart lists', a list's, and a flag. */
function Glyph({ name }: { name: Smart | "list" }) {
  const paths: Record<typeof name, string> = {
    today: "M4 6.5A2.5 2.5 0 0 1 6.5 4h11A2.5 2.5 0 0 1 20 6.5v11a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 4 17.5zM4 9h16M8 2.5v3M16 2.5v3M10 14h4",
    scheduled: "M4 6.5A2.5 2.5 0 0 1 6.5 4h11A2.5 2.5 0 0 1 20 6.5v11a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 4 17.5zM4 9h16M8 2.5v3M16 2.5v3M8 13h.01M12 13h.01M16 13h.01M8 16.5h.01M12 16.5h.01",
    all: "M3.5 13.5 6 5h12l2.5 8.5V19a1 1 0 0 1-1 1h-15a1 1 0 0 1-1-1zM3.5 13.5H8l1.5 2.5h5l1.5-2.5h4.5",
    flagged: "M6 21V4M6 4.5h11l-2.5 4 2.5 4H6",
    completed: "M5 12.5l4.5 4.5L19 7",
    list: "M9.5 7H19M9.5 12H19M9.5 17H19M5 7h.01M5 12h.01M5 17h.01",
  };
  return (
    <svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
      <path d={paths[name]} />
    </svg>
  );
}

used by