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