Margin Calendar: a Google Calendar client for desktop and phone

Tauri 2, React 19 and zustand on the front, Rust behind. Rust owns auth,
all HTTP to Google, the SQLite store, the sync loop, recurrence expansion
and timezone maths. TypeScript owns rendering and never talks to Google,
which keeps the content security policy locked to ipc:.

Week, day and agenda views, and no month view: it would be a second layout
engine, and the fit and fold logic that makes a day fit the window without
scrolling is the whole point of the app.

Runs on macOS, Linux, Android and iOS. Desktop catches Google's OAuth
redirect on a loopback port. A phone cannot, and Google rejects loopback
for mobile client types anyway, so it redirects to a custom URI scheme and
needs its own public OAuth clients, which docs/mobile.md covers. Refresh
tokens are sealed with XChaCha20-Poly1305 in the app data directory on
every platform, with no OS credential store in the picture.

On a phone the chrome becomes a top bar and a bottom tab bar, overlays
become sheets, hover affordances become taps, and dragging out an event
waits for a long press. Navigation moves one day at a time everywhere,
a swipe included.
This commit is contained in:
pj committed 2026-08-12 17:09:21 +05:30
commit 661100dfdc
243 files changed
+35200

No files matched your search

+159
View File
@@ -0,0 +1,159 @@
// Connecting and disconnecting Google accounts. The connect flow lives entirely in `useAccounts`:
// this panel calls it and then shows what is happening, including the two escape hatches for when
// the system browser did not open by itself.
//
// Disconnecting is the destructive one. It revokes the token and wipes every row belonging to the
// account, so it asks first and says what goes.
import { useEffect, useState } from "react";
import { useEscapeLayer } from "../escape";
import type { Account } from "../ipc";
import { useAccounts } from "../store/useAccounts";
import { useCalendarView } from "../store/useCalendarView";
import { useOverlays } from "../store/useOverlays";
import { Icon } from "./Icon";
import { Confirm, Sheet } from "./overlayShell";
const COPY = "M9 9h10v11H9zM5 15H4V4h11v1";
const OPEN = "M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6M15 3h6v6M10 14 21 3";
export function Accounts() {
const open = useOverlays((s) => s.open);
const close = useOverlays((s) => s.close);
const accounts = useAccounts((s) => s.accounts);
const calendars = useAccounts((s) => s.calendars);
const phase = useAccounts((s) => s.phase);
const error = useAccounts((s) => s.error);
const authUrl = useAccounts((s) => s.authUrl);
const connect = useAccounts((s) => s.connect);
const cancelConnect = useAccounts((s) => s.cancelConnect);
const openAuthUrl = useAccounts((s) => s.openAuthUrl);
const copyAuthUrl = useAccounts((s) => s.copyAuthUrl);
const disconnect = useAccounts((s) => s.disconnect);
const refresh = useAccounts((s) => s.refresh);
const [confirming, setConfirming] = useState<Account | null>(null);
const showing = open === "accounts";
const connecting = phase === "connecting";
// While the browser has the flow, Escape belongs to the flow rather than to the panel.
useEscapeLayer(showing && connecting, cancelConnect);
useEscapeLayer(showing && confirming !== null, () => setConfirming(null));
useEffect(() => {
if (showing) void refresh();
}, [showing, refresh]);
const remove = async (account: Account) => {
setConfirming(null);
await disconnect(account.id);
void useCalendarView.getState().load();
};
const countFor = (accountId: string) => calendars.filter((c) => c.accountId === accountId).length;
return (
<Sheet
open={showing}
title="Accounts"
onClose={close}
foot={
confirming ? undefined : (
<button
type="button"
className="panel-button"
data-variant="primary"
data-autofocus=""
disabled={connecting || phase === "working"}
onClick={() => void connect()}
>
Connect an account
</button>
)
}
>
{confirming ? (
<Confirm
title={`Disconnect ${confirming.email}?`}
body={
<p>
The token is revoked and every calendar, event and pending write stored on this
computer for that account is deleted. Nothing changes in Google Calendar itself, and
you can connect the account again afterwards.
</p>
}
confirmLabel="Disconnect"
busy={phase === "working"}
onConfirm={() => void remove(confirming)}
onCancel={() => setConfirming(null)}
/>
) : (
<>
{connecting && (
<div className="connect-pending">
<span className="connect-line">Waiting for Google in your browser.</span>
<span className="connect-note">
{authUrl
? "If nothing opened, use the link. Finish there and this comes back on its own."
: "Building the consent link."}
</span>
<div className="connect-actions">
<button
type="button"
className="panel-button"
disabled={!authUrl}
onClick={openAuthUrl}
>
<Icon d={OPEN} size={14} />
Open the link
</button>
<button
type="button"
className="panel-button"
disabled={!authUrl}
onClick={() => void copyAuthUrl()}
>
<Icon d={COPY} size={14} />
Copy the link
</button>
<button type="button" className="panel-button" onClick={cancelConnect}>
Cancel
</button>
</div>
</div>
)}
{phase === "error" && error && <p className="panel-note" data-error="">{error}</p>}
{accounts.length === 0 && !connecting ? (
<p className="panel-note">
No account is connected, so there is nothing to show on the grid yet.
</p>
) : (
accounts.map((account) => (
<div className="account-row" key={account.id}>
<span className="account-text">
<span className="account-email">{account.email}</span>
<span className="account-meta">
{account.connected ? `${countFor(account.id)} calendar${countFor(account.id) === 1 ? "" : "s"}` : "Needs reconnecting"}
</span>
</span>
<button
type="button"
className="panel-button"
data-variant="danger"
disabled={phase === "working"}
onClick={() => setConfirming(account)}
>
Disconnect
</button>
</div>
))
)}
</>
)}
</Sheet>
);
}
export default Accounts;
+95
View File
@@ -0,0 +1,95 @@
// The list itself, shared by the agenda view and by search results, which are the same thing shown
// for a different set of instances.
//
// No virtualisation and no measurement: thirty days is a few hundred rows and the DOM is fine with
// that. The only thing this component does beyond render is keep the selection on screen, because
// the selection moves from the keyboard and the list is the one part of the app that scrolls.
import { useEffect, useRef } from "react";
import type { InstanceKey } from "../ipc";
import { addDays, dayName, monthName, today } from "../time";
import { keyId, keyOf, sameKey } from "./GridModel";
import { AgendaRow } from "./AgendaRow";
import { gapDates, gapLabel, type AgendaSection } from "./AgendaModel";
const NO_TERMS: readonly string[] = [];
interface AgendaListProps {
sections: readonly AgendaSection[];
selected: InstanceKey | null;
onSelect: (key: InstanceKey) => void;
onOpen?: (key: InstanceKey) => void;
/** Lowercased search terms to mark in each summary. */
terms?: readonly string[];
/** A quiet line above the list: loading, an error, or why there is nothing to show. */
note?: string | null;
}
export function AgendaList({
sections,
selected,
onSelect,
onOpen,
terms = NO_TERMS,
note,
}: AgendaListProps) {
const scrollRef = useRef<HTMLDivElement | null>(null);
const selectedId = selected ? keyId(selected) : null;
const todayMs = today();
useEffect(() => {
if (!selectedId) return;
const row = scrollRef.current?.querySelector(`[data-id="${CSS.escape(selectedId)}"]`);
row?.scrollIntoView({ block: "nearest" });
}, [selectedId]);
return (
<div className="agenda-scroll" ref={scrollRef}>
<div className="agenda-inner">
{note && <p className="agenda-note">{note}</p>}
{sections.map((section) =>
section.kind === "gap" ? (
<div
key={section.key}
className="agenda-gap"
data-past={section.to <= todayMs || undefined}
>
<span className="agenda-gap-dates">{gapDates(section)}</span>
<span className="agenda-gap-rule" />
<span className="agenda-gap-text">{gapLabel(section)}</span>
</div>
) : (
<section
key={section.key}
className="agenda-day"
data-today={section.dayStart === todayMs || undefined}
data-past={addDays(section.dayStart, 1) <= todayMs || undefined}
>
<h3 className="agenda-day-head">
<span className="agenda-day-name">{dayName(section.dayStart)}</span>
<span className="agenda-day-date">{new Date(section.dayStart).getDate()}</span>
{section.showMonth && (
<span className="agenda-day-month">{monthName(section.dayStart)}</span>
)}
<span className="agenda-day-rule" />
<span className="agenda-day-count">{section.items.length}</span>
</h3>
<div className="agenda-rows">
{section.items.map((item) => (
<AgendaRow
key={item.id}
item={item}
terms={terms}
selected={sameKey(selected, keyOf(item.instance))}
onSelect={onSelect}
onOpen={onOpen}
/>
))}
</div>
</section>
),
)}
</div>
</div>
);
}
+265
View File
@@ -0,0 +1,265 @@
import { describe, expect, it } from "vitest";
import type { Instance } from "../ipc";
import { addDays, startOfDay, toDateOnly, toOffsetIso } from "../time";
import {
buildAgenda,
buildSearch,
countItems,
gapDates,
gapLabel,
instanceRange,
matchesQuery,
queryTerms,
splitMatch,
timeLabel,
type AgendaDay,
type AgendaGap,
} from "./AgendaModel";
const at = (y: number, m: number, d: number, h = 0, min = 0) => new Date(y, m - 1, d, h, min).getTime();
function timed(id: string, startMs: number, endMs: number, over: Partial<Instance> = {}): Instance {
return {
colorId: null,
eventId: id,
calendarId: "cal",
accountId: "acc",
originalStart: null,
start: toOffsetIso(startMs),
end: toOffsetIso(endMs),
startMs,
endMs,
allDay: false,
summary: id,
description: null,
location: null,
status: "confirmed",
recurring: false,
colorHex: "#7986cb",
etag: null,
organizer: null,
attendees: [],
conference: null,
readOnly: false,
pending: false,
...over,
};
}
function whole(id: string, from: number, days = 1, over: Partial<Instance> = {}): Instance {
const startMs = startOfDay(from);
const endMs = addDays(startMs, days);
return timed(id, startMs, endMs, {
allDay: true,
start: toDateOnly(startMs),
end: toDateOnly(endMs),
...over,
});
}
const days = (sections: readonly { kind: string }[]) => sections.filter((s) => s.kind === "day") as AgendaDay[];
const gaps = (sections: readonly { kind: string }[]) => sections.filter((s) => s.kind === "gap") as AgendaGap[];
describe("buildAgenda", () => {
const monday = at(2026, 8, 10);
it("gives every day of the span a section, in order", () => {
const sections = buildAgenda([timed("a", at(2026, 8, 10, 9), at(2026, 8, 10, 10))], monday, addDays(monday, 3));
expect(sections.map((s) => s.kind)).toEqual(["day", "gap", "gap"]);
expect(gaps(sections).map((g) => g.days)).toEqual([1, 1]);
});
it("collapses a run of three or more empty days into one row", () => {
const sections = buildAgenda(
[
timed("a", at(2026, 8, 10, 9), at(2026, 8, 10, 10)),
timed("b", at(2026, 8, 16, 9), at(2026, 8, 16, 10)),
],
monday,
addDays(monday, 7),
);
expect(sections.map((s) => s.kind)).toEqual(["day", "gap", "day"]);
const [gap] = gaps(sections);
expect(gap.days).toBe(5);
expect(gap.from).toBe(addDays(monday, 1));
expect(gap.to).toBe(addDays(monday, 6));
expect(gap.next).toBe(addDays(monday, 6));
expect(gapLabel(gap)).toBe("nothing until Sunday");
});
it("leaves a run that reaches the end of the span with nowhere to point at", () => {
const sections = buildAgenda([timed("a", at(2026, 8, 10, 9), at(2026, 8, 10, 10))], monday, addDays(monday, 5));
const [gap] = gaps(sections);
expect(gap.next).toBeNull();
expect(gap.days).toBe(4);
expect(gapLabel(gap)).toBe("nothing for 4 days");
});
it("sorts all-day before timed, then by start, then longest first", () => {
const sections = buildAgenda(
[
timed("noon", at(2026, 8, 10, 12), at(2026, 8, 10, 13)),
timed("morning", at(2026, 8, 10, 9), at(2026, 8, 10, 10)),
timed("long", at(2026, 8, 10, 9), at(2026, 8, 10, 17)),
whole("holiday", monday),
],
monday,
addDays(monday, 1),
);
expect(days(sections)[0].items.map((i) => i.instance.eventId)).toEqual([
"holiday",
"long",
"morning",
"noon",
]);
});
it("repeats a multi-day event under each day it covers, clipped", () => {
const sections = buildAgenda([whole("trip", monday, 3)], monday, addDays(monday, 5));
expect(sections.map((s) => s.kind)).toEqual(["day", "day", "day", "gap", "gap"]);
const middle = days(sections)[1].items[0];
expect(middle.continued).toBe(true);
expect(middle.continues).toBe(true);
expect(middle.startMs).toBe(addDays(monday, 1));
expect(timeLabel(middle)).toBe("all day");
});
it("puts an event that crosses midnight on both days", () => {
const sections = buildAgenda(
[timed("late", at(2026, 8, 10, 22), at(2026, 8, 11, 1))],
monday,
addDays(monday, 2),
);
const [first, second] = days(sections);
expect(timeLabel(first.items[0])).toBe("from 10pm");
expect(timeLabel(second.items[0])).toBe("until 1am");
});
it("drops cancelled instances, so a day holding only one reads as empty", () => {
const sections = buildAgenda(
[timed("gone", at(2026, 8, 10, 9), at(2026, 8, 10, 10), { status: "cancelled" })],
monday,
addDays(monday, 1),
);
expect(sections.map((s) => s.kind)).toEqual(["gap"]);
});
it("names the month on the first section and again when it changes", () => {
const start = at(2026, 8, 30);
const sections = buildAgenda(
[
timed("a", at(2026, 8, 30, 9), at(2026, 8, 30, 10)),
timed("b", at(2026, 9, 1, 9), at(2026, 9, 1, 10)),
],
start,
addDays(start, 3),
);
expect(sections.map((s) => s.showMonth)).toEqual([true, false, true]);
});
it("survives an empty span", () => {
expect(buildAgenda([], monday, monday)).toEqual([]);
});
});
describe("labels", () => {
const monday = at(2026, 8, 10);
it("says when without an axis", () => {
const sections = buildAgenda(
[
timed("a", at(2026, 8, 10, 9), at(2026, 8, 10, 10, 30)),
timed("point", at(2026, 8, 10, 14), at(2026, 8, 10, 14)),
],
monday,
addDays(monday, 1),
);
const items = days(sections)[0].items;
expect(timeLabel(items[0])).toBe("9am to 10:30am");
expect(timeLabel(items[1])).toBe("2pm");
});
it("writes a gap's dates the way a heading would", () => {
const start = at(2026, 8, 30);
const sections = buildAgenda([timed("a", at(2026, 9, 3, 9), at(2026, 9, 3, 10))], start, addDays(start, 5));
const [gap] = gaps(sections);
expect(gapDates(gap)).toBe("Sun 30 August to Wed 2 September");
});
it("drops the month from a single quiet day already under its heading", () => {
const sections = buildAgenda(
[timed("a", at(2026, 8, 10, 9), at(2026, 8, 10, 10))],
monday,
addDays(monday, 2),
);
const [gap] = gaps(sections);
expect(gap.showMonth).toBe(false);
expect(gapDates(gap)).toBe("Tue 11");
expect(gapLabel(gap)).toBe("nothing");
});
});
describe("instanceRange", () => {
it("reads an all-day boundary out of the date-only strings", () => {
const instance = whole("holiday", at(2026, 8, 10), 2);
expect(instanceRange(instance)).toEqual({ startMs: at(2026, 8, 10), endMs: at(2026, 8, 12) });
});
it("falls back to the epoch fields when the date-only strings are not there", () => {
const instance = timed("a", at(2026, 8, 10, 9), at(2026, 8, 10, 10), { allDay: true, start: "", end: "" });
expect(instanceRange(instance)).toEqual({ startMs: at(2026, 8, 10, 9), endMs: at(2026, 8, 10, 10) });
});
});
describe("search", () => {
const monday = at(2026, 8, 10);
const set = [
timed("a", at(2026, 8, 10, 9), at(2026, 8, 10, 10), { summary: "Lunch with Sam" }),
timed("b", at(2026, 8, 12, 9), at(2026, 8, 12, 10), { summary: "Standup", location: "Sam's desk" }),
timed("c", at(2026, 8, 12, 11), at(2026, 8, 12, 12), { summary: "Retro", description: "with sam" }),
timed("d", at(2026, 8, 13, 9), at(2026, 8, 13, 10), { summary: "Dentist" }),
timed("e", at(2026, 8, 14, 9), at(2026, 8, 14, 10), { summary: "Sam", status: "cancelled" }),
];
it("matches summary, location and description, and groups by day", () => {
const sections = buildSearch(set, "sam");
expect(sections.map((s) => (s as AgendaDay).dayStart)).toEqual([monday, at(2026, 8, 12)]);
expect(countItems(sections)).toBe(3);
});
it("narrows with a second term rather than widening", () => {
expect(countItems(buildSearch(set, "sam lunch"))).toBe(1);
expect(countItems(buildSearch(set, "sam dentist"))).toBe(0);
});
it("has nothing to show for an empty query", () => {
expect(buildSearch(set, " ")).toEqual([]);
expect(matchesQuery(set[0], queryTerms(""))).toBe(false);
});
it("shows a multi-day hit once, on the day it starts", () => {
const sections = buildSearch([whole("trip", monday, 4, { summary: "Trip to Rome" })], "rome");
expect(sections).toHaveLength(1);
expect(countItems(sections)).toBe(1);
});
});
describe("splitMatch", () => {
it("cuts around every occurrence, keeping the original case", () => {
expect(splitMatch("Sam and sam", ["sam"])).toEqual([
{ text: "Sam", hit: true },
{ text: " and ", hit: false },
{ text: "sam", hit: true },
]);
});
it("prefers the longest term at a position", () => {
expect(splitMatch("standup", ["stand", "standup"])).toEqual([{ text: "standup", hit: true }]);
});
it("leaves text alone when nothing matches", () => {
expect(splitMatch("Dentist", ["sam"])).toEqual([{ text: "Dentist", hit: false }]);
expect(splitMatch("Dentist", [])).toEqual([{ text: "Dentist", hit: false }]);
expect(splitMatch("", ["sam"])).toEqual([{ text: "", hit: false }]);
});
});
+304
View File
@@ -0,0 +1,304 @@
// Pure model for the agenda: cutting a span into day sections, collapsing runs of empty days into
// one line, and the substring matching that search results are marked with.
//
// Nothing here holds state or touches the DOM. It is a `.tsx` file only because the agenda owns
// `Agenda*.tsx` and nothing else, which is the same reason `GridModel.tsx` is one.
import type { Instance } from "../ipc";
import { addDays, dayName, formatTime, monthName, parseDateOnly, startOfDay } from "../time";
import { keyId, keyOf } from "./GridModel";
/**
* A run of empty days this long or longer collapses into a single line. One and two day runs stay
* as a row each, because a quiet weekend is a shape you want to keep seeing; a fortnight of
* nothing is one fact and deserves one row.
*/
export const COLLAPSE_DAYS = 3;
/** One instance as it appears on one day, clipped to that day. */
export interface AgendaItem {
instance: Instance;
id: string;
/** Clipped to the day this row sits under. */
startMs: number;
endMs: number;
/** It started before this day. */
continued: boolean;
/** It runs past the end of this day. */
continues: boolean;
}
export interface AgendaDay {
kind: "day";
key: string;
dayStart: number;
items: AgendaItem[];
/** The month changed here, so the heading says which one. */
showMonth: boolean;
}
/** A run of days with nothing in them. A run is information, so it gets a row rather than a skip. */
export interface AgendaGap {
kind: "gap";
key: string;
from: number;
/** Exclusive, at local midnight. */
to: number;
days: number;
/** The next day in the span that has something, or null when the span simply ends. */
next: number | null;
showMonth: boolean;
}
export type AgendaSection = AgendaDay | AgendaGap;
const DATE_ONLY = /^\d{4}-\d{2}-\d{2}$/;
/**
* The half-open span an instance covers, in epoch milliseconds. All-day boundaries are read back
* out of the date-only strings, which are the authoritative ones and must never be shifted into a
* zone; anything that is not date-only falls back to the epoch fields.
*/
export function instanceRange(i: Instance): { startMs: number; endMs: number } {
if (i.allDay && DATE_ONLY.test(i.start)) {
const startMs = parseDateOnly(i.start);
const endMs = DATE_ONLY.test(i.end) ? parseDateOnly(i.end) : startMs;
return { startMs, endMs: endMs > startMs ? endMs : addDays(startMs, 1) };
}
return { startMs: i.startMs, endMs: i.endMs };
}
/** All-day first, then anything covering the whole day, then by start, longest first. */
function rank(item: AgendaItem): number {
return item.instance.allDay || (item.continued && item.continues) ? 0 : 1;
}
function compareItems(a: AgendaItem, b: AgendaItem): number {
return (
rank(a) - rank(b) ||
a.startMs - b.startMs ||
b.endMs - b.startMs - (a.endMs - a.startMs) ||
a.instance.summary.localeCompare(b.instance.summary) ||
(a.id < b.id ? -1 : a.id > b.id ? 1 : 0)
);
}
const monthKey = (ms: number): string => {
const d = new Date(ms);
return `${d.getFullYear()}-${d.getMonth()}`;
};
/** A heading names its month only when it is not the month the line above was already in. */
function withMonths(sections: AgendaSection[]): AgendaSection[] {
let last = "";
for (const section of sections) {
const first = section.kind === "day" ? section.dayStart : section.from;
const end = section.kind === "day" ? section.dayStart : addDays(section.to, -1);
section.showMonth = monthKey(first) !== last;
last = monthKey(end);
}
return sections;
}
function itemFor(instance: Instance, dayStart: number, dayEnd: number): AgendaItem {
const range = instanceRange(instance);
return {
instance,
id: keyId(keyOf(instance)),
startMs: Math.max(range.startMs, dayStart),
endMs: Math.min(range.endMs, dayEnd),
continued: range.startMs < dayStart,
continues: range.endMs > dayEnd,
};
}
/**
* Every day of `[from, to)` in order, days with something in them carrying their events and runs
* of empty days collapsed. An event covering several days appears under each of them, clipped,
* because a week-long holiday does not make the days it covers empty.
*/
export function buildAgenda(
instances: readonly Instance[],
from: number,
to: number,
): AgendaSection[] {
const days: number[] = [];
for (let d = startOfDay(from); d < to; d = addDays(d, 1)) days.push(d);
if (days.length === 0) return [];
const ends = days.map((d) => addDays(d, 1));
const buckets: AgendaItem[][] = days.map(() => []);
for (const instance of instances) {
if (instance.status === "cancelled") continue;
const range = instanceRange(instance);
// A zero length event still happens somewhere, so it counts as covering its own instant.
const covered = Math.max(range.endMs, range.startMs + 1);
for (let i = 0; i < days.length; i++) {
if (range.startMs >= ends[i]) continue;
if (covered <= days[i]) break;
buckets[i].push(itemFor(instance, days[i], ends[i]));
}
}
for (const bucket of buckets) bucket.sort(compareItems);
const sections: AgendaSection[] = [];
let i = 0;
while (i < days.length) {
if (buckets[i].length > 0) {
sections.push({
kind: "day",
key: `d${days[i]}`,
dayStart: days[i],
items: buckets[i],
showMonth: false,
});
i++;
continue;
}
let j = i;
while (j < days.length && buckets[j].length === 0) j++;
const next = j < days.length ? days[j] : null;
if (j - i >= COLLAPSE_DAYS) {
sections.push({
kind: "gap",
key: `g${days[i]}`,
from: days[i],
to: ends[j - 1],
days: j - i,
next,
showMonth: false,
});
} else {
for (let k = i; k < j; k++) {
sections.push({
kind: "gap",
key: `g${days[k]}`,
from: days[k],
to: ends[k],
days: 1,
next,
showMonth: false,
});
}
}
i = j;
}
return withMonths(sections);
}
/** The matches, grouped by the day they start on. No gap rows: a gap between hits says nothing. */
export function buildSearch(instances: readonly Instance[], query: string): AgendaSection[] {
const terms = queryTerms(query);
if (terms.length === 0) return [];
const buckets = new Map<number, AgendaItem[]>();
for (const instance of instances) {
if (instance.status === "cancelled") continue;
if (!matchesQuery(instance, terms)) continue;
// A hit appears once, on the day it starts, so a long event does not fill the results.
const dayStart = startOfDay(instanceRange(instance).startMs);
const item = itemFor(instance, dayStart, addDays(dayStart, 1));
const bucket = buckets.get(dayStart);
if (bucket) bucket.push(item);
else buckets.set(dayStart, [item]);
}
const sections: AgendaSection[] = [...buckets.keys()]
.sort((a, b) => a - b)
.map((dayStart) => ({
kind: "day" as const,
key: `d${dayStart}`,
dayStart,
items: (buckets.get(dayStart) as AgendaItem[]).sort(compareItems),
showMonth: false,
}));
return withMonths(sections);
}
export const countItems = (sections: readonly AgendaSection[]): number =>
sections.reduce((n, section) => n + (section.kind === "day" ? section.items.length : 0), 0);
/* Labels
----------------------------------------------------------------------------------------- */
const weekdays = [
"Sunday",
"Monday",
"Tuesday",
"Wednesday",
"Thursday",
"Friday",
"Saturday",
];
/** The agenda has no time axis, so this line is the only thing saying when. */
export function timeLabel(item: AgendaItem): string {
const { instance } = item;
if (instance.allDay || (item.continued && item.continues)) return "all day";
if (item.continued) return `until ${formatTime(item.endMs)}`;
if (item.continues) return `from ${formatTime(item.startMs)}`;
if (item.endMs <= item.startMs) return formatTime(item.startMs);
return `${formatTime(item.startMs)} to ${formatTime(item.endMs)}`;
}
/** The date or dates a gap covers, in the same shape a day heading uses. */
export function gapDates(gap: AgendaGap): string {
const head = `${dayName(gap.from)} ${new Date(gap.from).getDate()}`;
if (gap.days === 1) return gap.showMonth ? `${head} ${monthName(gap.from)}` : head;
const last = addDays(gap.to, -1);
const tail = `${dayName(last)} ${new Date(last).getDate()} ${monthName(last)}`;
return monthKey(gap.from) === monthKey(last) ? `${head} to ${tail}` : `${head} ${monthName(gap.from)} to ${tail}`;
}
export function gapLabel(gap: AgendaGap): string {
if (gap.days === 1) return "nothing";
if (gap.next !== null) return `nothing until ${weekdays[new Date(gap.next).getDay()]}`;
return `nothing for ${gap.days} days`;
}
/* Search matching
----------------------------------------------------------------------------------------- */
export const queryTerms = (query: string): string[] =>
query
.toLowerCase()
.split(/\s+/)
.filter((term) => term.length > 0);
/** Every term has to land somewhere, so a second word narrows rather than widens. */
export function matchesQuery(instance: Instance, terms: readonly string[]): boolean {
if (terms.length === 0) return false;
const hay = `${instance.summary}\n${instance.location ?? ""}\n${instance.description ?? ""}`.toLowerCase();
return terms.every((term) => hay.includes(term));
}
export interface MatchPart {
text: string;
hit: boolean;
}
/** `text` cut around every occurrence of any term, longest term winning at a given position. */
export function splitMatch(text: string, terms: readonly string[]): MatchPart[] {
const clean = terms.filter((term) => term.length > 0);
if (clean.length === 0 || text.length === 0) return [{ text, hit: false }];
const hay = text.toLowerCase();
const parts: MatchPart[] = [];
let plain = 0;
let at = 0;
while (at < text.length) {
let width = 0;
for (const term of clean) {
if (term.length > width && hay.startsWith(term, at)) width = term.length;
}
if (width === 0) {
at++;
continue;
}
if (at > plain) parts.push({ text: text.slice(plain, at), hit: false });
parts.push({ text: text.slice(at, at + width), hit: true });
at += width;
plain = at;
}
if (plain < text.length) parts.push({ text: text.slice(plain), hit: false });
return parts;
}
+71
View File
@@ -0,0 +1,71 @@
// One line of the agenda: when, what, where, on a surface tinted with its calendar's colour. Same
// rule as a block on the grid, and the same tokens, drawn a shade weaker: a row is several times
// the area of a block, and a fortnight of them at the grid's strength is a stack of highlighter.
import { memo } from "react";
import type { InstanceKey } from "../ipc";
import { calVars, keyOf, vars, eventTitle } from "./GridModel";
import { splitMatch, timeLabel, type AgendaItem } from "./AgendaModel";
interface AgendaRowProps {
item: AgendaItem;
selected: boolean;
/** Lowercased search terms to mark in the summary. Empty in the agenda proper. */
terms: readonly string[];
onSelect: (key: InstanceKey) => void;
onOpen?: (key: InstanceKey) => void;
}
export const AgendaRow = memo(function AgendaRow({
item,
selected,
terms,
onSelect,
onOpen,
}: AgendaRowProps) {
const { instance } = item;
const summary = eventTitle(instance);
let hint = summary;
if (instance.location) hint += ` · ${instance.location}`;
if (instance.status === "tentative") hint += " · tentative";
if (instance.pending) hint += " · not pushed yet";
const parts = terms.length > 0 ? splitMatch(summary, terms) : null;
return (
<button
type="button"
className="agenda-row"
tabIndex={-1}
title={hint}
data-id={item.id}
data-allday={instance.allDay || undefined}
data-status={instance.status}
data-pending={instance.pending || undefined}
data-readonly={instance.readOnly || undefined}
data-selected={selected || undefined}
style={vars(calVars(instance))}
onClick={() => onSelect(keyOf(instance))}
onDoubleClick={() => onOpen?.(keyOf(instance))}
>
<span className="agenda-row-time">{timeLabel(item)}</span>
<span className="agenda-row-text">
<span className="agenda-row-title">
{parts
? parts.map((part, i) =>
part.hit ? (
<mark key={i} className="agenda-mark">
{part.text}
</mark>
) : (
<span key={i}>{part.text}</span>
),
)
: summary}
</span>
{instance.location && <span className="agenda-row-where">{instance.location}</span>}
</span>
</button>
);
});
+75
View File
@@ -0,0 +1,75 @@
// Search. Results are an agenda list, which is the whole reason the agenda owns them: a search hit
// is a day, a time and a summary, and that is a row.
//
// It filters the instances the view has already loaded rather than asking the backend, because
// there is no search command in the IPC contract. See the note in `useAgendaSearch`.
//
// This renders its own surface but not the backdrop: drop it inside a `.overlay` and let that
// layer own Escape.
import { useMemo } from "react";
import type { Instance, InstanceKey } from "../ipc";
import { useCalendarView } from "../store/useCalendarView";
import { buildSearch, countItems, queryTerms } from "./AgendaModel";
import { AgendaList } from "./AgendaList";
import { useAgendaSearch } from "./useAgendaSearch";
import "../styles/agenda.css";
interface AgendaSearchProps {
/** Defaults to whatever the calendar view has loaded. */
instances?: readonly Instance[];
onOpen?: (key: InstanceKey) => void;
}
export function AgendaSearch({ instances, onOpen }: AgendaSearchProps) {
const loaded = useCalendarView((s) => s.instances);
const selected = useCalendarView((s) => s.selected);
const select = useCalendarView((s) => s.select);
const query = useAgendaSearch((s) => s.query);
const setQuery = useAgendaSearch((s) => s.setQuery);
const source = instances ?? loaded;
const terms = useMemo(() => queryTerms(query), [query]);
const sections = useMemo(() => buildSearch(source, query), [source, query]);
const hits = countItems(sections);
const note =
terms.length === 0
? "Type to search the days already loaded."
: hits === 0
? `Nothing matches "${query.trim()}".`
: null;
return (
<div className="agenda agenda-search">
<div className="agenda-search-field">
<input
className="agenda-search-input"
type="text"
value={query}
placeholder="Search events"
autoFocus
autoComplete="off"
spellCheck={false}
onFocus={(e) => e.currentTarget.select()}
onChange={(e) => setQuery(e.target.value)}
/>
{hits > 0 && (
<span className="agenda-search-count">
{hits} {hits === 1 ? "match" : "matches"}
</span>
)}
</div>
<AgendaList
sections={sections}
selected={selected}
onSelect={select}
onOpen={onOpen}
terms={terms}
note={note}
/>
</div>
);
}
export default AgendaSearch;
+46
View File
@@ -0,0 +1,46 @@
// The agenda: thirty days as a flat chronological list, grouped by day, with no time axis at all.
// Nothing here is measured, solved or packed, which is what makes it cheap and what makes it a
// genuinely different view rather than a squashed grid.
import { useMemo } from "react";
import { spanFor, useCalendarView } from "../store/useCalendarView";
import { buildAgenda } from "./AgendaModel";
import { AgendaList } from "./AgendaList";
import { keyId } from "./GridModel";
import { openDetailsFor } from "./useDetails";
import "../styles/agenda.css";
export function AgendaView() {
const view = useCalendarView((s) => s.view);
const anchor = useCalendarView((s) => s.anchor);
const instances = useCalendarView((s) => s.instances);
const phase = useCalendarView((s) => s.phase);
const error = useCalendarView((s) => s.error);
const selected = useCalendarView((s) => s.selected);
const select = useCalendarView((s) => s.select);
// The store loads a day of padding either side, so the span is cut back to what it claims.
const sections = useMemo(() => {
const { from, to } = spanFor("agenda", anchor);
return buildAgenda(instances, from, to);
}, [instances, anchor]);
if (view !== "agenda") return null;
const note =
phase === "error" ? error : phase === "loading" && instances.length === 0 ? "Loading…" : null;
return (
<div className="agenda" data-phase={phase}>
<AgendaList
sections={sections}
selected={selected}
onSelect={select}
onOpen={(key) => openDetailsFor(key, document.querySelector(`[data-id="${keyId(key)}"]`))}
note={note}
/>
</div>
);
}
export default AgendaView;
+110
View File
@@ -0,0 +1,110 @@
// Which calendars the grid draws, grouped by the account they came from. The checkbox writes
// straight through `useAccounts.setSelected`, which is already optimistic and reverts itself on a
// failure, so there is nothing to hold here.
//
// A calendar you do not own behaves differently once you try to edit it, so its role is on the
// row rather than hidden behind the event that will refuse to save.
import { useEffect, useMemo } from "react";
import type { Calendar } from "../ipc";
import { useAccounts } from "../store/useAccounts";
import { useCalendarView } from "../store/useCalendarView";
import { useOverlays } from "../store/useOverlays";
import { calSlot } from "./GridModel";
import { roleLabel } from "./overlayModel";
import { Sheet } from "./overlayShell";
interface Group {
accountId: string;
email: string;
calendars: Calendar[];
}
function group(calendars: readonly Calendar[], emails: ReadonlyMap<string, string>): Group[] {
const groups = new Map<string, Group>();
for (const calendar of calendars) {
let g = groups.get(calendar.accountId);
if (!g) {
g = {
accountId: calendar.accountId,
email: emails.get(calendar.accountId) ?? "Disconnected account",
calendars: [],
};
groups.set(calendar.accountId, g);
}
g.calendars.push(calendar);
}
for (const g of groups.values()) {
g.calendars.sort(
(a, b) => Number(b.primary) - Number(a.primary) || a.summary.localeCompare(b.summary),
);
}
return [...groups.values()].sort((a, b) => a.email.localeCompare(b.email));
}
export function CalendarList() {
const open = useOverlays((s) => s.open);
const close = useOverlays((s) => s.close);
const show = useOverlays((s) => s.show);
const accounts = useAccounts((s) => s.accounts);
const calendars = useAccounts((s) => s.calendars);
const setSelected = useAccounts((s) => s.setSelected);
const refresh = useAccounts((s) => s.refresh);
const showing = open === "calendars";
useEffect(() => {
if (showing) void refresh();
}, [showing, refresh]);
const groups = useMemo(
() => group(calendars, new Map(accounts.map((a) => [a.id, a.email]))),
[calendars, accounts],
);
const toggle = (calendar: Calendar) => {
void setSelected(calendar.id, !calendar.selected);
// The instance stream is filtered on the Rust side, so the span has to be asked for again.
void useCalendarView.getState().load();
};
return (
<Sheet open={showing} title="Calendars" onClose={close}>
{groups.length === 0 ? (
<div className="panel-empty">
<p>No calendars here yet.</p>
<button type="button" className="panel-button" onClick={() => show("accounts")}>
Connect an account
</button>
</div>
) : (
groups.map((g) => (
<div className="cal-group" key={g.accountId}>
<span className="field-label">{g.email}</span>
{g.calendars.map((calendar) => {
const role = roleLabel(calendar.accessRole);
return (
<label className="cal-row" key={calendar.id} title={calendar.description ?? undefined}>
<input
type="checkbox"
className="cal-check"
checked={calendar.selected}
onChange={() => toggle(calendar)}
/>
<span
className="cal-dot"
style={{ background: `var(--cal-${calSlot(calendar.colorHex, calendar.id)})` }}
/>
<span className="cal-name">{calendar.summary || "Untitled"}</span>
{role ? <span className="cal-role">{role}</span> : null}
</label>
);
})}
</div>
))
)}
</Sheet>
);
}
export default CalendarList;
+94
View File
@@ -0,0 +1,94 @@
// The eleven colours Google keeps against an event, plus the one that is not a colour: follow the
// calendar. Shared by the quick create card and the full editor so the choice reads the same in
// both, which is the whole point of it being a component rather than two rows of buttons.
//
// The swatches are painted in Google's own hex, because that is what `colorId` means to every
// other client this account is signed into. The grid mutes those hues onto its own eight when it
// paints the block (`calSlot` and `calHue` in GridModel), so a block is the nearest warm cousin of
// the swatch rather than the swatch itself. Showing the muted eight here instead would put three
// pairs of identical dots in the row and paint Graphite, which is grey, some arbitrary colour.
//
// One radio group, one tab stop: the arrow keys move between swatches. Twelve tab stops in a card
// this small would bury the buttons under them.
import { useRef, type KeyboardEvent } from "react";
import { EVENT_COLORS } from "../ipc";
import { vars } from "./GridModel";
/** `null` is "same as calendar", and is always one of the options. */
export type ColorChoice = string | null;
export interface ColorPickerProps {
value: ColorChoice;
onChange: (colorId: ColorChoice) => void;
/**
* A CSS colour for the "same as calendar" swatch, so it previews what following the calendar
* would look like. The callers pass the same expression their calendar dot uses.
*/
calendarColor?: string;
disabled?: boolean;
/** Names the group for a screen reader. The visible label is the caller's business. */
label?: string;
}
const OPTIONS: ColorChoice[] = [null, ...EVENT_COLORS.map((c) => c.id)];
const nameOf = (id: ColorChoice): string =>
id === null ? "Same as calendar" : (EVENT_COLORS.find((c) => c.id === id)?.name ?? "Colour");
export function ColorPicker({
value,
onChange,
calendarColor = "var(--line-strong)",
disabled,
label = "Colour",
}: ColorPickerProps) {
const row = useRef<HTMLDivElement | null>(null);
const at = OPTIONS.indexOf(OPTIONS.includes(value) ? value : null);
function move(delta: number) {
const next = OPTIONS[(at + delta + OPTIONS.length) % OPTIONS.length];
onChange(next);
// Focus follows the selection, which is what a radio group does and what makes holding an
// arrow key feel like scrubbing through the palette rather than tabbing through it.
const buttons = row.current?.querySelectorAll<HTMLButtonElement>(".swatch");
buttons?.[OPTIONS.indexOf(next)]?.focus();
}
function onKeyDown(e: KeyboardEvent<HTMLDivElement>) {
if (e.key === "ArrowRight" || e.key === "ArrowDown") move(1);
else if (e.key === "ArrowLeft" || e.key === "ArrowUp") move(-1);
else return;
e.preventDefault();
}
return (
<div className="swatches" role="radiogroup" aria-label={label} ref={row} onKeyDown={onKeyDown}>
{OPTIONS.map((id) => {
const on = id === OPTIONS[at];
return (
<button
key={id ?? "calendar"}
type="button"
role="radio"
className="swatch"
aria-checked={on}
aria-label={nameOf(id)}
title={nameOf(id)}
tabIndex={on ? 0 : -1}
data-on={on ? "" : undefined}
data-default={id === null ? "" : undefined}
disabled={disabled}
style={vars({
"--swatch": id === null ? calendarColor : EVENT_COLORS.find((c) => c.id === id)?.hex,
})}
onClick={() => onChange(id)}
/>
);
})}
</div>
);
}
export default ColorPicker;
+403
View File
@@ -0,0 +1,403 @@
// What clicking an event gets you: a card pinned to the block you clicked, not a modal in the
// middle of the window. Clicking an event is a local gesture and the answer should arrive in the
// same place your eye already is.
//
// Everything here is read-only except the three buttons at the bottom. Attendees and the
// conference link are shown and never touched: there is no RSVP flow, no availability finder and
// no scheduling links in v1, which is a deliberate cut in docs/design.md rather than an oversight.
//
// The card is not an `.overlay`. It has no backdrop, because a backdrop would swallow the click
// that opens the next event and make switching between two blocks take two clicks instead of one.
// A click outside closes it through one capture-phase pointer listener, and the keyboard is left
// to the keymap: pushing the `overlay` frame shadows the view's keys for as long as the card is up.
import "../styles/details.css";
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { eventDelete } from "../api/events";
import { useEscapeLayer } from "../escape";
import type { Attendee, Calendar, Instance, InstanceKey, Scope } from "../ipc";
import { useKeyContext } from "../keys/keymap";
import { useAccounts } from "../store/useAccounts";
import { useCalendarView } from "../store/useCalendarView";
import { notify } from "../store/useToast";
import { parseDescription } from "./EventDetailsHtml";
import { RichText, openLink } from "./RichText";
import {
badgesOf,
conferenceLabel,
orderGuests,
place,
type Bounds,
type Placement,
} from "./EventDetailsModel";
// The title is the grid's, so a free/busy block reads "Busy" in both places or in neither.
import { calSlot, eventTitle, keyId } from "./GridModel";
import { Icon } from "./Icon";
const CLOSE = "M18 6L6 18M6 6l12 12";
import { attendeeTally, responseLabel, whenText } from "./overlayModel";
import { Confirm } from "./overlayShell";
import { useDetails, type AnchorRect } from "./useDetails";
import { useEditor } from "./useEditor";
const CLOCK = "M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0M12 7v5l3 2";
const PIN = "M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0M15 10a3 3 0 1 1-6 0 3 3 0 0 1 6 0";
const LINK = "M10 13a5 5 0 0 0 7 0l3-3a5 5 0 0 0-7-7l-2 2M14 11a5 5 0 0 0-7 0l-3 3a5 5 0 0 0 7 7l2-2";
const TEXT = "M4 6h16M4 12h12M4 18h9";
const USERS =
"M17 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2M12.5 7.5a3.5 3.5 0 1 1-7 0 3.5 3.5 0 0 1 7 0M22 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75";
/** Clear of the window edges, and clear of the one row of chrome that is always resident. */
const EDGE = 8;
function viewBounds(): Bounds {
const bar = Number.parseFloat(
getComputedStyle(document.documentElement).getPropertyValue("--titlebar-h"),
);
return {
top: (Number.isFinite(bar) ? bar : 0) + EDGE,
left: EDGE,
right: window.innerWidth - EDGE,
bottom: window.innerHeight - EDGE,
};
}
/**
* Mounted unconditionally and renders nothing until something is open. The inner card is keyed on
* the instance, so opening a second event mounts a fresh one rather than carrying the first one's
* half-finished delete over to it.
*/
export function EventDetails() {
const key = useDetails((s) => s.key);
const anchor = useDetails((s) => s.anchor);
if (!key) return null;
return <DetailsCard key={keyId(key)} target={key} anchor={anchor} />;
}
interface DetailsCardProps {
target: InstanceKey;
anchor: AnchorRect | null;
}
function DetailsCard({ target, anchor }: DetailsCardProps) {
const close = useDetails((s) => s.close);
const instances = useCalendarView((s) => s.instances);
const calendars = useAccounts((s) => s.calendars);
const card = useRef<HTMLDivElement | null>(null);
const [spot, setSpot] = useState<Placement | null>(null);
const [asking, setAsking] = useState(false);
const [busy, setBusy] = useState(false);
const instance = useMemo(
() =>
instances.find((i) => i.eventId === target.eventId && i.originalStart === target.originalStart) ??
null,
[instances, target],
);
const recurring = instance?.recurring ?? false;
const readOnly = instance?.readOnly ?? true;
useEscapeLayer(true, close);
// The scope step is not a `Confirm`, so it brings its own layer. Pushed after the card's, which
// is what makes Escape unwind the question first and the card second.
useEscapeLayer(asking && recurring, () => setAsking(false));
useKeyContext("overlay");
// Measured, then placed, before the browser paints. The card also grows and shrinks as the
// delete question replaces its body, so the observer keeps it against its block either way.
useLayoutEffect(() => {
const el = card.current;
if (!el) return;
const measure = () => {
const box = el.getBoundingClientRect();
setSpot(place(anchor, { width: box.width, height: box.height }, viewBounds()));
};
measure();
const watch = new ResizeObserver(measure);
watch.observe(el);
return () => watch.disconnect();
}, [anchor]);
// Focus has to leave the grid or the first keystroke goes to whatever the click left focused.
// Only once it has been placed: React flushes this before the placement commit, and the frame
// before that the card is still invisible at the origin.
const placed = spot !== null;
useEffect(() => {
if (placed) card.current?.focus({ preventScroll: true });
}, [placed]);
// A pointer anywhere else closes it, including on the header, which the card does not cover.
// Capture phase, so the block you clicked instead can open its own card in the same gesture.
useEffect(() => {
const onDown = (e: PointerEvent) => {
const el = card.current;
if (el && e.target instanceof Node && el.contains(e.target)) return;
close();
};
document.addEventListener("pointerdown", onDown, true);
return () => document.removeEventListener("pointerdown", onDown, true);
}, [close]);
// The row heights are derived from the window, so after a resize the anchor describes somewhere
// the block no longer is. Repositioning against a stale rectangle is worse than getting out of
// the way. Scrolling is the agenda's list, and means the same thing.
useEffect(() => {
window.addEventListener("resize", close);
window.addEventListener("scroll", close, true);
return () => {
window.removeEventListener("resize", close);
window.removeEventListener("scroll", close, true);
};
}, [close]);
const edit = () => {
close();
useEditor.getState().edit(target);
};
async function remove(scope: Scope) {
setBusy(true);
try {
await eventDelete(target, scope);
notify("Event deleted");
void useCalendarView.getState().load();
close();
} catch (e) {
notify(`Could not delete the event: ${e}`);
setAsking(false);
} finally {
setBusy(false);
}
}
return (
<div
className="details-card"
ref={card}
tabIndex={-1}
role="dialog"
aria-label={instance ? eventTitle(instance) : "Event"}
data-side={spot?.side}
data-placed={spot ? "" : undefined}
style={{
top: `${spot?.top ?? 0}px`,
left: `${spot?.left ?? 0}px`,
["--cal" as string]: instance
? `var(--cal-${calSlot(instance.colorHex, instance.calendarId)})`
: "var(--line-strong)",
}}
>
<button
type="button"
className="details-close"
title="Close (\u238B)"
aria-label="Close"
onClick={() => close()}
>
<Icon d={CLOSE} />
</button>
{!instance ? (
<Missing onClose={close} />
) : asking ? (
recurring ? (
<Scopes instance={instance} busy={busy} onPick={(s) => void remove(s)} onBack={() => setAsking(false)} />
) : (
<div className="details-body">
<Confirm
title={`Delete ${eventTitle(instance)}?`}
body={<p>It goes from this calendar for everyone it was shared with.</p>}
confirmLabel="Delete"
busy={busy}
onConfirm={() => void remove("this")}
onCancel={() => setAsking(false)}
/>
</div>
)
) : (
<>
<Body instance={instance} calendars={calendars} />
<div className="details-foot">
{!readOnly && (
<>
<button type="button" className="panel-button" onClick={edit}>
Edit
</button>
<button
type="button"
className="panel-button"
data-variant="danger"
onClick={() => setAsking(true)}
>
Delete
</button>
</>
)}
<button type="button" className="panel-button" data-side="trail" onClick={close}>
Close
</button>
</div>
</>
)}
</div>
);
}
function Missing({ onClose }: { onClose: () => void }) {
return (
<>
<div className="details-body">
<p className="details-note">That event is no longer in the range you are looking at.</p>
</div>
<div className="details-foot">
<button type="button" className="panel-button" data-side="trail" onClick={onClose}>
Close
</button>
</div>
</>
);
}
const SCOPES: [Scope, string, string][] = [
["this", "This event", "Only the occurrence you opened."],
["following", "This and following", "This one and every later occurrence."],
["all", "All events", "The whole series, including the ones already past."],
];
/** Deleting an occurrence of a series means three different things, so the card stops and asks. */
function Scopes({
instance,
busy,
onPick,
onBack,
}: {
instance: Instance;
busy: boolean;
onPick: (scope: Scope) => void;
onBack: () => void;
}) {
return (
<>
<div className="details-body">
<p className="details-note">{eventTitle(instance)} repeats. Delete which of it?</p>
<div className="scope-list">
{SCOPES.map(([scope, name, note]) => (
<button
key={scope}
type="button"
className="scope-option"
data-variant="danger"
disabled={busy}
onClick={() => onPick(scope)}
>
<span className="scope-name">{name}</span>
<span className="scope-note">{note}</span>
</button>
))}
</div>
</div>
<div className="details-foot">
<button type="button" className="panel-button" data-side="trail" onClick={onBack}>
Back
</button>
</div>
</>
);
}
function Body({ instance, calendars }: { instance: Instance; calendars: readonly Calendar[] }) {
const badges = badgesOf(instance);
// Parsed, never interpolated. `EventDetailsHtml` explains why at length; the short version is
// that this string is written by whoever put the event on the calendar.
const description = useMemo(() => parseDescription(instance.description), [instance.description]);
const calendar = calendars.find((c) => c.id === instance.calendarId) ?? null;
const name = calendar?.summary || "Unknown calendar";
const uri = instance.conference?.uri ?? null;
const tally = attendeeTally(instance.attendees);
const organizer = instance.organizer;
return (
<div className="details-body">
<div className="details-head">
<h2 className="details-title">{eventTitle(instance)}</h2>
{badges.length > 0 && (
<div className="details-badges">
{badges.map((badge) => (
<span className="details-badge" key={badge}>
{badge}
</span>
))}
</div>
)}
</div>
<div className="details-row">
<Icon d={CLOCK} size={14} />
<span className="details-text">{whenText(instance)}</span>
</div>
<div className="details-row">
<span className="details-dot" />
<span className="details-text">{name}</span>
</div>
{instance.location && (
<div className="details-row">
<Icon d={PIN} size={14} />
<span className="details-text">{instance.location}</span>
</div>
)}
{uri && (
<div className="details-row">
<Icon d={LINK} size={14} />
<button type="button" className="details-link" onClick={() => openLink(uri)}>
{conferenceLabel(instance)}
</button>
</div>
)}
{description.nodes.length > 0 && (
<div className="details-row" data-block="">
<Icon d={TEXT} size={14} />
<div className="details-desc" data-plain={description.plain ? "" : undefined}>
<RichText nodes={description.nodes} />
</div>
</div>
)}
{(tally || organizer) && (
<div className="details-row" data-block="">
<Icon d={USERS} size={14} />
<div className="details-guests">
<span className="details-text" data-soft="">
{tally ?? `Organised by ${organizer}`}
</span>
{instance.attendees.length > 0 && (
<ul className="guest-list">
{orderGuests(instance.attendees).map((a) => (
<li className="guest" key={a.email} data-response={a.responseStatus}>
<span className="guest-dot" />
<span className="guest-name">{nameOf(a)}</span>
{a.organizer && <span className="guest-tag">organiser</span>}
{a.self && <span className="guest-tag">you</span>}
{a.optional && <span className="guest-tag">optional</span>}
<span className="guest-response">{responseLabel(a.responseStatus)}</span>
</li>
))}
</ul>
)}
</div>
</div>
)}
</div>
);
}
const nameOf = (a: Attendee): string => a.displayName || a.email;
/** The parser's vocabulary, and the only elements a description can put on screen. */
export default EventDetails;
+290
View File
@@ -0,0 +1,290 @@
// Two halves. The first is "does a real description come out looking like what it says", using the
// shapes Google actually sends. The second is the one that matters: hostile input, because this
// string comes off the wire from whoever put the event on the calendar, and the only reason it is
// safe to render is that this module refuses to carry anything but a known list of elements and one
// vetted attribute.
import { describe, expect, it } from "vitest";
import {
decodeEntities,
hasMarkup,
parseDescription,
safeHref,
type DescElement,
type DescNode,
} from "./EventDetailsHtml";
/** The visible words, which is what "never renders" is asserted against. */
function textOf(nodes: readonly DescNode[]): string {
return nodes
.map((node) =>
node.kind === "text" ? node.text : node.kind === "break" ? "\n" : textOf(node.children),
)
.join("");
}
/** Every element in the tree, flattened, so a whitelist can be asserted over the whole thing. */
function elementsOf(nodes: readonly DescNode[]): DescElement[] {
const out: DescElement[] = [];
for (const node of nodes) {
if (node.kind !== "element") continue;
out.push(node);
out.push(...elementsOf(node.children));
}
return out;
}
const parse = (raw: string): DescNode[] => parseDescription(raw).nodes;
const links = (raw: string): DescElement[] => elementsOf(parse(raw)).filter((e) => e.tag === "a");
describe("plain text", () => {
it("keeps its newlines as breaks", () => {
const nodes = parse("Bring the deck.\nRoom 3.\n\nAsk Sam first.");
expect(nodes.map((n) => (n.kind === "text" ? n.text : "<br>"))).toEqual([
"Bring the deck.",
"<br>",
"Room 3.",
"<br>",
"<br>",
"Ask Sam first.",
]);
});
it("takes carriage returns and the Windows pair as one break each", () => {
expect(textOf(parse("one\r\ntwo\rthree"))).toBe("one\ntwo\nthree");
});
it("is marked plain, so the card can keep its own spacing", () => {
expect(parseDescription(" indented\n more").plain).toBe(true);
expect(parseDescription("a<br>b").plain).toBe(false);
});
it("keeps an angle bracket that is arithmetic rather than markup", () => {
expect(hasMarkup("if a < b then 2 > 1")).toBe(false);
expect(textOf(parse("if a < b then 2 > 1"))).toBe("if a < b then 2 > 1");
});
it("is nothing at all when it is empty or only whitespace", () => {
expect(parse("")).toEqual([]);
expect(parse(" \n ")).toEqual([]);
expect(parseDescription(null).nodes).toEqual([]);
expect(parseDescription(undefined).nodes).toEqual([]);
});
});
describe("html", () => {
it("turns a br into a break", () => {
expect(parse("Job List:<br>tomorrow")).toEqual([
{ kind: "text", text: "Job List:" },
{ kind: "break" },
{ kind: "text", text: "tomorrow" },
]);
expect(parse("a<BR/>b")).toEqual([
{ kind: "text", text: "a" },
{ kind: "break" },
{ kind: "text", text: "b" },
]);
});
it("renders the description from the bug report as text and one link", () => {
const url =
"https://docs.google.com/spreadsheets/d/15rc9TXa46HwY3KsppvMR93T8L_7BTWacDnvisxPpzkE/edit?gid=71264683#gid=71264683";
const nodes = parse(
`Job List:<br><a href="${url}" class="pastedDriveLink-0">${url}</a>`,
);
expect(textOf(nodes)).toBe(`Job List:\n${url}`);
const [link] = links(`Job List:<br><a href="${url}" class="pastedDriveLink-0">${url}</a>`);
expect(link.href).toBe(url);
// The class attribute is gone, and nothing else came with it.
expect(Object.keys(link)).toEqual(["kind", "tag", "href", "children"]);
});
it("keeps the href of an anchor and the words inside it", () => {
const [link] = links('<a href="https://example.com/a?b=1&amp;c=2">the sheet</a>');
expect(link.href).toBe("https://example.com/a?b=1&c=2");
expect(textOf(link.children)).toBe("the sheet");
});
it("takes a mailto link", () => {
expect(links('<a href="mailto:[email protected]">Sam</a>')[0].href).toBe("mailto:[email protected]");
});
it("keeps emphasis, and normalises the tags that mean the same thing", () => {
const tags = elementsOf(parse("<strong>a</strong><em>b</em><i>c</i><u>d</u><del>e</del>")).map(
(e) => e.tag,
);
expect(tags).toEqual(["b", "i", "i", "u", "s"]);
});
it("keeps lists as lists", () => {
const nodes = parse("<ul><li>first</li><li>second</li></ul>");
expect(nodes).toHaveLength(1);
const list = nodes[0] as DescElement;
expect(list.tag).toBe("ul");
expect(list.children.map((c) => (c as DescElement).tag)).toEqual(["li", "li"]);
expect(textOf(list.children)).toBe("firstsecond");
expect((parse("<ol><li>one</li></ol>")[0] as DescElement).tag).toBe("ol");
});
it("closes an item that the sender left open", () => {
const list = parse("<ul><li>first<li>second</ul>")[0] as DescElement;
expect(list.children).toHaveLength(2);
expect(textOf(list.children[0].kind === "element" ? list.children[0].children : [])).toBe("first");
});
it("gathers loose content in a list into an item, so a ul only ever holds li", () => {
const list = parse("<ul>stray<li>first</li></ul>")[0] as DescElement;
expect(list.children.every((c) => c.kind === "element" && c.tag === "li")).toBe(true);
expect(textOf([list])).toBe("strayfirst");
});
it("degrades an unknown tag to the text it wrapped", () => {
expect(textOf(parse('<span style="color:red">red</span> and <font size=7>big</font>'))).toBe(
"red and big",
);
expect(elementsOf(parse("<table><tr><td>cell</td></tr></table>"))).toEqual([]);
expect(textOf(parse("<table><tr><td>cell</td></tr></table>"))).toBe("cell");
});
it("collapses whitespace the way html does, and keeps a hard space", () => {
expect(textOf(parse("<div>a \n b</div>"))).toBe("a b");
expect(textOf(parse("<div>a&nbsp;&nbsp;b</div>"))).toBe("a\u00a0\u00a0b");
});
it("decodes entities, named, decimal and hex", () => {
expect(textOf(parse("<p>Tom &amp; Jerry &lt;b&gt; &#8364; &#x1F600; &quot;hi&quot;</p>"))).toBe(
'Tom & Jerry <b> \u20ac \u{1F600} "hi"',
);
});
it("leaves an entity it does not know exactly as it found it", () => {
expect(decodeEntities("&zzz; &amp;")).toBe("&zzz; &");
// Not a lookup on Object.prototype, whatever the name looks like.
expect(decodeEntities("&constructor; &toString;")).toBe("&constructor; &toString;");
});
it("drops the padding a mail client leaves behind", () => {
expect(parse("<br><br>Only this.<br><br><br><p> </p>")).toEqual([
{ kind: "text", text: "Only this." },
]);
});
it("keeps a blank line in the middle, but not a run of six", () => {
expect(parse("a<br><br><br><br>b").filter((n) => n.kind === "break")).toHaveLength(2);
});
it("survives tags that were never closed", () => {
expect(textOf(parse("<div><b>bold <i>both"))).toBe("bold both");
expect(textOf(parse("</b>stray close</div>"))).toBe("stray close");
});
it("survives a bare angle bracket next to real markup", () => {
expect(textOf(parse("5 < 6 <b>really</b>"))).toBe("5 < 6 really");
});
it("drops comments and doctypes without dropping the text around them", () => {
expect(textOf(parse("<!DOCTYPE html>before<!-- hidden -->after"))).toBe("beforeafter");
expect(textOf(parse("before<!-- never closed"))).toBe("before");
});
});
describe("hostile input", () => {
it("never renders a script, and never its contents", () => {
const nodes = parse("hello<script>alert(1)</script>bye");
expect(textOf(nodes)).toBe("hellobye");
expect(textOf(parse("<script>alert(1)"))).toBe("");
expect(textOf(parse("<SCRIPT SRC=//evil.test/x.js></SCRIPT>ok"))).toBe("ok");
// Nested inside something else the parser does not know, which is the usual smuggling route.
expect(textOf(parse("<svg><script>alert(1)</script></svg>safe"))).toBe("safe");
});
it("never renders a style block", () => {
expect(textOf(parse("<style>body{display:none}</style>text"))).toBe("text");
});
it("never renders an image, with or without an onerror", () => {
const nodes = parse('<img src=x onerror="alert(1)">caption');
expect(elementsOf(nodes)).toEqual([]);
expect(textOf(nodes)).toBe("caption");
expect(JSON.stringify(nodes)).not.toContain("onerror");
expect(JSON.stringify(parse('<img src="x" onerror=alert(1) />'))).not.toContain("alert");
});
it("drops a javascript url and keeps the words that were the link", () => {
const raw = '<a href="javascript:alert(1)">click me</a>';
expect(links(raw)).toEqual([]);
expect(textOf(parse(raw))).toBe("click me");
});
it("drops a javascript url however it is spelled", () => {
for (const href of [
"javascript:alert(1)",
"JaVaScRiPt:alert(1)",
" javascript:alert(1)",
"java\tscript:alert(1)",
"java\nscript:alert(1)",
"&#106;avascript:alert(1)",
"&#x6A;avascript:alert(1)",
"\u0000javascript:alert(1)",
"jav&#x09;ascript:alert(1)",
"vbscript:msgbox(1)",
"data:text/html;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg==",
"file:///etc/passwd",
"//evil.test/x",
"/relative/path",
"#anchor",
"",
]) {
expect(safeHref(href), href).toBeNull();
}
expect(safeHref(null)).toBeNull();
});
it("keeps the schemes it does allow, and only those", () => {
expect(safeHref("https://example.com/a b")).toBe("https://example.com/ab");
expect(safeHref("HTTP://Example.com")).toBe("HTTP://Example.com");
expect(safeHref("mailto:[email protected]?subject=hi")).toBe("mailto:[email protected]?subject=hi");
});
it("carries no attribute other than the href of a link", () => {
const raw =
'<a href="https://ok.test" onclick="steal()" onmouseover="steal()" target="_top" style="x">x</a>' +
'<div onload="steal()" id="q"><b class="c">y</b></div>';
const nodes = parse(raw);
for (const element of elementsOf(nodes)) {
const keys = Object.keys(element).filter((k) => k !== "kind" && k !== "tag" && k !== "children");
expect(keys, element.tag).toEqual(element.tag === "a" ? ["href"] : []);
}
expect(JSON.stringify(nodes)).not.toContain("steal");
});
it("renders an escaped tag as the characters it is, not as a tag", () => {
const nodes = parse("<p>&lt;script&gt;alert(1)&lt;/script&gt;</p>");
expect(textOf(nodes)).toBe("<script>alert(1)</script>");
expect(elementsOf(nodes).map((e) => e.tag)).toEqual(["block"]);
});
it("only ever emits tags from the whitelist", () => {
const raw =
'<form action="x"><input value="y"><iframe src="//evil.test"></iframe><object></object>' +
"<marquee>hi</marquee><h1>Head</h1><blockquote>quote</blockquote><pre>code</pre></form>";
const allowed = new Set(["block", "ul", "ol", "li", "b", "i", "u", "s", "code", "a"]);
for (const element of elementsOf(parse(raw))) expect(allowed.has(element.tag), element.tag).toBe(true);
expect(textOf(parse(raw))).toContain("hi");
expect(textOf(parse(raw))).not.toContain("evil.test");
});
it("flattens absurd nesting instead of building a tree that deep", () => {
const deep = "<b>".repeat(400) + "still here" + "</b>".repeat(400);
const nodes = parse(deep);
expect(textOf(nodes)).toBe("still here");
const depth = (list: readonly DescNode[]): number =>
1 + Math.max(0, ...list.map((n) => (n.kind === "element" ? depth(n.children) : 0)));
expect(depth(nodes)).toBeLessThanOrEqual(20);
});
it("drops a tag that never terminates rather than spilling it as words", () => {
expect(textOf(parse('<b>bold</b><a href="https://ok.test' + "x".repeat(200)))).toBe("bold");
expect(textOf(parse("<<<<>>>>text"))).toContain("text");
});
});
+449
View File
@@ -0,0 +1,449 @@
// Google Calendar descriptions are HTML. Not "sometimes HTML": the API documents the field as
// HTML, and a real account returns `<br>`, `<a href>`, `<b>`, lists, and the `pastedDriveLink`
// anchors the web UI writes when you paste a Docs URL. Printing that field as text puts raw markup
// in front of the user, which is the bug this module exists to fix.
//
// It is also the least trustworthy string in the app. Anyone who can put an event on a calendar
// you subscribe to writes those bytes, so it can never reach `dangerouslySetInnerHTML`. Instead the
// markup is parsed here into a small tree of plain data, and the card turns that tree into React
// elements. Nothing that is not in `TAGS` survives, no attribute other than a vetted `href`
// survives, and `<script>` and `<style>` are swallowed whole, so there is no path from the wire to
// executable anything.
//
// The parser is deliberately hand-written rather than `DOMParser`: it is pure, so the unit run in
// node covers it, including the hostile cases, and it cannot be tempted into loading a subresource
// the way a real document fragment can.
//
// A description may equally be plain text with newlines in it. `parseDescription` decides which it
// is looking at and says so, because the two want different whitespace treatment: HTML collapses
// its own, plain text has meaningful indentation.
/** The entire vocabulary the card will render. Everything else degrades to its text. */
export type DescTag = "block" | "ul" | "ol" | "li" | "b" | "i" | "u" | "s" | "code" | "a";
export interface DescText {
kind: "text";
text: string;
}
export interface DescBreak {
kind: "break";
}
export interface DescElement {
kind: "element";
tag: DescTag;
/** Only ever set on `a`, only ever an http, https or mailto URL. */
href?: string;
children: DescNode[];
}
export type DescNode = DescText | DescBreak | DescElement;
export interface Description {
/** True when the source carried no markup, so its own spacing is worth preserving. */
plain: boolean;
nodes: DescNode[];
}
/** Source names mapped onto the small vocabulary above. Anything absent here is not rendered. */
const TAGS = new Map<string, DescTag>([
["a", "a"],
["b", "b"],
["strong", "b"],
["i", "i"],
["em", "i"],
["cite", "i"],
["u", "u"],
["ins", "u"],
["s", "s"],
["strike", "s"],
["del", "s"],
["code", "code"],
["kbd", "code"],
["samp", "code"],
["tt", "code"],
["p", "block"],
["div", "block"],
["pre", "block"],
["blockquote", "block"],
["h1", "block"],
["h2", "block"],
["h3", "block"],
["h4", "block"],
["h5", "block"],
["h6", "block"],
["ul", "ul"],
["ol", "ol"],
["li", "li"],
]);
/** Elements whose content is text to the tokenizer, and which are dropped along with it. */
const RAW_TEXT = new Set(["script", "style", "textarea", "title", "noscript", "iframe", "xmp"]);
/** No end tag, so they must never open a frame. `br` is handled before this set is consulted. */
const VOID = new Set([
"area",
"base",
"basefont",
"br",
"col",
"embed",
"frame",
"hr",
"img",
"input",
"link",
"meta",
"param",
"source",
"track",
"wbr",
]);
/**
* Past this the tree is nested deeper than any description means anything at, and the extra levels
* are flattened. `<b><b><b>` a thousand deep is a cheap way to make a renderer work hard.
*/
const MAX_DEPTH = 16;
/** Only these three. A `javascript:` or `data:` URL is not a link, it is an attack. */
const SAFE_SCHEME = /^(?:https?:|mailto:)/i;
/** Control characters and the invisible spaces, none of which belong inside a URL. */
const INVISIBLE = /[\u0000-\u0020\u007f-\u00a0\u2000-\u200f\u2028\u2029\u202f\u205f\u3000\ufeff]/g;
const MARKUP = /<(\/?)([a-zA-Z][a-zA-Z0-9]*)((?:"[^"]*"|'[^']*'|[^>"'])*)>/y;
const ATTRIBUTE = /([a-zA-Z_:][-a-zA-Z0-9_:.]*)(?:\s*=\s*("[^"]*"|'[^']*'|[^\s"'=<>`]+))?/g;
const ENTITY = /&(#[xX][0-9a-fA-F]{1,6}|#\d{1,7}|[a-zA-Z][a-zA-Z0-9]{1,31});/g;
/**
* A complete tag, or the start of a comment. What decides that a description is markup rather than
* prose that happens to contain an angle bracket, which is why it wants a closing `>` and not just
* something that starts hopefully.
*/
const HAS_TAG = /<(?:\/?[a-zA-Z][a-zA-Z0-9]*(?:\s[^<>]*)?\/?>|!--)/;
// A Map rather than an object literal, so that `&constructor;` cannot walk into Object.prototype
// and hand a function back to the replacer. Escapes rather than the characters themselves, because
// half of these are indistinguishable from a plain space or hyphen in a source file.
const NAMED = new Map<string, string>([
["amp", "&"],
["lt", "<"],
["gt", ">"],
["quot", '"'],
["apos", "'"],
["nbsp", "\u00a0"],
["ensp", "\u2002"],
["emsp", "\u2003"],
["thinsp", "\u2009"],
["shy", ""],
["hellip", "…"],
["mdash", "\u2014"],
["ndash", "\u2013"],
["lsquo", "‘"],
["rsquo", "’"],
["ldquo", "“"],
["rdquo", "”"],
["bull", "•"],
["middot", "·"],
["laquo", "«"],
["raquo", "»"],
["copy", "©"],
["reg", "®"],
["trade", "™"],
["deg", "°"],
["plusmn", "±"],
["times", "×"],
["divide", "÷"],
["frac12", "½"],
["frac14", "¼"],
["euro", "€"],
["pound", "£"],
["yen", "¥"],
["cent", "¢"],
["sect", "§"],
["para", "¶"],
["dagger", "†"],
["prime", "′"],
["ne", "≠"],
["le", "≤"],
["ge", "≥"],
["larr", "←"],
["rarr", "→"],
["harr", "↔"],
]);
/** True when the string contains something tag-shaped, which is what makes it worth parsing. */
export function hasMarkup(text: string): boolean {
return HAS_TAG.test(text);
}
/**
* Character references, named and numeric. Anything unrecognised is left exactly as it was found,
* which is both what a browser does and the safe direction to fail in: an undecoded `&#38;` is a
* cosmetic wart, an over-eager decode is a way to smuggle a delimiter past a check.
*/
export function decodeEntities(text: string): string {
if (!text.includes("&")) return text;
return text.replace(ENTITY, (whole, body: string) => {
if (body.startsWith("#")) {
const hex = body[1] === "x" || body[1] === "X";
const value = Number.parseInt(hex ? body.slice(2) : body.slice(1), hex ? 16 : 10);
return codePoint(value) ?? whole;
}
return NAMED.get(body) ?? NAMED.get(body.toLowerCase()) ?? whole;
});
}
/** Surrogates, out-of-range values and control characters never come back as text. */
function codePoint(value: number): string | null {
if (!Number.isFinite(value) || value <= 0 || value > 0x10ffff) return null;
if (value >= 0xd800 && value <= 0xdfff) return null;
if (value < 0x20 && value !== 0x09 && value !== 0x0a) return null;
return String.fromCodePoint(value);
}
/**
* The URL of a link, or null if it is not one we will follow.
*
* A whitelist of schemes, checked after decoding, because `&#106;avascript:` and a tab wedged into
* the middle of the word are both older than this app. Since the test is "does it begin with one
* of three known schemes" rather than "is it one of the bad ones", obfuscation has nothing to win:
* anything it produces that is not plainly http, https or mailto is dropped.
*/
export function safeHref(raw: string | null | undefined): string | null {
if (!raw) return null;
const cleaned = decodeEntities(raw).replace(INVISIBLE, "");
if (!cleaned || !SAFE_SCHEME.test(cleaned)) return null;
return cleaned;
}
/** What the card should render for a description, whichever of the two shapes it arrived in. */
export function parseDescription(raw: string | null | undefined): Description {
const source = raw ?? "";
if (!source.trim()) return { plain: true, nodes: [] };
if (!hasMarkup(source)) return { plain: true, nodes: tidy(plainNodes(source), null) };
return { plain: false, nodes: tidy(htmlNodes(source), null) };
}
/** Plain text: the newlines are the whole structure, and everything else is left alone. */
function plainNodes(text: string): DescNode[] {
const decoded = decodeEntities(text);
const out: DescNode[] = [];
decoded.split(/\r\n|\r|\n/).forEach((line, index) => {
if (index > 0) out.push({ kind: "break" });
if (line) out.push({ kind: "text", text: line });
});
return out;
}
interface Frame {
/** The source name, which is what a closing tag is matched against. */
name: string;
/** null means transparent: the element is not rendered and its children join its parent. */
tag: DescTag | null;
href?: string;
children: DescNode[];
}
/**
* A stack machine over the token stream. Unknown elements become transparent frames rather than
* being dropped with their contents, which is what makes `<span>`, `<font>` and a pasted table
* degrade to the words they contained. Unclosed tags close themselves at the end, stray closing
* tags are ignored, and a closing tag that matches something further down the stack closes
* everything above it, so no input can leave the tree unbalanced.
*/
function htmlNodes(html: string): DescNode[] {
const root: Frame = { name: "", tag: null, children: [] };
const stack: Frame[] = [root];
const top = (): Frame => stack[stack.length - 1];
const closeTop = (): void => {
if (stack.length < 2) return;
const frame = stack.pop() as Frame;
const parent = top();
if (frame.tag === null) parent.children.push(...frame.children);
else if (frame.tag === "a") {
parent.children.push({ kind: "element", tag: "a", href: frame.href, children: frame.children });
} else parent.children.push({ kind: "element", tag: frame.tag, children: frame.children });
};
const addText = (chunk: string): void => {
// HTML's own whitespace rules: a run of it is one space. `&nbsp;` survives because it is
// decoded after the collapse, which is the difference between a space and a hard space.
const text = decodeEntities(chunk.replace(/[\t\n\f\r ]+/g, " "));
if (text) top().children.push({ kind: "text", text });
};
let i = 0;
while (i < html.length) {
const lt = html.indexOf("<", i);
if (lt < 0) {
addText(html.slice(i));
break;
}
if (lt > i) addText(html.slice(i, lt));
if (html.startsWith("<!--", lt)) {
const end = html.indexOf("-->", lt + 4);
i = end < 0 ? html.length : end + 3;
continue;
}
if (html.startsWith("<!", lt) || html.startsWith("<?", lt)) {
const end = html.indexOf(">", lt);
i = end < 0 ? html.length : end + 1;
continue;
}
MARKUP.lastIndex = lt;
const tag = MARKUP.exec(html);
if (!tag) {
// Something tag-shaped that never finished parsing: a browser drops it, and so do we, rather
// than spilling half an attribute list into the card as words. A bare `<` in prose, which is
// the far more common case, is text and stays text.
if (/^<\/?[a-zA-Z]/.test(html.slice(lt, lt + 3))) {
const end = html.indexOf(">", lt);
i = end < 0 ? html.length : end + 1;
} else {
addText("<");
i = lt + 1;
}
continue;
}
i = MARKUP.lastIndex;
const name = tag[2].toLowerCase();
const attrs = tag[3] ?? "";
if (tag[1] === "/") {
for (let at = stack.length - 1; at > 0; at -= 1) {
if (stack[at].name !== name) continue;
while (stack.length > at) closeTop();
break;
}
continue;
}
if (RAW_TEXT.has(name)) {
// Everything up to the matching close is the element's raw text, and none of it is ours.
// An unterminated one swallows the rest of the description, which is what a browser does.
const close = new RegExp(`</\\s*${name}(?:\\s[^>]*)?>`, "i");
const found = close.exec(html.slice(i));
i = found ? i + found.index + found[0].length : html.length;
continue;
}
if (name === "br") {
top().children.push({ kind: "break" });
continue;
}
if (VOID.has(name)) continue;
// `<li>a<li>b` is two items, not one nested inside the other.
if (name === "li" && top().name === "li") closeTop();
let mapped = stack.length > MAX_DEPTH ? null : TAGS.get(name) ?? null;
let href: string | undefined;
if (mapped === "a") {
href = safeHref(attribute(attrs, "href")) ?? undefined;
// A link we will not follow is not a link. Its text still belongs to the reader.
if (!href) mapped = null;
}
stack.push({ name, tag: mapped, href, children: [] });
if (/\/\s*$/.test(attrs)) closeTop();
}
while (stack.length > 1) closeTop();
return root.children;
}
/** One attribute out of a tag's attribute text. Every other attribute is discarded unread. */
function attribute(attrs: string, want: string): string | null {
ATTRIBUTE.lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = ATTRIBUTE.exec(attrs)) !== null) {
if (match[1].toLowerCase() !== want) continue;
const value = match[2];
if (value === undefined) return null;
const quoted = value.startsWith('"') || value.startsWith("'");
return quoted ? value.slice(1, -1) : value;
}
return null;
}
const isBreak = (node: DescNode): boolean => node.kind === "break";
const isBlank = (node: DescNode): boolean =>
isBreak(node) || (node.kind === "text" && node.text.trim() === "");
/**
* The cosmetic pass. Descriptions arrive padded with the editor's leftovers: empty paragraphs, a
* run of six `<br>` at the end, list markup with loose text in it. None of that should turn into
* white space the reader has to scroll past.
*/
function tidy(nodes: readonly DescNode[], parent: DescTag | null): DescNode[] {
const out: DescNode[] = [];
for (const node of nodes) {
if (node.kind === "text") {
out.push(node);
continue;
}
if (node.kind === "break") {
// Two is a blank line. Beyond that it is someone leaning on the return key.
const run = out.length >= 2 && isBreak(out[out.length - 1]) && isBreak(out[out.length - 2]);
if (!run) out.push(node);
continue;
}
// An `<li>` that lost its list reads as a paragraph rather than as a stray bullet.
const tag = node.tag === "li" && parent !== "ul" && parent !== "ol" ? "block" : node.tag;
let children = tidy(node.children, tag);
if (tag === "ul" || tag === "ol") children = intoItems(children);
if (children.length === 0) continue;
out.push(
node.href
? { kind: "element", tag, href: node.href, children }
: { kind: "element", tag, children },
);
}
return trimEdges(out);
}
/** Loose content directly inside a list is gathered into items, so `ul` only ever holds `li`. */
function intoItems(children: readonly DescNode[]): DescNode[] {
const items: DescNode[] = [];
let loose: DescNode[] = [];
const flush = (): void => {
const trimmed = trimEdges(loose);
if (trimmed.length > 0) items.push({ kind: "element", tag: "li", children: trimmed });
loose = [];
};
for (const child of children) {
if (child.kind === "element" && child.tag === "li") {
flush();
items.push(child);
} else loose.push(child);
}
flush();
return items;
}
/** Blank leading and trailing nodes go, and the text at either end loses its outer whitespace. */
function trimEdges(nodes: readonly DescNode[]): DescNode[] {
let from = 0;
let to = nodes.length;
while (from < to && isBlank(nodes[from])) from += 1;
while (to > from && isBlank(nodes[to - 1])) to -= 1;
const out = nodes.slice(from, to);
if (out.length === 0) return [];
const first = out[0];
if (first.kind === "text") out[0] = { kind: "text", text: first.text.replace(/^[ \t]+/, "") };
const last = out[out.length - 1];
if (last.kind === "text") out[out.length - 1] = { kind: "text", text: last.text.replace(/[ \t]+$/, "") };
return out.filter((node) => node.kind !== "text" || node.text !== "");
}
+169
View File
@@ -0,0 +1,169 @@
import { describe, expect, it } from "vitest";
import {
badgesOf,
conferenceLabel,
isStrandedAnchor,
orderGuests,
place,
type Bounds,
} from "./EventDetailsModel";
import type { Attendee, Instance } from "../ipc";
const bounds: Bounds = { top: 54, left: 8, right: 1192, bottom: 792 };
const card = { width: 336, height: 300 };
describe("place", () => {
it("prefers the right of the block, aligned to its top", () => {
const at = place({ top: 200, left: 300, width: 120, height: 40 }, card, bounds);
expect(at).toEqual({ top: 200, left: 430, side: "right" });
});
it("flips to the left when the right would not fit", () => {
const at = place({ top: 200, left: 1000, width: 150, height: 40 }, card, bounds);
expect(at).toEqual({ top: 200, left: 654, side: "left" });
});
it("goes below when neither side fits, centred across the block", () => {
const narrow: Bounds = { top: 54, left: 8, right: 420, bottom: 792 };
const at = place({ top: 100, left: 120, width: 160, height: 40 }, card, narrow);
expect(at.side).toBe("below");
expect(at.top).toBe(150);
expect(at.left).toBe(32);
});
it("goes above when there is no room below", () => {
const shallow: Bounds = { top: 54, left: 8, right: 420, bottom: 600 };
const at = place({ top: 500, left: 120, width: 160, height: 40 }, card, shallow);
expect(at).toEqual({ top: 190, left: 32, side: "above" });
});
it("never lets the card hang off the top or the bottom", () => {
const high = place({ top: 30, left: 300, width: 120, height: 60 }, { width: 336, height: 700 }, bounds);
expect(high.top).toBe(54);
const low = place({ top: 700, left: 300, width: 120, height: 20 }, card, bounds);
expect(low.top).toBe(492);
expect(low.top + card.height).toBeLessThanOrEqual(bounds.bottom);
});
it("takes the middle of the window when the anchor has no size", () => {
const at = place({ top: 400, left: 600, width: 0, height: 0 }, card, bounds);
expect(at.side).toBe("centre");
expect(at.left).toBe(432);
expect(at.top).toBe(273);
});
it("takes the middle of the window when there is no anchor at all", () => {
expect(place(null, card, bounds).side).toBe("centre");
});
it("stays inside bounds smaller than the card itself", () => {
const tiny: Bounds = { top: 54, left: 8, right: 200, bottom: 200 };
const at = place({ top: 100, left: 20, width: 40, height: 10 }, card, tiny);
expect(at.top).toBe(54);
expect(at.left).toBe(8);
});
});
describe("isStrandedAnchor", () => {
it("catches a rectangle scrolled off the top", () => {
expect(isStrandedAnchor({ top: -80, left: 300, width: 100, height: 40 }, bounds)).toBe(true);
});
it("catches a rectangle past the right edge", () => {
expect(isStrandedAnchor({ top: 200, left: 1400, width: 100, height: 40 }, bounds)).toBe(true);
});
it("accepts a rectangle only partly on screen", () => {
expect(isStrandedAnchor({ top: 20, left: 300, width: 100, height: 80 }, bounds)).toBe(false);
});
});
const instance = (over: Partial<Instance> = {}): Instance => ({
colorId: null,
eventId: "ev-1",
calendarId: "cal-1",
accountId: "acct-1",
originalStart: null,
start: "2026-08-11T09:00:00+05:30",
end: "2026-08-11T09:30:00+05:30",
startMs: 0,
endMs: 0,
allDay: false,
summary: "Stand-up",
description: null,
location: null,
status: "confirmed",
recurring: false,
colorHex: "#6f6194",
etag: null,
organizer: null,
attendees: [],
conference: null,
readOnly: false,
pending: false,
...over,
});
describe("badgesOf", () => {
it("says nothing about an ordinary event", () => {
expect(badgesOf(instance())).toEqual([]);
});
it("names everything unusual about the occurrence", () => {
expect(badgesOf(instance({ status: "tentative", recurring: true, pending: true, readOnly: true }))).toEqual([
"tentative",
"repeats",
"not synced yet",
"read only",
]);
});
});
describe("conferenceLabel", () => {
it("prefers the label Google sent", () => {
const c = { kind: "hangoutsMeet", uri: "https://meet.google.com/a", label: "meet.google.com/a" };
expect(conferenceLabel(instance({ conference: c }))).toBe("meet.google.com/a");
});
it("names Meet when there is no label", () => {
const c = { kind: "hangoutsMeet", uri: "https://meet.google.com/a", label: null };
expect(conferenceLabel(instance({ conference: c }))).toBe("Google Meet");
});
it("is empty when there is no conference", () => {
expect(conferenceLabel(instance())).toBe("");
});
});
const guest = (over: Partial<Attendee>): Attendee => ({
email: "[email protected]",
displayName: null,
responseStatus: "needsAction",
organizer: false,
self: false,
optional: false,
...over,
});
describe("orderGuests", () => {
it("puts the organiser first and the declines last", () => {
const list = [
guest({ email: "[email protected]", responseStatus: "declined" }),
guest({ email: "[email protected]", responseStatus: "accepted" }),
guest({ email: "[email protected]", responseStatus: "needsAction", organizer: true }),
guest({ email: "[email protected]", responseStatus: "tentative" }),
];
expect(orderGuests(list).map((a) => a.email)).toEqual([
"[email protected]",
"[email protected]",
"[email protected]",
"[email protected]",
]);
});
it("does not mutate what it was handed", () => {
const list = [guest({ email: "[email protected]", responseStatus: "declined" }), guest({ email: "[email protected]" })];
orderGuests(list);
expect(list[0].email).toBe("[email protected]");
});
});
+144
View File
@@ -0,0 +1,144 @@
// Pure helpers for the details card: where it goes, and the small amount of vocabulary its
// content needs. Nothing here holds state or touches the DOM, which is what makes it testable in
// a node run.
//
// The placement is the only genuinely fiddly part. The card is anchored to the block that was
// clicked rather than centred, so it has to find a side that fits and then stay inside the window
// whatever happens, including when the anchor is offscreen, has no size at all, or the card is
// taller than the space it was given.
import type { Attendee, Instance } from "../ipc";
/** Viewport coordinates, the same shape `useDetails` stores and a DOMRect satisfies. */
export interface Rect {
top: number;
left: number;
width: number;
height: number;
}
export interface Size {
width: number;
height: number;
}
/** The region the card is allowed to occupy, in viewport coordinates. */
export interface Bounds {
top: number;
left: number;
right: number;
bottom: number;
}
/** Which side of the anchor the card ended up on. `centre` means it gave up and took the window. */
export type Side = "right" | "left" | "below" | "above" | "centre";
export interface Placement {
top: number;
left: number;
side: Side;
}
/** The breathing room between the block and the card. */
export const ANCHOR_GAP = 10;
function clamp(value: number, low: number, high: number): number {
if (high < low) return low;
return value < low ? low : value > high ? high : value;
}
/**
* True when the rectangle is not worth aiming at: absent, sizeless, or entirely outside the
* region the card may occupy. `openDetailsFor` hands over a zero-size rect at the middle of the
* window when it had no element to measure, and that is exactly the case this catches.
*/
export function isStrandedAnchor(anchor: Rect | null, bounds: Bounds): boolean {
if (!anchor) return true;
if (anchor.width <= 0 && anchor.height <= 0) return true;
return (
anchor.left + anchor.width <= bounds.left ||
anchor.left >= bounds.right ||
anchor.top + anchor.height <= bounds.top ||
anchor.top >= bounds.bottom
);
}
/**
* Right of the block, then left, then below, then above, then the middle of the window. The first
* side the card fits on wins, and the cross axis is clamped rather than allowed to hang off an
* edge, so the card is always whole and always on screen.
*/
export function place(anchor: Rect | null, card: Size, bounds: Bounds, gap = ANCHOR_GAP): Placement {
const middle = (): Placement => ({
top: clamp(Math.round((bounds.top + bounds.bottom - card.height) / 2), bounds.top, bounds.bottom - card.height),
left: clamp(Math.round((bounds.left + bounds.right - card.width) / 2), bounds.left, bounds.right - card.width),
side: "centre",
});
if (isStrandedAnchor(anchor, bounds)) return middle();
const a = anchor as Rect;
const beside = (left: number, side: Side): Placement => ({
top: clamp(Math.round(a.top), bounds.top, bounds.bottom - card.height),
left: Math.round(left),
side,
});
const right = a.left + a.width + gap;
if (right + card.width <= bounds.right) return beside(right, "right");
const left = a.left - gap - card.width;
if (left >= bounds.left) return beside(left, "left");
const across = clamp(
Math.round(a.left + a.width / 2 - card.width / 2),
bounds.left,
bounds.right - card.width,
);
const below = a.top + a.height + gap;
if (below + card.height <= bounds.bottom) return { top: Math.round(below), left: across, side: "below" };
const above = a.top - gap - card.height;
if (above >= bounds.top) return { top: Math.round(above), left: across, side: "above" };
return middle();
}
const RESPONSE_ORDER: Record<Attendee["responseStatus"], number> = {
accepted: 0,
tentative: 1,
needsAction: 2,
declined: 3,
};
/** The organiser first, then who is coming, then who is not. Read-only: v1 has no RSVP flow. */
export function orderGuests(attendees: readonly Attendee[]): Attendee[] {
return attendees
.slice()
.sort(
(a, b) =>
Number(b.organizer) - Number(a.organizer) ||
RESPONSE_ORDER[a.responseStatus] - RESPONSE_ORDER[b.responseStatus] ||
(a.displayName ?? a.email).localeCompare(b.displayName ?? b.email),
);
}
/** What to call the conference link, falling back through label, kind, then something honest. */
export function conferenceLabel(instance: Instance): string {
const conference = instance.conference;
if (!conference) return "";
if (conference.label) return conference.label;
if (conference.kind === "hangoutsMeet") return "Google Meet";
return conference.kind || "Join";
}
/** The badges under the title: everything unusual about this occurrence, and nothing else. */
export function badgesOf(instance: Instance): string[] {
const out: string[] = [];
if (instance.status === "tentative") out.push("tentative");
if (instance.recurring) out.push("repeats");
if (instance.pending) out.push("not synced yet");
if (instance.readOnly) out.push("read only");
return out;
}
+602
View File
@@ -0,0 +1,602 @@
// One event, created or edited. Summary, calendar, when, where and the description are writable;
// attendees and the conference link are not, deliberately, because v1 has no RSVP flow and no
// scheduling anything.
//
// The one genuinely subtle thing here is scope. Saving or deleting an occurrence of a recurring
// series does something different to the server depending on whether you meant this one, this one
// and everything after it, or the whole series, so the panel stops and asks. There is no default,
// because a default is exactly what gets missed.
import { useMemo, useState, type FormEvent } from "react";
import { openUrl } from "@tauri-apps/plugin-opener";
import { eventCreate, eventDelete, eventUpdate } from "../api/events";
import { useEscapeLayer } from "../escape";
import type { Calendar, EventDraft, EventPatch, Instance, InstanceKey, Scope } from "../ipc";
import { useAccounts } from "../store/useAccounts";
import { useCalendarView } from "../store/useCalendarView";
import { useOverlays } from "../store/useOverlays";
import { notify } from "../store/useToast";
import { parseDateOnly, toDateOnly, toOffsetIso } from "../time";
import "../styles/create.css";
import { ColorPicker } from "./ColorPicker";
import { parseDescription } from "./EventDetailsHtml";
import { Icon } from "./Icon";
import { RichText } from "./RichText";
import {
allDayEndInput,
allDayEndWire,
attendeeTally,
canWrite,
defaultRange,
fromDateTime,
responseLabel,
toTimeOnly,
whenText,
} from "./overlayModel";
import { Confirm, Sheet } from "./overlayShell";
import { calSlot, eventTitle } from "./GridModel";
import { useEditor, type EditorSeed, type EditorTarget } from "./useEditor";
const FORM_ID = "event-editor-form";
const LINK = "M10 13a5 5 0 0 0 7 0l3-3a5 5 0 0 0-7-7l-2 2M14 11a5 5 0 0 0-7 0l-3 3a5 5 0 0 0 7 7l2-2";
const REPEAT = "M17 2l4 4-4 4M3 11V9a4 4 0 0 1 4-4h14M7 22l-4-4 4-4M21 13v2a4 4 0 0 1-4 4H3";
const LOCK = "M5 11h14v10H5zM8 11V7a4 4 0 0 1 8 0v4";
interface Form {
summary: string;
calendarId: string;
allDay: boolean;
startDate: string;
startTime: string;
endDate: string;
endTime: string;
location: string;
description: string;
/** Google's per-event colour id. Empty means the event takes its calendar's colour. */
colorId: string;
}
interface Times {
startMs: number;
endMs: number;
start: string;
end: string;
valid: boolean;
}
function formFor(instance: Instance | null, seed: EditorSeed, anchor: number, fallback: string): Form {
if (instance) {
return {
summary: instance.summary,
calendarId: instance.calendarId,
allDay: instance.allDay,
startDate: toDateOnly(instance.startMs),
startTime: toTimeOnly(instance.startMs),
endDate: instance.allDay ? allDayEndInput(instance.endMs) : toDateOnly(instance.endMs),
endTime: toTimeOnly(instance.endMs),
location: instance.location ?? "",
description: instance.description ?? "",
colorId: instance.colorId ?? "",
};
}
const range = defaultRange(anchor);
const startMs = seed.startMs ?? range.startMs;
const endMs = seed.endMs ?? startMs + (range.endMs - range.startMs);
const allDay = seed.allDay ?? false;
return {
summary: seed.summary ?? "",
calendarId: seed.calendarId ?? fallback,
allDay,
startDate: toDateOnly(startMs),
startTime: toTimeOnly(startMs),
endDate: allDay ? allDayEndInput(endMs) : toDateOnly(endMs),
endTime: toTimeOnly(endMs),
location: seed.location ?? "",
description: seed.description ?? "",
colorId: seed.colorId ?? "",
};
}
/** What the form means in wire terms, and whether it means anything at all. */
function timesOf(form: Form): Times {
if (form.allDay) {
const startMs = parseDateOnly(form.startDate);
const endMs = parseDateOnly(form.endDate);
const valid = Number.isFinite(startMs) && Number.isFinite(endMs) && endMs >= startMs;
return { startMs, endMs, start: form.startDate, end: valid ? allDayEndWire(form.endDate) : "", valid };
}
const startMs = fromDateTime(form.startDate, form.startTime);
const endMs = fromDateTime(form.endDate, form.endTime);
const valid = Number.isFinite(startMs) && Number.isFinite(endMs) && endMs > startMs;
return {
startMs,
endMs,
start: valid ? toOffsetIso(startMs) : "",
end: valid ? toOffsetIso(endMs) : "",
valid,
};
}
/** Only what moved. An absent field means unchanged; an empty string clears the two that allow it. */
function patchOf(form: Form, times: Times, instance: Instance): EventPatch {
const patch: EventPatch = {};
if (form.summary !== instance.summary) patch.summary = form.summary;
if (form.location !== (instance.location ?? "")) patch.location = form.location;
if (form.description !== (instance.description ?? "")) patch.description = form.description;
if (form.calendarId !== instance.calendarId) patch.calendarId = form.calendarId;
// The empty string is meaningful here: it is how the event is put back on its calendar's colour.
if (form.colorId !== (instance.colorId ?? "")) patch.colorId = form.colorId;
// The panel holds the inclusive last day of an all-day event, so compare like with like.
const wasEnd = instance.allDay ? parseDateOnly(allDayEndInput(instance.endMs)) : instance.endMs;
if (form.allDay !== instance.allDay || times.startMs !== instance.startMs || times.endMs !== wasEnd) {
patch.allDay = form.allDay;
patch.start = times.start;
patch.end = times.end;
}
return patch;
}
const targetId = (target: EditorTarget): string =>
target.mode === "create" ? "create" : `edit ${target.key.eventId} ${target.key.originalStart ?? ""}`;
export interface EventEditorProps {
/** Where a new event lands when nothing seeded a calendar. */
defaultCalendarId?: string;
}
/**
* Nothing is resident: the panel below only exists while the overlay is open, which is what keeps
* its form state from surviving into the next event you open.
*/
export function EventEditor({ defaultCalendarId }: EventEditorProps) {
const open = useOverlays((s) => s.open);
const target = useEditor((s) => s.target);
if (open !== "editor") return null;
const resolved: EditorTarget = target ?? { mode: "create", seed: {} };
return (
<EditorPanel
key={targetId(resolved)}
target={resolved}
defaultCalendarId={defaultCalendarId}
/>
);
}
interface EditorPanelProps {
target: EditorTarget;
defaultCalendarId?: string;
}
function EditorPanel({ target, defaultCalendarId }: EditorPanelProps) {
const dismiss = useEditor((s) => s.close);
const calendars = useAccounts((s) => s.calendars);
const instances = useCalendarView((s) => s.instances);
const anchor = useCalendarView((s) => s.anchor);
const key: InstanceKey | null = target.mode === "edit" ? target.key : null;
const instance = useMemo(
() =>
key
? (instances.find((i) => i.eventId === key.eventId && i.originalStart === key.originalStart) ?? null)
: null,
[instances, key],
);
const writable = useMemo(
() => calendars.filter((c) => canWrite(c.accessRole) || c.id === instance?.calendarId),
[calendars, instance],
);
const fallback = defaultCalendarId ?? writable.find((c) => c.primary)?.id ?? writable[0]?.id ?? "";
const [form, setForm] = useState<Form>(() =>
formFor(instance, target.mode === "create" ? target.seed : {}, anchor, fallback),
);
const [ask, setAsk] = useState<{ action: "save" | "delete" } | null>(null);
const [busy, setBusy] = useState(false);
// The swatch for "same as calendar" previews the calendar it would follow, drawn in the slot
// that calendar's dot is drawn in everywhere else rather than in the hex Google stores.
const chosen = calendars.find((c) => c.id === form.calendarId) ?? null;
const calendarColor = chosen
? `var(--cal-${calSlot(chosen.colorHex, chosen.id)})`
: "var(--line-strong)";
const recurring = instance?.recurring ?? false;
const readOnly = instance?.readOnly ?? false;
const times = timesOf(form);
const scopeStep = ask !== null && recurring;
useEscapeLayer(scopeStep, () => setAsk(null));
const set = <K extends keyof Form>(field: K, value: Form[K]) =>
setForm((prev) => ({ ...prev, [field]: value }));
const close = () => {
setAsk(null);
dismiss();
};
async function run(action: "save" | "delete", scope: Scope) {
setBusy(true);
try {
if (action === "delete") {
if (!key) {
close();
return;
}
await eventDelete(key, scope);
notify("Event deleted");
} else if (key && instance) {
const patch = patchOf(form, times, instance);
if (Object.keys(patch).length > 0) await eventUpdate(key, patch, scope);
} else {
const draft: EventDraft = {
calendarId: form.calendarId,
summary: form.summary.trim() || "Untitled",
description: form.description || null,
location: form.location || null,
start: times.start,
end: times.end,
allDay: form.allDay,
// Absent rather than empty when it follows the calendar, which is what the wire reads
// as no override.
...(form.colorId ? { colorId: form.colorId } : {}),
};
await eventCreate(draft);
}
void useCalendarView.getState().load();
close();
} catch (e) {
notify(action === "delete" ? `Could not delete the event: ${e}` : `Could not save the event: ${e}`);
setAsk(null);
} finally {
setBusy(false);
}
}
const onSubmit = (e: FormEvent) => {
e.preventDefault();
if (!times.valid || !form.calendarId || busy) return;
if (recurring) setAsk({ action: "save" });
else void run("save", "this");
};
if (scopeStep && ask) {
const action = ask.action;
return (
<Sheet
open
title={action === "delete" ? "Delete which?" : "Save which?"}
size="wide"
onClose={close}
foot={
<button type="button" className="panel-button" onClick={() => setAsk(null)}>
Back
</button>
}
>
<p className="panel-note">
{instance?.summary || "This event"} repeats. Choose what this{" "}
{action === "delete" ? "deletion" : "change"} applies to.
</p>
<div className="scope-list">
{SCOPES.map(([scope, name, note]) => (
<button
key={scope}
type="button"
className="scope-option"
data-variant={action === "delete" ? "danger" : undefined}
disabled={busy}
onClick={() => void run(action, scope)}
>
<span className="scope-name">{name}</span>
<span className="scope-note">{note}</span>
</button>
))}
</div>
</Sheet>
);
}
if (ask) {
return (
<Sheet open title="Delete event" onClose={close}>
<Confirm
title={`Delete ${instance?.summary || "this event"}?`}
body={<p>It goes from this calendar for everyone it was shared with.</p>}
confirmLabel="Delete"
busy={busy}
onConfirm={() => void run("delete", "this")}
onCancel={() => setAsk(null)}
/>
</Sheet>
);
}
if (target.mode === "edit" && !instance) {
return (
<Sheet
open
title="Event"
onClose={close}
foot={
<button type="button" className="panel-button" data-autofocus="" onClick={close}>
Close
</button>
}
>
<p className="panel-note">That event is no longer in the range you are looking at.</p>
</Sheet>
);
}
if (readOnly && instance) {
return (
<Sheet
open
title="Event"
size="wide"
onClose={close}
foot={
<button type="button" className="panel-button" data-autofocus="" onClick={close}>
Close
</button>
}
>
<ReadOnlyEvent instance={instance} calendars={calendars} />
</Sheet>
);
}
return (
<Sheet
open
title={target.mode === "create" ? "New event" : "Edit event"}
size="wide"
onClose={close}
foot={
<>
{target.mode === "edit" && (
<button
type="button"
className="panel-button"
data-variant="danger"
data-side="lead"
disabled={busy}
onClick={() => setAsk({ action: "delete" })}
>
Delete
</button>
)}
<button type="button" className="panel-button" onClick={close}>
Cancel
</button>
<button
type="submit"
form={FORM_ID}
className="panel-button"
data-variant="primary"
disabled={busy || !times.valid || !form.calendarId}
>
{target.mode === "create" ? "Create" : "Save"}
</button>
</>
}
>
<form id={FORM_ID} className="editor" onSubmit={onSubmit} onKeyDown={(e) => e.stopPropagation()}>
<label className="field">
<span className="field-label">Summary</span>
<input
className="field-input"
value={form.summary}
placeholder="Untitled"
data-autofocus=""
onChange={(e) => set("summary", e.target.value)}
/>
</label>
<label className="field">
<span className="field-label">Calendar</span>
<select
className="field-select"
value={form.calendarId}
onChange={(e) => set("calendarId", e.target.value)}
>
{writable.length === 0 && <option value="">Nothing you can write to</option>}
{writable.map((c) => (
<option key={c.id} value={c.id}>
{c.summary || "Untitled"}
</option>
))}
</select>
</label>
<div className="field">
<span className="field-label">Colour</span>
<ColorPicker
value={form.colorId || null}
onChange={(colorId) => set("colorId", colorId ?? "")}
calendarColor={calendarColor}
/>
</div>
<div className="field">
<span className="field-label">When</span>
<div className="field-row">
<input
type="date"
className="field-input"
value={form.startDate}
onChange={(e) => set("startDate", e.target.value)}
/>
{!form.allDay && (
<input
type="time"
className="field-input"
value={form.startTime}
onChange={(e) => set("startTime", e.target.value)}
/>
)}
<span className="field-to">to</span>
<input
type="date"
className="field-input"
value={form.endDate}
onChange={(e) => set("endDate", e.target.value)}
/>
{!form.allDay && (
<input
type="time"
className="field-input"
value={form.endTime}
onChange={(e) => set("endTime", e.target.value)}
/>
)}
</div>
<label className="field-check">
<input
type="checkbox"
checked={form.allDay}
onChange={(e) => set("allDay", e.target.checked)}
/>
All day
</label>
{!times.valid && (
<span className="field-hint" data-error="">
That ends before it starts.
</span>
)}
{recurring && (
<span className="field-hint">
<Icon d={REPEAT} size={13} />
Repeats, so saving will ask what the change applies to.
</span>
)}
</div>
<label className="field">
<span className="field-label">Location</span>
<input
className="field-input"
value={form.location}
placeholder="Somewhere"
onChange={(e) => set("location", e.target.value)}
/>
</label>
<label className="field">
<span className="field-label">Description</span>
<textarea
className="field-textarea"
rows={3}
value={form.description}
onChange={(e) => set("description", e.target.value)}
/>
</label>
{instance && <Guests instance={instance} />}
</form>
</Sheet>
);
}
const SCOPES: [Scope, string, string][] = [
["this", "This event", "Only the occurrence you opened."],
["following", "This and following", "This one and every later occurrence. Earlier ones keep what they had."],
["all", "All events", "The whole series, including the occurrences already past."],
];
const nameOf = (calendars: readonly Calendar[], id: string): string =>
calendars.find((c) => c.id === id)?.summary || "Unknown calendar";
/** Read-only on purpose: no RSVP, no availability finder and no scheduling links in v1. */
function Guests({ instance }: { instance: Instance }) {
const tally = attendeeTally(instance.attendees);
const uri = instance.conference?.uri ?? null;
const label = instance.conference?.label || instance.conference?.kind || "Join";
if (!tally && !uri) return null;
return (
<>
{uri && (
<div className="field">
<span className="field-label">Conference</span>
<button
type="button"
className="panel-button"
data-variant="ghost"
data-align="lead"
onClick={() => openUrl(uri).catch(() => {})}
>
<Icon d={LINK} size={14} />
{label}
</button>
</div>
)}
{tally && (
<div className="field">
<span className="field-label">Attendees</span>
<span className="field-hint">{tally}</span>
<ul className="guest-list">
{instance.attendees.map((a) => (
<li className="guest" key={a.email} data-response={a.responseStatus}>
<span className="guest-dot" />
<span className="guest-name">{a.displayName || a.email}</span>
{a.organizer && <span className="guest-tag">organiser</span>}
{a.optional && <span className="guest-tag">optional</span>}
<span className="guest-response">{responseLabel(a.responseStatus)}</span>
</li>
))}
</ul>
</div>
)}
</>
);
}
function ReadOnlyEvent({ instance, calendars }: { instance: Instance; calendars: readonly Calendar[] }) {
// Google's description field is HTML, so printing it as text shows the reader the markup. The
// editable path below keeps the raw string in its textarea on purpose: rendering it there and
// patching it back would rewrite formatting nobody touched.
const description = useMemo(() => parseDescription(instance.description), [instance.description]);
return (
<>
<p className="panel-note" data-icon="">
<Icon d={LOCK} size={13} />
This calendar is read only, so nothing here can be changed.
</p>
<div className="field">
<span className="field-label">Summary</span>
<span className="field-static">{eventTitle(instance)}</span>
</div>
<div className="field">
<span className="field-label">When</span>
<span className="field-static">{whenText(instance)}</span>
</div>
<div className="field">
<span className="field-label">Calendar</span>
<span className="field-static">{nameOf(calendars, instance.calendarId)}</span>
</div>
{instance.location && (
<div className="field">
<span className="field-label">Location</span>
<span className="field-static">{instance.location}</span>
</div>
)}
{description.nodes.length > 0 && (
<div className="field">
<span className="field-label">Description</span>
<div className="rich" data-plain={description.plain ? "" : undefined}>
<RichText nodes={description.nodes} />
</div>
</div>
)}
<Guests instance={instance} />
</>
);
}
export default EventEditor;
+106
View File
@@ -0,0 +1,106 @@
// The all-day band. All-day events live here, and so do multi-day timed ones: they genuinely
// occupy midnight, and putting them on the axis would pin it open at both ends for good.
//
// The band is exactly one row tall, always, and everything past that goes behind a count that
// expands as an overlay.
//
// It has to be a constant. The axis below is solved from whatever height is left, so a band that
// grew with its contents would change the row height as you paged, sliding every hour on the grid.
// That kills the positional memory the whole product is built on. A taller reserve would be the
// other sin, an empty band eating vertical space you cannot reclaim, so one row it is.
import { useMemo, useState } from "react";
import type { Instance, InstanceKey } from "../ipc";
import {
bandItems,
bandRows,
calVars,
eventTitle,
isBusy,
isDeclined,
keyOf,
sameKey,
vars,
type BandItem,
} from "./GridModel";
import { openDetailsFor } from "./useDetails";
/** Grid rows the band reserves. Constant on purpose: see the note above. */
const BAND_ROWS = 1;
interface GridAllDayProps {
days: readonly number[];
instances: readonly Instance[];
selected: InstanceKey | null;
onSelect: (key: InstanceKey) => void;
}
export function GridAllDay({ days, instances, selected, onSelect }: GridAllDayProps) {
const [expanded, setExpanded] = useState(false);
const rows = useMemo(() => bandRows(bandItems(instances, days)), [instances, days]);
// Expanding a band that never overflowed would leave a shadow over nothing, and the flag
// outlives a navigation, so it only counts while there is something to expand.
const overflowing = rows.length > BAND_ROWS;
const open = expanded && overflowing;
const shown = open ? rows : rows.slice(0, BAND_ROWS);
const hidden = open ? [] : rows.slice(BAND_ROWS);
const hiddenCount = hidden.reduce((n, row) => n + row.length, 0);
const chip = (item: BandItem, row: number) => (
<button
type="button"
key={item.id}
className="grid-chip"
title={eventTitle(item.instance)}
data-busy={isBusy(item.instance) || undefined}
data-declined={isDeclined(item.instance) || undefined}
data-status={item.instance.status}
data-pending={item.instance.pending || undefined}
data-readonly={item.instance.readOnly || undefined}
data-selected={sameKey(selected, keyOf(item.instance)) || undefined}
style={vars({
...calVars(item.instance),
gridColumn: `${item.from + 1} / ${item.to + 1}`,
gridRow: row + 1,
})}
onClick={(e) => {
onSelect(keyOf(item.instance));
openDetailsFor(keyOf(item.instance), e.currentTarget);
}}
>
{eventTitle(item.instance)}
</button>
);
return (
<div className="grid-allday" style={vars({ "--rows": BAND_ROWS })}>
<div className="grid-allday-gutter">all-day</div>
<div className="grid-allday-wrap">
<div className="grid-allday-rows" data-expanded={open || undefined}>
{shown.map((row, r) => row.map((item) => chip(item, r)))}
{hiddenCount > 0 && (
<button
type="button"
className="grid-chip-more"
title={`${hiddenCount} more all-day`}
onClick={() => setExpanded(true)}
>
{hiddenCount} more
</button>
)}
{open && (
<button
type="button"
className="grid-chip-less"
style={vars({ gridColumn: `1 / ${days.length + 1}`, gridRow: rows.length + 1 })}
onClick={() => setExpanded(false)}
>
Less
</button>
)}
</div>
</div>
</div>
);
}
+61
View File
@@ -0,0 +1,61 @@
// One day column. Packing decides the horizontal share, the fit decides the vertical, and neither
// of them knows about the other.
import { memo, useMemo, type PointerEvent as ReactPointerEvent } from "react";
import { timeToY, type FitLayout } from "../grid/fit";
import { packEvents } from "../grid/packing";
import type { InstanceKey } from "../ipc";
import { GridEvent } from "./GridEvent";
import { MIN_BLOCK_H, dayMinutes, keyId, keyOf, sameKey, type Placed } from "./GridModel";
import type { DragMode } from "./GridStore";
interface GridDayProps {
layout: FitLayout;
dayStart: number;
index: number;
items: readonly Placed[];
selected: InstanceKey | null;
dragId: string | null;
past: boolean;
today: boolean;
onPointerDownBlock: (e: ReactPointerEvent, item: Placed, mode: DragMode) => void;
}
export const GridDay = memo(function GridDay({
layout,
dayStart,
index,
items,
selected,
dragId,
past,
today,
onPointerDownBlock,
}: GridDayProps) {
const packed = useMemo(() => packEvents(items), [items]);
return (
<div className="grid-col" data-index={index} data-past={past || undefined} data-today={today || undefined}>
{packed.map((p) => {
const { startMin, endMin } = dayMinutes(p.event, dayStart);
const top = timeToY(layout, startMin);
const raw = timeToY(layout, endMin) - top;
// Both ends inside the same strip: it is hiding in there, and the strip carries the count.
if (raw < 1) return null;
return (
<GridEvent
key={p.event.id}
item={p.event}
top={top}
height={Math.max(raw, MIN_BLOCK_H)}
left={p.left}
width={p.width}
selected={sameKey(selected, keyOf(p.event.instance))}
dragging={dragId === keyId(keyOf(p.event.instance))}
onPointerDown={onPointerDownBlock}
/>
);
})}
</div>
);
});
+145
View File
@@ -0,0 +1,145 @@
// Drag out a range and the quick create card appears beside it. Enter commits, Escape puts it back.
// Same event the palette makes, different mood.
//
// This file is the anchor and nothing else: it turns the store's draft into a box over the range
// that was dragged, decides where the card can sit without leaving the grid body or covering the
// range, and hands the rest to `QuickCreateCard`. The range keeps its own shade under the card,
// because it is the only thing the user has said so far and hiding it would be the whole complaint.
import { useLayoutEffect, useMemo, useRef, useState } from "react";
import { useEscapeLayer } from "../escape";
import { timeToY } from "../grid/fit";
import { startOfDay } from "../time";
import "../styles/create.css";
import { MIN_BLOCK_H, dayMinutes, minutesToMs, vars } from "./GridModel";
import { useGrid, type Draft } from "./GridStore";
import { QuickCreateCard } from "./QuickCreateCard";
import { placeCard, type Placement, type Slot } from "./QuickCreateModel";
/** Between the range and the card, and between the card and the edge of the grid body. */
const GAP = 8;
const MARGIN = 8;
interface GridDraftProps {
/**
* The grid's title-only commit. The card creates the event itself: a title is not enough to say
* which calendar, where, or when once the fields have been touched. Kept so `GridView` compiles
* against the same component.
*/
onCommit?: (title: string) => void;
onCancel: () => void;
}
export function GridDraft({ onCancel }: GridDraftProps) {
const draft = useGrid((s) => s.draft);
const layout = useGrid((s) => s.layout);
const days = useGrid((s) => s.days);
useEscapeLayer(draft !== null, onCancel);
if (!draft || !layout) return null;
const dayStart = days[draft.dayIndex];
if (dayStart === undefined) return null;
// Keyed on the range, so a second draft never inherits the first one's title or its fields.
return (
<DraftAnchor
key={`${dayStart} ${draft.startMin} ${draft.endMin}`}
draft={draft}
dayStart={dayStart}
onClose={onCancel}
/>
);
}
interface DraftAnchorProps {
draft: Draft;
dayStart: number;
onClose: () => void;
}
function DraftAnchor({ draft, dayStart, onClose }: DraftAnchorProps) {
const layout = useGrid((s) => s.layout);
const dragged = useMemo<Slot>(
() => ({
startMs: minutesToMs(dayStart, draft.startMin),
endMs: minutesToMs(dayStart, draft.endMin),
allDay: false,
}),
[dayStart, draft.startMin, draft.endMin],
);
const [slot, setSlot] = useState<Slot>(dragged);
const [place, setPlace] = useState<Placement | null>(null);
const anchor = useRef<HTMLDivElement | null>(null);
const card = useRef<HTMLFormElement | null>(null);
// The shade follows the fields while they stay on this column. A parse that moves the event to
// another day leaves it where it was drawn and dims it: the sentence in the card carries the
// truth, and there is no honest place to draw a Thursday on a Tuesday column.
const onDay = !slot.allDay && startOfDay(slot.startMs) === dayStart;
const span = onDay ? dayMinutes(slot, dayStart) : { startMin: draft.startMin, endMin: draft.endMin };
const top = layout ? timeToY(layout, span.startMin) : 0;
const height = layout ? Math.max(MIN_BLOCK_H, timeToY(layout, span.endMin) - top) : MIN_BLOCK_H;
useLayoutEffect(() => {
const box = anchor.current;
const panel = card.current;
if (!box || !panel) return;
const measure = () => {
const a = box.getBoundingClientRect();
// The body clips its own overflow, so it is the region the card has to stay inside.
const body = box.closest(".grid-body")?.getBoundingClientRect();
const bounds = {
left: Math.max(MARGIN, (body?.left ?? 0) + MARGIN),
top: Math.max(MARGIN, (body?.top ?? 0) + MARGIN),
right: Math.min(window.innerWidth, body?.right ?? window.innerWidth) - MARGIN,
bottom: Math.min(window.innerHeight, body?.bottom ?? window.innerHeight) - MARGIN,
};
const next = placeCard(
a,
{ width: panel.offsetWidth, height: panel.offsetHeight },
bounds,
GAP,
);
// Held relative to the range box, so the card rides along with it rather than being replaced.
const offset = { left: next.left - a.left, top: next.top - a.top, side: next.side };
setPlace((prev) =>
prev && prev.left === offset.left && prev.top === offset.top && prev.side === offset.side
? prev
: offset,
);
};
measure();
const observer = new ResizeObserver(measure);
observer.observe(panel);
observer.observe(box);
window.addEventListener("resize", measure);
return () => {
observer.disconnect();
window.removeEventListener("resize", measure);
};
}, [top, height, draft.dayIndex]);
return (
<div
className="quick-create"
ref={anchor}
style={vars({ "--i": draft.dayIndex, top: `${top}px`, height: `${height}px` })}
>
<span className="quick-create-range" data-elsewhere={onDay ? undefined : ""} />
<QuickCreateCard
ref={card}
style={{ left: `${place?.left ?? 0}px`, top: `${place?.top ?? 0}px` }}
side={place?.side ?? "right"}
placed={place !== null}
dayStart={dayStart}
slot={slot}
onSlot={setSlot}
dragged={dragged}
onClose={onClose}
/>
</div>
);
}
+162
View File
@@ -0,0 +1,162 @@
// One block on the axis: a pill filled with its calendar's colour. Not the raw hue, which would
// make a week of grid unreadable, but that hue held at a lightness a whole week of them can be
// read at, with the type drawn from the same hue so it stays legible on top. All this file hands
// CSS is the angle; grid.css owns every level built from it.
//
// The block decides how much of itself to show from its own rendered height. A five minute event
// is 12 pixels tall and still has to be a readable, hittable event rather than a stray rule, so
// the smallest rung of the ladder is a bar with a title on it, not an empty sliver.
import { memo, type PointerEvent as ReactPointerEvent } from "react";
import { formatTime } from "../time";
import {
blockSize,
calVars,
eventTitle,
isBusy,
isDeclined,
keyOf,
titleLines,
vars,
type Placed,
} from "./GridModel";
import { useGrid, type DragMode } from "./GridStore";
import { openDetailsFor } from "./useDetails";
/**
* Pointer travel that makes a press a drag rather than a click. Mirrors `DRAG_SLOP` and
* `TOUCH_SLOP` in GridView, which owns the gesture; these are only the backstop for blocks that
* never start one, since a read-only block is pressed and released without a drag ever existing.
*/
const CLICK_SLOP = 3;
const TOUCH_CLICK_SLOP = 12;
interface GridEventProps {
item: Placed;
top: number;
height: number;
left: number;
width: number;
selected: boolean;
dragging: boolean;
onPointerDown: (e: ReactPointerEvent, item: Placed, mode: DragMode) => void;
}
function edgeMode(target: EventTarget | null): DragMode {
const el = target instanceof Element ? target.closest("[data-edge]") : null;
const edge = el?.getAttribute("data-edge");
return edge === "start" ? "resize-start" : edge === "end" ? "resize-end" : "move";
}
export const GridEvent = memo(function GridEvent({
item,
top,
height,
left,
width,
selected,
dragging,
onPointerDown,
}: GridEventProps) {
const { instance } = item;
const size = blockSize(height);
const busy = isBusy(instance);
const declined = isDeclined(instance);
const title = eventTitle(instance);
// A free/busy block's time is where it sits and its place is nobody's business, so the second
// line would only repeat the axis. Everything else earns one once there is room for it.
const meta = size === "full" && !busy;
const open = (element: Element | null) => {
// A draft owns the grid while it is open; opening a card over it would fight for the escape.
if (useGrid.getState().draft) return;
openDetailsFor(keyOf(instance), element);
};
/**
* A click is a press that did not travel. The canvas captures the pointer as soon as a gesture
* begins, which retargets `pointerup` and the click that follows it away from this block, so
* the release is watched in the capture phase: that runs before GridView clears the drag, and
* `moved` is the flag that already knows a drag from a click.
*/
const press = (e: ReactPointerEvent) => {
onPointerDown(e, item, edgeMode(e.target));
if (e.button !== 0) return;
// The mouse events a browser sends after a touch land once the gesture is over, wherever the
// finger happens to be. Suppressing them here keeps a press on a block from blurring an open
// create card. It does not cover the click, which is dealt with where the click is handled.
if (e.pointerType !== "mouse") e.preventDefault();
const element = e.currentTarget;
const downX = e.clientX;
const downY = e.clientY;
// A tap is a click a finger made, and a finger wanders. It also has to survive the long press
// that a drag now waits for: `drag.moved` is what says the press became a gesture instead.
const slop = e.pointerType === "mouse" ? CLICK_SLOP : TOUCH_CLICK_SLOP;
const stop = () => {
window.removeEventListener("pointerup", up, true);
window.removeEventListener("pointercancel", stop, true);
};
const up = (event: PointerEvent) => {
stop();
if (useGrid.getState().drag?.moved) return;
if (Math.abs(event.clientX - downX) > slop) return;
if (Math.abs(event.clientY - downY) > slop) return;
open(element);
};
window.addEventListener("pointerup", up, true);
window.addEventListener("pointercancel", stop, true);
};
return (
<div
className="grid-event"
role="button"
tabIndex={-1}
title={title}
data-size={size}
data-busy={busy || undefined}
data-declined={declined || undefined}
data-status={instance.status}
data-pending={instance.pending || undefined}
data-readonly={instance.readOnly || undefined}
data-selected={selected || undefined}
data-dragging={dragging || undefined}
style={vars({
...calVars(instance),
"--l": left,
"--w": width,
"--lines": busy ? 1 : titleLines(height, meta),
top: `${top}px`,
height: `${height}px`,
})}
onPointerDown={press}
// Never fires while the canvas holds the pointer, which is every real gesture with a mouse.
// It is here for the presses that do not go through a pointer at all, and a click a touch
// left behind is not one of those: capture does not retarget it, so it arrives however the
// gesture ended, and a long press meant to pick the block up would open its card as well.
// The release in `press` is the way in on a touchscreen, and it knows a drag from a tap.
onClick={(e) => {
if ((e.nativeEvent as PointerEvent).pointerType === "touch") return;
open(e.currentTarget);
}}
>
<div className="grid-event-text">
<span className="grid-event-title">{title}</span>
{meta && (
<span className="grid-event-time">
{formatTime(item.startMs)}
{/* The place is the first thing a narrow block gives up, so it is its own span. */}
{instance.location && <span className="grid-event-where"> · {instance.location}</span>}
</span>
)}
</div>
{!instance.readOnly && size !== "bar" && (
<>
<span className="grid-event-grip" data-edge="start" />
<span className="grid-event-grip" data-edge="end" />
</>
)}
</div>
);
});
+142
View File
@@ -0,0 +1,142 @@
// The collapse half of folding, by pointer.
//
// A folded band is already a thing you can see and click. An empty one was not: `z` folded the run
// under the cursor and nothing on screen said so. Hovering any empty run of an hour or more now
// lights the whole run, draws the rule the strip would leave behind, and hangs a labelled chip off
// the left edge of the columns.
//
// A finger has no hover, so it would never see any of that: on a coarse pointer every empty run
// carries its own chip instead, and the wash goes, since a preview that is always on is not a
// preview and seven of them would stripe the grid.
//
// Everything here is absolutely positioned inside the canvas, so the affordance cannot feed back
// into the fit. Pointing at a run never moves an hour.
import { memo, useMemo } from "react";
import { isFolded, timeToY, type Fold, type FitLayout } from "../grid/fit";
import { TOUCH_QUERY, useMediaQuery } from "../useMedia";
import { busyHours, rangeLabel, vars } from "./GridModel";
import { useGrid } from "./GridStore";
import { Icon } from "./Icon";
/**
* Two arrowheads closing on a line: the run folds onto a strip. Mirrored by `EXPAND_ICON`.
*
* The line is what makes them read. Two bare chevrons pointing at each other are a bowtie at this
* size, which is a close button, and two pointing away are a diamond, which is nothing at all.
*/
export const COLLAPSE_ICON = (
<>
<path d="M7 4l5 4 5-4" />
<path d="M3 12h18" />
<path d="M7 20l5-4 5 4" />
</>
);
/** The same pair pointing away from the line: the strip opens back into its hours. */
export const EXPAND_ICON = (
<>
<path d="M7 8l5-4 5 4" />
<path d="M3 12h18" />
<path d="M7 16l5 4 5-4" />
</>
);
/** Big enough that three marks stay three marks. Shared with the strip. */
export const FOLD_ICON_SIZE = 16;
/**
* A run shorter than this has nowhere to put the chip, and folding it would trade its hours for a
* strip the same height. Only reachable at a row height the grid is already struggling at, and `z`
* still works there.
*/
const MIN_GAP_H = 24;
/**
* The empty run around `hour`: nothing scheduled on any visible day, nothing already folded, both
* ends stopped by the bounds. Null when the hour is busy, folded or off the axis.
*
* This is deliberately not `bandAt`, which is what `z` folds and which walks straight through an
* existing fold. What the mouse offers has to be exactly what the label says, so a run cut in two
* by a fold reads as two runs and collapses as the half you pointed at. Folding merges them anyway.
*/
export function gapAt(layout: FitLayout, busy: readonly boolean[], hour: number): Fold | null {
const { start: lo, end: hi } = layout.bounds;
if (hour < lo || hour >= hi) return null;
const free = (h: number) => !busy[h] && !isFolded(layout, h * 60);
if (!free(hour)) return null;
let start = hour;
let end = hour + 1;
while (start > lo && free(start - 1)) start--;
while (end < hi && free(end)) end++;
return { start, end };
}
/** Every empty run on the axis, which is what a pointer with no hover has to be given instead. */
export function allGaps(layout: FitLayout, busy: readonly boolean[]): Fold[] {
const out: Fold[] = [];
let hour = layout.bounds.start;
while (hour < layout.bounds.end) {
const gap = gapAt(layout, busy, hour);
if (!gap) {
hour++;
continue;
}
out.push(gap);
hour = gap.end;
}
return out;
}
export const GridGaps = memo(function GridGaps() {
const layout = useGrid((s) => s.layout);
const items = useGrid((s) => s.items);
const hoverHour = useGrid((s) => s.hoverHour);
// Booleans rather than the objects themselves: this must not repaint on every pointermove.
const gesturing = useGrid((s) => s.drag !== null || s.draft !== null);
const fold = useGrid((s) => s.fold);
const touch = useMediaQuery(TOUCH_QUERY);
const busy = useMemo(() => busyHours(items), [items]);
const gaps = useMemo(() => {
if (!layout) return [];
if (touch) return allGaps(layout, busy);
const hovered = hoverHour === null ? null : gapAt(layout, busy, hoverHour);
return hovered ? [hovered] : [];
}, [layout, busy, hoverHour, touch]);
if (!layout || gesturing) return null;
return (
<>
{gaps.map((gap) => {
const top = timeToY(layout, gap.start * 60);
const height = timeToY(layout, gap.end * 60) - top;
if (height < MIN_GAP_H) return null;
const label = `Collapse ${rangeLabel(gap)}`;
return (
<div
key={gap.start}
className="grid-gap"
style={vars({ top: `${top}px`, height: `${height}px` })}
>
<div className="grid-gap-bar">
<button
type="button"
className="grid-gap-fold"
data-no-drag=""
title={`${label} (z)`}
aria-label={label}
onClick={() => fold(gap)}
>
<Icon size={FOLD_ICON_SIZE}>{COLLAPSE_ICON}</Icon>
<span className="grid-fold-verb">Collapse</span>
</button>
<span className="grid-gap-rule" />
</div>
</div>
);
})}
</>
);
});
+33
View File
@@ -0,0 +1,33 @@
// The block under the pointer during a drag. It is the only thing that repaints while you drag,
// which is why the drag lives in the store rather than in `GridView`'s render.
import { timeToY } from "../grid/fit";
import { formatTime } from "../time";
import { MIN_BLOCK_H, minutesToMs, vars } from "./GridModel";
import { useGrid } from "./GridStore";
export function GridGhost() {
const drag = useGrid((s) => s.drag);
const layout = useGrid((s) => s.layout);
const days = useGrid((s) => s.days);
if (!drag || !drag.moved || !layout) return null;
const dayStart = days[drag.dayIndex];
if (dayStart === undefined) return null;
const top = timeToY(layout, drag.startMin);
const height = Math.max(MIN_BLOCK_H, timeToY(layout, drag.endMin) - top);
return (
<div
className="grid-ghost"
data-mode={drag.mode}
style={vars({ "--i": drag.dayIndex, top: `${top}px`, height: `${height}px` })}
>
<span className="grid-ghost-time">
{formatTime(minutesToMs(dayStart, drag.startMin))} to{" "}
{formatTime(minutesToMs(dayStart, drag.endMin))}
</span>
</div>
);
}
Binary file not shown.
+48
View File
@@ -0,0 +1,48 @@
// The now line, and the wash over the part of today that has already gone. A minute is as fine as
// either needs to be, and the tick lives here so the rest of the grid never rerenders for it.
import { useEffect, useState } from "react";
import { isFolded, timeToY, type FitLayout } from "../grid/fit";
import { isSameDay, minutesFromMidnight } from "../time";
import { vars } from "./GridModel";
const MINUTE = 60_000;
/** Rounds up to the next wall-clock minute, so the line moves when the clock does. */
function useMinuteTick(): number {
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
let timer = 0;
const schedule = () => {
timer = window.setTimeout(() => {
setNow(Date.now());
schedule();
}, MINUTE - (Date.now() % MINUTE));
};
schedule();
return () => window.clearTimeout(timer);
}, []);
return now;
}
interface GridNowLineProps {
layout: FitLayout;
dayStart: number;
dayIndex: number;
}
export function GridNowLine({ layout, dayStart, dayIndex }: GridNowLineProps) {
const now = useMinuteTick();
if (!isSameDay(now, dayStart)) return null;
const minutes = minutesFromMidnight(now);
if (isFolded(layout, minutes)) return null;
const y = timeToY(layout, minutes);
return (
<>
<div className="grid-now-past" style={vars({ "--i": dayIndex, height: `${y}px` })} />
<div className="grid-now" style={vars({ "--i": dayIndex, top: `${y}px` })} />
</>
);
}
+141
View File
@@ -0,0 +1,141 @@
// Grid state that outlives a render pass: the folds, the manually unfolded floor, the live drag
// and the in-place create draft. It also publishes the current layout so the keymap can act on
// the grid without the grid owning a global key listener.
//
// One store, no middleware, actions as inline arrow properties, following `src/store`. It lives
// under `components/` because it is the grid's own state and no other domain reads it.
import { create } from "zustand";
import {
DEFAULT_BOUNDS,
addFold,
foldAt,
normalizeFolds,
type Bounds,
type FitLayout,
type Fold,
} from "../grid/fit";
import { loadFolds, saveFolds } from "../grid/folds";
import { bandAt, type Placed } from "./GridModel";
export type DragMode = "create" | "move" | "resize-start" | "resize-end";
/** The gesture in flight. Only `GridGhost` subscribes, so a pointermove repaints one element. */
export interface Drag {
mode: DragMode;
/** The block being dragged, or null while dragging out a new one. */
id: string | null;
dayIndex: number;
startMin: number;
endMin: number;
/** False until the pointer has travelled far enough to be a drag rather than a click. */
moved: boolean;
}
/** A dragged-out range waiting for its title. */
export interface Draft {
dayIndex: number;
startMin: number;
endMin: number;
}
interface GridState {
folds: Fold[];
/** Bands the user unfolded by hand, held open against the automatic bounds until refolded. */
floor: Bounds | null;
layout: FitLayout | null;
days: number[];
items: Placed[];
hoverHour: number | null;
drag: Drag | null;
draft: Draft | null;
saving: boolean;
publish: (layout: FitLayout, days: number[], items: Placed[]) => void;
setHoverHour: (hour: number | null) => void;
setDrag: (drag: Drag | null) => void;
setDraft: (draft: Draft | null) => void;
setSaving: (saving: boolean) => void;
fold: (range: Fold) => void;
unfold: (range: Fold) => void;
/** `z`: fold the empty band under the cursor, or unfold the strip it is already in. */
toggleFold: (hour?: number) => void;
unfoldAll: () => void;
}
export const useGrid = create<GridState>((set, get) => ({
folds: loadFolds(),
floor: null,
layout: null,
days: [],
items: [],
hoverHour: null,
drag: null,
draft: null,
saving: false,
publish: (layout, days, items) => set({ layout, days, items }),
setHoverHour: (hoverHour) => set((s) => (s.hoverHour === hoverHour ? {} : { hoverHour })),
setDrag: (drag) => set({ drag }),
setDraft: (draft) => set({ draft }),
setSaving: (saving) => set({ saving }),
fold: (range) => {
const folds = saveFolds(addFold(get().folds, range));
set({ folds });
},
unfold: (range) => {
const folds = saveFolds(
normalizeFolds(get().folds).filter((f) => f.end <= range.start || f.start >= range.end),
);
// An out-of-bounds band has no fold to remove, so widening the floor is what holds it open.
const base = get().floor ?? get().layout?.bounds ?? DEFAULT_BOUNDS;
set({
folds,
floor: { start: Math.min(base.start, range.start), end: Math.max(base.end, range.end) },
});
},
toggleFold: (hour) => {
const { layout, hoverHour, items, folds } = get();
if (!layout) return;
const at = hour ?? hoverHour;
if (at === null || at === undefined) return;
const h = Math.max(0, Math.min(23, Math.floor(at)));
const segment = layout.segments.find((s) => h * 60 >= s.start && h * 60 < s.end);
if (segment?.kind === "strip") {
get().unfold({ start: segment.start / 60, end: segment.end / 60 });
return;
}
if (foldAt(folds, h)) {
get().unfold({ start: h, end: h + 1 });
return;
}
get().fold(bandAt(items, layout.bounds, h));
},
unfoldAll: () => {
saveFolds([]);
set({ folds: [], floor: { start: 0, end: 24 } });
},
}));
/**
* The imperative surface the keymap dispatches into. Every one of these is safe to call before
* the grid has laid out, and none of them needs a React tree.
*/
export const gridCommands = {
toggleFold: (hour?: number) => useGrid.getState().toggleFold(hour),
unfoldAll: () => useGrid.getState().unfoldAll(),
cancelDraft: () => {
useGrid.setState({ draft: null, drag: null });
},
/** Opens the in-place create input, in minutes from local midnight on the given column. */
draftAt: (dayIndex: number, startMin: number, endMin: number) =>
useGrid.getState().setDraft({ dayIndex, startMin, endMin }),
/** The same, addressed by local midnight rather than by column. Ignored off screen. */
draftOn: (dayStart: number, startMin: number, minutes: number) => {
const dayIndex = useGrid.getState().days.indexOf(dayStart);
if (dayIndex === -1) return;
useGrid.getState().setDraft({ dayIndex, startMin, endMin: startMin + minutes });
},
/** True while the grid owns the keyboard, so the keymap can stand back. */
isEditing: () => useGrid.getState().draft !== null,
layout: () => useGrid.getState().layout,
days: () => useGrid.getState().days,
};
+40
View File
@@ -0,0 +1,40 @@
// A folded band. The empty hours outside the bounds fold themselves; an interior band only folds
// because you asked for it, with `z` or with the chip that hangs off an empty run. Either way the
// strip says what it covers, how much is hiding in there, and which way it goes: the whole band is
// the button, and the mark next to its range says Expand.
import { memo } from "react";
import type { Segment } from "../grid/fit";
import { EXPAND_ICON, FOLD_ICON_SIZE } from "./GridGaps";
import { rangeLabel, vars } from "./GridModel";
import { Icon } from "./Icon";
interface GridStripProps {
segment: Segment;
hidden: number;
onUnfold: (range: { start: number; end: number }) => void;
}
export const GridStrip = memo(function GridStrip({ segment, hidden, onUnfold }: GridStripProps) {
const range = { start: segment.start / 60, end: segment.end / 60 };
const label = `Expand ${rangeLabel(range)}`;
return (
<button
type="button"
className="grid-strip"
data-no-drag=""
title={`${label} (z)`}
aria-label={label}
style={vars({ top: `${segment.y}px`, height: `${segment.height}px` })}
onClick={() => onUnfold(range)}
>
<span className="grid-strip-range">{rangeLabel(range)}</span>
<span className="grid-strip-mark">
<Icon size={FOLD_ICON_SIZE}>{EXPAND_ICON}</Icon>
<span className="grid-fold-verb">Expand</span>
</span>
<span className="grid-strip-rule" />
{hidden > 0 && <span className="grid-strip-count">{hidden}</span>}
</button>
);
});
+784
View File
@@ -0,0 +1,784 @@
// Week and day. Day is week with one column: same fit, same packing, same everything, so there is
// one component and a day count rather than two views that drift apart.
//
// The grid never scrolls. The body is measured with a ResizeObserver and handed to `computeFit`,
// which solves the row height for whatever is left after the day bar and the all-day band, so
// resizing the window rescales the day instead of growing a scrollbar. The one exception is the
// escape hatch: when the row height hits its floor the fit says `overflow` and the body scrolls.
//
// A finger and a mouse mean different things by the same press. A mouse press on empty grid can
// only be the start of a drag, because a mouse has nothing else to do here; a finger press is
// ambiguous until it either stays put or travels, so the grid waits it out. Everything that
// follows from that is in `begin`, `arm` and `onPointerMove`.
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type PointerEvent as ReactPointerEvent,
} from "react";
import { eventCreate, eventUpdate } from "../api/events";
import {
computeBounds,
computeFit,
timeToY,
yToTime,
type Bounds,
type Fold,
} from "../grid/fit";
import { loadBounds, saveBounds } from "../grid/folds";
import { useCalendarView } from "../store/useCalendarView";
import { notify } from "../store/useToast";
import {
MINUTES_PER_DAY,
addDays,
dayName,
startOfDay,
weekAnchor,
toOffsetIso,
today,
} from "../time";
import { PHONE_QUERY, useMediaQuery } from "../useMedia";
import "../styles/grid.css";
import "../styles/folds.css";
import { GridAllDay } from "./GridAllDay";
import { GridDay } from "./GridDay";
import { GridDraft } from "./GridDraft";
import { GridGaps } from "./GridGaps";
import { GridGhost } from "./GridGhost";
import {
MIN_EVENT_MINUTES,
clampMinutes,
dayMinutes,
hourLabel,
inBand,
isVisible,
keyId,
keyOf,
minutesToMs,
snapMinutes,
vars,
type Placed,
type Times,
} from "./GridModel";
import { GridNowLine } from "./GridNowLine";
import { GridStrip } from "./GridStrip";
import { useGrid, type Drag, type DragMode } from "./GridStore";
/** Half hour rules stop earning their ink below this row height. */
const HALF_RULE_MIN_ROW = 34;
/** Pointer travel before a press becomes a drag rather than a click. */
const DRAG_SLOP = 3;
/**
* The same for a finger, which cannot hold to three pixels: a still thumb wanders further than
* that, and every one of those pixels used to be read as the start of a drag.
*/
const TOUCH_SLOP = 12;
/**
* How long a finger has to stay down before the grid takes the gesture as a drag.
*
* A press is the one thing a touchscreen cannot spend freely. It is how you tap, how you swipe and
* how you drag, and the grid cannot tell which it is watching until the finger either travels or
* stays. Long enough that a swipe is never mistaken for a create, short enough that holding still
* feels answered rather than ignored.
*/
const LONG_PRESS_MS = 350;
/**
* Sideways travel that pages the view instead. Deliberately well past `TOUCH_SLOP`, so the drift
* in a press that meant to stay put cannot turn into navigation.
*/
const SWIPE_MIN = 60;
/**
* What a long press on empty grid drags out on its own, so a press that never travels still leaves
* something behind. Mirrors `NEW_MINUTES` in keys/commands.ts: `c` and a long press are the same
* act with a different limb.
*/
const TOUCH_CREATE_MINUTES = 60;
/**
* Whether this press is a finger, which is a question about the gesture rather than about the
* device. Read off the event rather than off `(pointer: coarse)` on purpose: a tablet with a
* trackpad attached reports a coarse primary pointer all day and still wants the mouse rules for
* whatever is actually in your hand.
*/
const isCoarse = (e: ReactPointerEvent): boolean =>
e.pointerType === "touch" || e.pointerType === "pen";
interface Override extends Times {
/** The write came back; the next batch of instances is allowed to take over. */
settled: boolean;
}
interface Gesture {
pointerId: number;
mode: DragMode;
item: Placed | null;
colsLeft: number;
colsWidth: number;
canvasTop: number;
downX: number;
downY: number;
originDay: number;
originMin: number;
baseStart: number;
baseEnd: number;
/** The pointer has travelled past the slop, so the drag follows it from here on. */
moved: boolean;
coarse: boolean;
/** A finger that is down but has not earned the drag yet: still a tap, a swipe or a long press. */
pending: boolean;
/** The long press, cancelled the moment the press turns into anything else. */
timer: number | null;
}
export interface GridViewProps {
/** Where a dragged-out event lands. Falls back to the calendar most of the span already uses. */
defaultCalendarId?: string;
}
export function GridView({ defaultCalendarId }: GridViewProps) {
const view = useCalendarView((s) => s.view);
const anchor = useCalendarView((s) => s.anchor);
const instances = useCalendarView((s) => s.instances);
const selected = useCalendarView((s) => s.selected);
const select = useCalendarView((s) => s.select);
const moveDay = useCalendarView((s) => s.moveDay);
const setView = useCalendarView((s) => s.setView);
const setAnchor = useCalendarView((s) => s.setAnchor);
// Layout only. Which gesture rules apply is decided per press, off the pointer itself.
const phone = useMediaQuery(PHONE_QUERY);
const folds = useGrid((s) => s.folds);
const floor = useGrid((s) => s.floor);
const dragId = useGrid((s) => s.drag?.id ?? null);
const bodyRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLDivElement | null>(null);
const colsRef = useRef<HTMLDivElement | null>(null);
const gesture = useRef<Gesture | null>(null);
const hoverRef = useRef<number | null>(null);
const previous = useRef<Bounds>(loadBounds());
const saved = useRef("");
const [viewportH, setViewportH] = useState(0);
const [overrides, setOverrides] = useState<Record<string, Override>>({});
// The window is the only input to the row height, so the body measures itself and nothing else
// in the tree gets a say. Measured before paint, so the first frame is already the right shape.
//
// This is a callback ref rather than an effect on mount. Switching to the agenda unmounts this
// body and switching back mounts a different element, so an effect with an empty dependency list
// would keep observing the discarded node and solve the axis for a stale height for the rest of
// the session. The ref fires for every element that actually takes the role.
const observerRef = useRef<ResizeObserver | null>(null);
const setBody = useCallback((el: HTMLDivElement | null) => {
observerRef.current?.disconnect();
observerRef.current = null;
bodyRef.current = el;
if (!el) return;
const observer = new ResizeObserver((entries) => {
const h = entries[entries.length - 1].contentRect.height;
setViewportH((prev) => (Math.abs(prev - h) < 0.5 ? prev : h));
});
observer.observe(el);
observerRef.current = observer;
setViewportH(el.clientHeight);
}, []);
// An optimistic move survives until the instance that replaces it arrives.
useEffect(() => {
setOverrides((prev) => {
const next: Record<string, Override> = {};
let dropped = false;
for (const [id, value] of Object.entries(prev)) {
if (value.settled) dropped = true;
else next[id] = value;
}
return dropped ? next : prev;
});
}, [instances]);
const days = useMemo(() => {
const count = view === "day" ? 1 : 7;
const first = count === 1 ? startOfDay(anchor) : weekAnchor(anchor);
return Array.from({ length: count }, (_, i) => addDays(first, i));
}, [view, anchor]);
const banded = useMemo(
() => instances.filter((i) => isVisible(i) && inBand(i)),
[instances],
);
/** Timed, single day, inside the span, with any optimistic move already applied. */
const byDay = useMemo(() => {
const buckets = new Map<number, Placed[]>();
for (const day of days) buckets.set(day, []);
for (const instance of instances) {
if (!isVisible(instance)) continue;
const id = keyId(keyOf(instance));
const override = overrides[id];
const times: Times = override
? { startMs: override.startMs, endMs: override.endMs }
: { startMs: instance.startMs, endMs: instance.endMs };
if (inBand(instance, times)) continue;
const bucket = buckets.get(startOfDay(times.startMs));
if (bucket) bucket.push({ instance, id, ...times });
}
return days.map((day) => buckets.get(day) as Placed[]);
}, [instances, days, overrides]);
const timed = useMemo(() => byDay.flat(), [byDay]);
// Hysteresis needs the bounds it adopted last pass, so they live in a ref rather than in state:
// paging a week must not reflow the axis, and a render loop through state would fight that.
const layout = useMemo(() => {
const auto = computeBounds(timed, previous.current);
const bounds = floor
? { start: Math.min(auto.start, floor.start), end: Math.max(auto.end, floor.end) }
: auto;
previous.current = bounds;
return computeFit({ bounds, folds, viewportHeight: viewportH });
}, [timed, folds, floor, viewportH]);
useEffect(() => {
const signature = `${layout.bounds.start} ${layout.bounds.end}`;
if (signature === saved.current) return;
saved.current = signature;
saveBounds(layout.bounds);
}, [layout.bounds]);
const publish = useGrid((s) => s.publish);
useEffect(() => {
if (viewportH > 0) publish(layout, days, timed);
}, [publish, layout, days, timed, viewportH]);
const todayMs = today();
const todayIndex = days.findIndex((d) => d === todayMs);
const rules = useMemo(() => {
const out: { key: string; y: number; half: boolean }[] = [];
const half = layout.rowHeight >= HALF_RULE_MIN_ROW;
for (const segment of layout.segments) {
if (segment.kind !== "hours") continue;
for (let m = segment.start; m < segment.end; m += 60) {
out.push({ key: `h${m}`, y: timeToY(layout, m), half: false });
if (half && m + 30 < segment.end) {
out.push({ key: `m${m}`, y: timeToY(layout, m + 30), half: true });
}
}
}
return out;
}, [layout]);
const labels = useMemo(() => {
const out: { hour: number; y: number }[] = [];
for (const segment of layout.segments) {
if (segment.kind !== "hours") continue;
for (let m = segment.start; m < segment.end; m += 60) {
out.push({ hour: m / 60, y: timeToY(layout, m) });
}
}
return out;
}, [layout]);
/** What each strip is hiding: events with nowhere to show, not events it merely clips. */
const hiddenIn = useCallback(
(start: number, end: number) =>
timed.filter((item) => {
const { startMin, endMin } = dayMinutes(item, startOfDay(item.startMs));
return startMin >= start && endMin <= end;
}).length,
[timed],
);
const unfold = useGrid((s) => s.unfold);
const onUnfold = useCallback((range: Fold) => unfold(range), [unfold]);
const columnAt = (clientX: number, left: number, width: number): number => {
const index = Math.floor(((clientX - left) / Math.max(width, 1)) * days.length);
return Math.max(0, Math.min(days.length - 1, index));
};
/**
* A fold is not somewhere you can drop things. `yToTime` reads a pixel inside a strip as the
* first minute it hides, which is right for reading and wrong for dragging: pulling an edge one
* hour up into the leading strip would jump it to midnight. So a drag that reaches a fold stops
* at the near side of it, judged from where the gesture started.
*/
const minuteAt = (clientY: number, canvasTop: number, anchor = 0): number => {
const raw = yToTime(layout, clientY - canvasTop);
const segment = layout.segments.find((s) => raw >= s.start && raw < s.end);
if (segment?.kind === "strip") return segment.start >= anchor ? segment.start : segment.end;
return snapMinutes(raw);
};
const clearPress = (g: Gesture) => {
if (g.timer === null) return;
clearTimeout(g.timer);
g.timer = null;
};
/** Drop the gesture without committing anything: a swipe, a cancel, an unmount. */
const abandon = (pointerId: number) => {
const g = gesture.current;
if (g) clearPress(g);
gesture.current = null;
try {
canvasRef.current?.releasePointerCapture(pointerId);
} catch {
/* already released */
}
useGrid.getState().setDrag(null);
};
/**
* The long press landed, so the press becomes the drag a mouse would have started on the way
* down. It arrives already `moved`, which is what lets a press that never travels leave a draft
* behind: a finger that has held still for a third of a second has said what it wants, and
* asking it to also drag out a duration before anything happens is asking twice.
*
* `g.moved` stays false, so the slop below still applies. Without that the hour this opens with
* would collapse to fifteen minutes on the first tremor.
*/
const arm = (g: Gesture) => {
if (gesture.current !== g) return;
g.pending = false;
g.timer = null;
const end =
g.mode === "create" ? Math.min(MINUTES_PER_DAY, g.baseStart + TOUCH_CREATE_MINUTES) : g.baseEnd;
useGrid.getState().setDrag({
mode: g.mode,
id: g.item?.id ?? null,
dayIndex: g.originDay,
startMin: g.baseStart,
endMin: end,
moved: true,
});
};
const begin = (
e: ReactPointerEvent,
mode: DragMode,
item: Placed | null,
dayIndex: number,
baseStart: number,
baseEnd: number,
) => {
const canvas = canvasRef.current;
const cols = colsRef.current;
if (!canvas || !cols) return;
const colsRect = cols.getBoundingClientRect();
const canvasTop = canvas.getBoundingClientRect().top;
const coarse = isCoarse(e);
const g: Gesture = {
pointerId: e.pointerId,
mode,
item,
colsLeft: colsRect.left,
colsWidth: colsRect.width,
canvasTop,
downX: e.clientX,
downY: e.clientY,
originDay: dayIndex,
originMin: minuteAt(e.clientY, canvasTop),
baseStart,
baseEnd,
moved: false,
coarse,
pending: coarse,
timer: null,
};
gesture.current = g;
// A pointer that has already gone throws here, and a gesture is not worth a broken render.
try {
canvas.setPointerCapture(e.pointerId);
} catch {
/* the gesture still works, it just stops at the window edge */
}
if (coarse) {
g.timer = window.setTimeout(() => arm(g), LONG_PRESS_MS);
return;
}
useGrid.getState().setDrag({
mode,
id: item?.id ?? null,
dayIndex,
startMin: baseStart,
endMin: baseEnd,
moved: false,
});
};
// A long press outliving its own grid would arm a drag into a component that is no longer there.
useEffect(
() => () => {
if (gesture.current) clearPress(gesture.current);
},
[],
);
const onPointerDownBlock = useCallback(
(e: ReactPointerEvent, item: Placed, mode: DragMode) => {
if (e.button !== 0 || gesture.current) return;
e.stopPropagation();
select(keyOf(item.instance));
if (item.instance.readOnly || useGrid.getState().draft) return;
const dayStart = startOfDay(item.startMs);
const index = days.findIndex((d) => d === dayStart);
if (index === -1) return;
const { startMin, endMin } = dayMinutes(item, dayStart);
begin(e, mode, item, index, startMin, endMin);
},
// `begin` closes over the current layout and days, which is what a fresh gesture wants.
[days, layout, select],
);
const onPointerDownCanvas = (e: ReactPointerEvent) => {
if (e.button !== 0 || gesture.current) return;
if (e.target instanceof Element && e.target.closest("[data-no-drag]")) return;
const cols = colsRef.current;
if (!cols) return;
const rect = cols.getBoundingClientRect();
if (e.clientX < rect.left) return;
if (useGrid.getState().draft) {
useGrid.getState().setDraft(null);
return;
}
// Everything past this point is the grid's gesture, so the browser is told not to follow it up
// with the mouse events it sends after a touch. They arrive once the gesture is over and land
// wherever the finger happens to be: on the create card that has just opened under it, whose
// input they blur before a single letter can be typed, or on an event on the day a swipe has
// just paged to. The presses that are still a click, on a block, a strip or a chip, have all
// returned above.
if (isCoarse(e)) e.preventDefault();
const canvasTop = canvasRef.current?.getBoundingClientRect().top ?? 0;
const minutes = minuteAt(e.clientY, canvasTop);
begin(e, "create", null, columnAt(e.clientX, rect.left, rect.width), minutes, minutes);
};
const onPointerMove = (e: ReactPointerEvent) => {
const g = gesture.current;
if (!g) {
const canvasTop = canvasRef.current?.getBoundingClientRect().top ?? 0;
const hour = Math.floor(yToTime(layout, e.clientY - canvasTop) / 60);
if (hour !== hoverRef.current) {
hoverRef.current = hour;
useGrid.getState().setHoverHour(hour);
}
return;
}
if (e.pointerId !== g.pointerId) return;
const dx = e.clientX - g.downX;
const dy = e.clientY - g.downY;
// A finger that is still deciding. Sideways and far enough is the page turn; anything else
// that travels is at least not a long press, so the timer goes and the gesture lives on as a
// swipe candidate until the finger comes up.
if (g.pending) {
if (Math.abs(dx) > SWIPE_MIN && Math.abs(dx) > Math.abs(dy)) {
abandon(g.pointerId);
// One day, whichever view is up. A week that jumped a week per swipe would be a week you
// could never land on the day you meant.
moveDay(dx > 0 ? -1 : 1);
return;
}
if (Math.abs(dx) > TOUCH_SLOP || Math.abs(dy) > TOUCH_SLOP) clearPress(g);
return;
}
if (!g.moved) {
const slop = g.coarse ? TOUCH_SLOP : DRAG_SLOP;
if (Math.abs(dx) < slop && Math.abs(dy) < slop) return;
g.moved = true;
}
const minutes = minuteAt(e.clientY, g.canvasTop, g.originMin);
const column = columnAt(e.clientX, g.colsLeft, g.colsWidth);
let next: Drag;
if (g.mode === "create") {
const start = Math.min(g.originMin, minutes);
const end = Math.max(g.originMin, minutes);
next = {
mode: g.mode,
id: null,
dayIndex: g.originDay,
startMin: start,
endMin: Math.max(end, start + MIN_EVENT_MINUTES),
moved: true,
};
} else if (g.mode === "move") {
const duration = g.baseEnd - g.baseStart;
const start = Math.min(clampMinutes(g.baseStart + minutes - g.originMin), MINUTES_PER_DAY - duration);
next = {
mode: g.mode,
id: g.item?.id ?? null,
dayIndex: column,
startMin: start,
endMin: start + duration,
moved: true,
};
} else if (g.mode === "resize-start") {
next = {
mode: g.mode,
id: g.item?.id ?? null,
dayIndex: g.originDay,
startMin: Math.min(minutes, g.baseEnd - MIN_EVENT_MINUTES),
endMin: g.baseEnd,
moved: true,
};
} else {
next = {
mode: g.mode,
id: g.item?.id ?? null,
dayIndex: g.originDay,
startMin: g.baseStart,
endMin: Math.max(minutes, g.baseStart + MIN_EVENT_MINUTES),
moved: true,
};
}
useGrid.getState().setDrag(next);
};
const onPointerUp = (e: ReactPointerEvent) => {
const g = gesture.current;
if (!g || e.pointerId !== g.pointerId) return;
clearPress(g);
gesture.current = null;
try {
canvasRef.current?.releasePointerCapture(e.pointerId);
} catch {
/* already released */
}
// A press that came up before the long press landed has no drag behind it, so this falls
// through to the tap: deselect on empty grid, and the block's own handler opens its card.
const drag = useGrid.getState().drag;
useGrid.getState().setDrag(null);
if (!drag || !drag.moved) {
if (g.mode === "create") select(null);
return;
}
if (g.mode === "create") {
useGrid.getState().setDraft({
dayIndex: drag.dayIndex,
startMin: drag.startMin,
endMin: drag.endMin,
});
return;
}
if (g.item) void commitMove(g.item, drag);
};
const onPointerCancel = (e: ReactPointerEvent) => {
if (!gesture.current || e.pointerId !== gesture.current.pointerId) return;
abandon(e.pointerId);
};
async function commitMove(item: Placed, drag: Drag) {
const dayStart = days[drag.dayIndex];
if (dayStart === undefined) return;
const startMs = minutesToMs(dayStart, drag.startMin);
const endMs = minutesToMs(dayStart, drag.endMin);
if (startMs === item.startMs && endMs === item.endMs) return;
setOverrides((prev) => ({ ...prev, [item.id]: { startMs, endMs, settled: false } }));
try {
await eventUpdate(
keyOf(item.instance),
{ start: toOffsetIso(startMs), end: toOffsetIso(endMs) },
"this",
);
setOverrides((prev) => ({ ...prev, [item.id]: { startMs, endMs, settled: true } }));
void useCalendarView.getState().load();
} catch (error) {
setOverrides((prev) => {
const next = { ...prev };
delete next[item.id];
return next;
});
notify(String(error));
}
}
const fallbackCalendar = useMemo(() => {
const tally = new Map<string, number>();
for (const i of instances) {
if (i.readOnly) continue;
tally.set(i.calendarId, (tally.get(i.calendarId) ?? 0) + 1);
}
let best: string | undefined;
let most = 0;
for (const [id, n] of tally) {
if (n > most) {
most = n;
best = id;
}
}
return best;
}, [instances]);
async function commitDraft(title: string) {
const draft = useGrid.getState().draft;
if (!draft) return;
const dayStart = days[draft.dayIndex];
const calendarId = defaultCalendarId ?? fallbackCalendar;
if (dayStart === undefined || !calendarId) {
useGrid.getState().setDraft(null);
notify("No calendar to create in yet.");
return;
}
useGrid.getState().setSaving(true);
try {
await eventCreate({
calendarId,
summary: title || "Untitled",
start: toOffsetIso(minutesToMs(dayStart, draft.startMin)),
end: toOffsetIso(minutesToMs(dayStart, draft.endMin)),
allDay: false,
});
useGrid.getState().setDraft(null);
void useCalendarView.getState().load();
} catch (error) {
notify(String(error));
} finally {
useGrid.getState().setSaving(false);
}
}
const cancelDraft = useCallback(() => useGrid.getState().setDraft(null), []);
/**
* The way out of a week you cannot read. Seven columns in a phone's width is an overview and
* nothing more, so the day bar, which is the one part of the grid that stays a comfortable size,
* doubles as the control that opens the column you tapped.
*
* `setView` first: it re-parks the anchor on entry, and the load that lands should be the day's.
*/
const openDay = useCallback(
(day: number) => {
setView("day");
setAnchor(day);
},
[setView, setAnchor],
);
if (view === "agenda") return null;
return (
<div
className="grid"
data-view={view}
data-overflow={layout.overflow || undefined}
style={vars({ "--days": days.length, "--row-h": `${layout.rowHeight}px` })}
>
<div className="grid-head">
<div className="grid-head-gutter" />
<div className="grid-head-days">
{days.map((day, i) => (
<button
type="button"
key={day}
className="grid-head-day"
title={`Open ${dayName(day)} ${new Date(day).getDate()}`}
data-today={day === todayMs || undefined}
data-past={day < todayMs || undefined}
data-index={i}
onClick={() => openDay(day)}
>
{/* A phone week gives a column fifty pixels. "Wed" in fifty pixels next to its date
is two things fighting, and the date is the one you navigate by. */}
<span className="grid-head-name">
{phone && view === "week" ? dayName(day).charAt(0) : dayName(day)}
</span>
<span className="grid-head-date">{new Date(day).getDate()}</span>
</button>
))}
</div>
</div>
<GridAllDay days={days} instances={banded} selected={selected} onSelect={select} />
<div className="grid-body" ref={setBody}>
<div
className="grid-canvas"
ref={canvasRef}
style={{ height: `${layout.totalHeight}px` }}
onPointerDown={onPointerDownCanvas}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerCancel}
onPointerLeave={() => {
if (!gesture.current) {
hoverRef.current = null;
useGrid.getState().setHoverHour(null);
}
}}
>
<div className="grid-gutter">
{labels.map((label) => (
<span key={label.hour} className="grid-gutter-label" style={{ top: `${label.y}px` }}>
{hourLabel(label.hour)}
</span>
))}
</div>
<div className="grid-rules">
{rules.map((rule) => (
<span
key={rule.key}
className="grid-rule"
data-half={rule.half || undefined}
style={{ top: `${rule.y}px` }}
/>
))}
</div>
{layout.segments.map((segment) =>
segment.kind === "strip" ? (
<GridStrip
key={`s${segment.start}`}
segment={segment}
hidden={hiddenIn(segment.start, segment.end)}
onUnfold={onUnfold}
/>
) : null,
)}
<GridGaps />
<div className="grid-cols" ref={colsRef}>
{days.map((day, i) => (
<GridDay
key={day}
layout={layout}
dayStart={day}
index={i}
items={byDay[i]}
selected={selected}
dragId={dragId}
past={day < todayMs}
today={day === todayMs}
onPointerDownBlock={onPointerDownBlock}
/>
))}
{todayIndex !== -1 && (
<GridNowLine layout={layout} dayStart={days[todayIndex]} dayIndex={todayIndex} />
)}
<GridGhost />
<GridDraft onCommit={commitDraft} onCancel={cancelDraft} />
</div>
</div>
</div>
</div>
);
}
export default GridView;
+171
View File
@@ -0,0 +1,171 @@
// The only persistent chrome. It carries the date range, the view switcher and the current time
// range, and on macOS the traffic lights float over it, so it costs no extra height.
import { getCurrentWindow } from "@tauri-apps/api/window";
import type { MouseEvent as ReactMouseEvent } from "react";
import { Icon } from "./Icon";
import { useCalendarView, spanFor, type View } from "../store/useCalendarView";
import { useOverlays } from "../store/useOverlays";
import { useSync } from "../store/useSync";
import { useTheme } from "../store/useTheme";
import { addDays, formatRange } from "../time";
import { keyLabel, keysFor } from "../keys/bindings";
import { isDesktop } from "../ipc";
import type { CommandId } from "../keys/commands";
// Exported where the phone bar shows the same thing: two rows of chrome that disagreed about which
// glyph means "sync" would be two different apps.
export const CHEVRON_LEFT = "M15 18l-6-6 6-6";
export const CHEVRON_RIGHT = "M9 18l6-6-6-6";
const CHEVRONS_LEFT = "M18 18l-6-6 6-6M11 18l-6-6 6-6";
const CHEVRONS_RIGHT = "M6 18l6-6-6-6M13 18l6-6-6-6";
export const GEAR =
"M12 15a3 3 0 100-6 3 3 0 000 6zM19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 11-2.83 2.83l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 11-4 0v-.09A1.65 1.65 0 008 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 11-2.83-2.83l.06-.06A1.65 1.65 0 004.6 15a1.65 1.65 0 00-1.51-1H3a2 2 0 110-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 112.83-2.83l.06.06A1.65 1.65 0 009 4.6a1.65 1.65 0 001-1.51V3a2 2 0 114 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 112.83 2.83l-.06.06A1.65 1.65 0 0019.4 9v0a1.65 1.65 0 001.51 1H21a2 2 0 110 4h-.09a1.65 1.65 0 00-1.51 1z";
export const CALENDAR = "M8 2v4M16 2v4M3 10h18M5 4h14a2 2 0 012 2v14a2 2 0 01-2 2H5a2 2 0 01-2-2V6a2 2 0 012-2z";
export const SEARCH = "M11 19a8 8 0 100-16 8 8 0 000 16zM21 21l-4.35-4.35";
export const REFRESH = "M21 12a9 9 0 11-3-6.7M21 3v6h-6";
export const SUN = "M12 17a5 5 0 100-10 5 5 0 000 10zM12 1v2M12 21v2M4.2 4.2l1.4 1.4M18.4 18.4l1.4 1.4M1 12h2M21 12h2M4.2 19.8l1.4-1.4M18.4 5.6l1.4-1.4";
export const MOON = "M21 12.8A9 9 0 1111.2 3a7 7 0 009.8 9.8z";
const VIEWS: { id: View; label: string; command: CommandId }[] = [
{ id: "day", label: "Day", command: "view-day" },
{ id: "week", label: "Week", command: "view-week" },
{ id: "agenda", label: "Agenda", command: "view-agenda" },
];
/** The shortcut in real glyphs, for a title attribute. */
function hint(command: CommandId): string {
const combo = keysFor(command)[0];
return combo ? ` (${keyLabel(combo)})` : "";
}
export function Header() {
const view = useCalendarView((s) => s.view);
const anchor = useCalendarView((s) => s.anchor);
const setView = useCalendarView((s) => s.setView);
const moveDay = useCalendarView((s) => s.moveDay);
const moveWeek = useCalendarView((s) => s.moveWeek);
const goToday = useCalendarView((s) => s.goToday);
const toggle = useOverlays((s) => s.toggle);
const phase = useSync((s) => s.phase);
const theme = useTheme((s) => s.theme);
const toggleTheme = useTheme((s) => s.toggle);
const pending = useSync((s) => s.pendingWrites);
const { from, to } = spanFor(view, anchor);
const step = moveDay;
// Double click to zoom, the way a native title bar does. Tauri's drag region only handles this
// when the header itself is the event target, and the layout children cover it end to end, so
// it is wired explicitly. A double click on a control is not a title bar gesture.
const onDoubleClick = (e: ReactMouseEvent<HTMLElement>) => {
if (!isDesktop) return;
if ((e.target as HTMLElement).closest("button, input, select, a")) return;
void getCurrentWindow().toggleMaximize();
};
return (
<header className="titlebar" data-tauri-drag-region onDoubleClick={onDoubleClick}>
<div className="lead" data-tauri-drag-region>
<button
className="icon-button"
onClick={() => moveWeek(-1)}
title={`Previous week${hint("week-prev")}`}
>
<Icon d={CHEVRONS_LEFT} />
</button>
<button
className="icon-button"
onClick={() => step(-1)}
title={`Previous day${hint("day-prev")}`}
>
<Icon d={CHEVRON_LEFT} />
</button>
<button
className="icon-button"
onClick={() => step(1)}
title={`Next day${hint("day-next")}`}
>
<Icon d={CHEVRON_RIGHT} />
</button>
<button
className="icon-button"
onClick={() => moveWeek(1)}
title={`Next week${hint("week-next")}`}
>
<Icon d={CHEVRONS_RIGHT} />
</button>
<button className="header-today" onClick={goToday} title={`Today${hint("today")}`}>
Today
</button>
<button
className="header-range"
onClick={() => toggle("mini-month")}
title={`Jump to a date${hint("mini-month")}`}
>
{formatRange(from, addDays(to, -1))}
</button>
</div>
<div className="view-switch" role="group" data-tauri-drag-region>
{VIEWS.map((v) => (
<button
key={v.id}
className="view-option"
data-active={view === v.id}
onClick={() => setView(v.id)}
title={`${v.label}${hint(v.command)}`}
>
{v.label}
</button>
))}
</div>
<div className="trail" data-tauri-drag-region>
<button
className="icon-button"
onClick={toggleTheme}
title={theme === "dark" ? "Light mode" : "Dark mode"}
>
<Icon d={theme === "dark" ? SUN : MOON} />
</button>
<button
className="icon-button"
onClick={() => toggle("calendars")}
title="Calendars"
>
<Icon d={CALENDAR} />
</button>
<button
className="icon-button"
onClick={() => toggle("settings")}
title={`Settings${hint("settings")}`}
>
<Icon d={GEAR} />
</button>
<button
className="icon-button"
onClick={() => toggle("search")}
title={`Search${hint("search")}`}
>
<Icon d={SEARCH} />
</button>
<button
className="icon-button"
data-active={phase === "syncing"}
data-error={phase === "error"}
onClick={() => useSync.getState().run()}
title={
pending > 0
? `${pending} waiting to send${hint("sync-now")}`
: `Sync now${hint("sync-now")}`
}
>
<Icon d={REFRESH} />
</button>
</div>
</header>
);
}
export default Header;
+24
View File
@@ -0,0 +1,24 @@
import type { ReactNode } from "react";
interface IconProps {
d?: string;
size?: number;
children?: ReactNode;
}
export function Icon({ d, size = 16, children }: IconProps) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
>
{children ?? <path d={d} />}
</svg>
);
}
+186
View File
@@ -0,0 +1,186 @@
// A month you can throw the anchor at. It marks today, marks the span the grid is currently
// showing, and dims the days that belong to the neighbouring months.
//
// The bare `MiniMonth` carries no overlay chrome, so it works as a hover popover from the header
// as well as inside the centred panel that `MiniMonthOverlay` puts around it. Arrow keys move a
// cursor while it has focus; a click or Enter is what actually commits.
import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent } from "react";
import { spanFor, useCalendarView } from "../store/useCalendarView";
import { useOverlays } from "../store/useOverlays";
import { addDays, isSameDay, today } from "../time";
import { Icon } from "./Icon";
import {
addMonths,
isSameMonth,
monthGrid,
monthLabel,
startOfMonth,
weekdayHeads,
} from "./overlayModel";
import { Sheet } from "./overlayShell";
const PREV = "M15 18l-6-6 6-6";
const NEXT = "M9 18l6-6-6-6";
export interface MiniMonthProps {
/** The month to open on and where the cursor starts. Defaults to the view's anchor. */
anchor?: number;
/** Defaults to moving the view's anchor. */
onPick?: (day: number) => void;
/** Takes the focus on mount, which is what makes the arrow keys reachable. */
autoFocus?: boolean;
}
export function MiniMonth({ anchor, onPick, autoFocus }: MiniMonthProps) {
const viewAnchor = useCalendarView((s) => s.anchor);
const view = useCalendarView((s) => s.view);
const jumpTo = useCalendarView((s) => s.jumpTo);
const base = anchor ?? viewAnchor;
const [cursor, setCursor] = useState(base);
const [month, setMonth] = useState(() => startOfMonth(base));
const root = useRef<HTMLDivElement | null>(null);
useEffect(() => {
setCursor(base);
setMonth(startOfMonth(base));
}, [base]);
useEffect(() => {
if (autoFocus) root.current?.focus();
}, [autoFocus]);
const days = useMemo(() => monthGrid(month), [month]);
const heads = useMemo(() => weekdayHeads(), []);
const span = useMemo(() => spanFor(view, viewAnchor), [view, viewAnchor]);
const now = today();
const pick = useCallback(
(day: number) => {
if (onPick) onPick(day);
else jumpTo(day);
},
[onPick, jumpTo],
);
const move = (delta: number) => {
const next = addDays(cursor, delta);
setCursor(next);
setMonth(startOfMonth(next));
};
const shiftMonth = (delta: number) => {
const next = addMonths(month, delta);
setMonth(next);
setCursor(next);
};
const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
const step =
e.key === "ArrowLeft" ? -1 : e.key === "ArrowRight" ? 1 : e.key === "ArrowUp" ? -7 : e.key === "ArrowDown" ? 7 : 0;
if (step !== 0) {
e.preventDefault();
e.stopPropagation();
move(step);
return;
}
if (e.key === "PageUp" || e.key === "PageDown") {
e.preventDefault();
e.stopPropagation();
shiftMonth(e.key === "PageUp" ? -1 : 1);
return;
}
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
e.stopPropagation();
pick(cursor);
return;
}
if (e.key === "t") {
e.preventDefault();
e.stopPropagation();
pick(now);
}
};
return (
<div
className="mini"
ref={root}
tabIndex={-1}
data-autofocus={autoFocus ? "" : undefined}
onKeyDown={onKeyDown}
>
<div className="mini-head">
<button
type="button"
className="icon-button"
title="Previous month (⇞)"
onClick={() => shiftMonth(-1)}
>
<Icon d={PREV} />
</button>
<span className="mini-label">{monthLabel(month)}</span>
<button type="button" className="icon-button" title="Next month (⇟)" onClick={() => shiftMonth(1)}>
<Icon d={NEXT} />
</button>
</div>
<div className="mini-week">
{heads.map((head, i) => (
<span key={i} className="mini-weekday">
{head}
</span>
))}
</div>
<div className="mini-grid">
{days.map((day) => (
<button
type="button"
key={day}
className="mini-day"
tabIndex={-1}
data-today={day === now || undefined}
data-outside={!isSameMonth(day, month) || undefined}
data-span={(day >= span.from && day < span.to) || undefined}
data-cursor={isSameDay(day, cursor) || undefined}
onClick={() => pick(day)}
>
{new Date(day).getDate()}
</button>
))}
</div>
<div className="mini-foot">
<button type="button" className="panel-button" data-variant="ghost" onClick={() => pick(now)}>
Today
</button>
</div>
</div>
);
}
/** The centred form, routed from `useOverlays` on `mini-month`. */
export function MiniMonthOverlay() {
const open = useOverlays((s) => s.open);
const close = useOverlays((s) => s.close);
const jumpTo = useCalendarView((s) => s.jumpTo);
const pick = useCallback(
(day: number) => {
jumpTo(day);
close();
},
[jumpTo, close],
);
return (
<Sheet open={open === "mini-month"} title="Jump to" size="mini" onClose={close}>
<MiniMonth autoFocus onPick={pick} />
</Sheet>
);
}
export default MiniMonth;
+189
View File
@@ -0,0 +1,189 @@
// The phone's chrome: a top bar for where you are, a bottom bar for what you are looking at, and a
// sheet for everything else.
//
// The desktop header puts three groups on one row and reaches every panel from an icon in the
// trailing one. At 390px that row is about twice the width there is, so it splits: the date and the
// day arrows stay at the top where a thumb can still get at them, the view switch and the one
// action worth a permanent button move to the bottom where the thumb already is, and the rest goes
// behind the overflow. Nothing new is invented here, every control runs the same command a key or
// a menu item does.
import { Icon } from "./Icon";
import {
CALENDAR,
CHEVRON_LEFT,
CHEVRON_RIGHT,
GEAR,
MOON,
REFRESH,
SEARCH,
SUN,
} from "./Header";
import { Sheet } from "./overlayShell";
import { commandLabel, runCommand, type CommandId } from "../keys/commands";
import { spanFor, useCalendarView, type View } from "../store/useCalendarView";
import { useOverlays } from "../store/useOverlays";
import { useTheme } from "../store/useTheme";
import { addDays, isSameDay, monthName, dayName, today } from "../time";
const MORE = "M4 7h16M4 12h16M4 17h16";
const PLUS = "M12 5v14M5 12h14";
const PERSON = "M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2M12 11a4 4 0 100-8 4 4 0 000 8z";
const KEYBOARD =
"M4 6h16a1 1 0 011 1v10a1 1 0 01-1 1H4a1 1 0 01-1-1V7a1 1 0 011-1zM7 10h.01M11 10h.01M15 10h.01M17 10h.01M7 14h10";
const VIEWS: { id: View; label: string; command: CommandId }[] = [
{ id: "day", label: "Day", command: "view-day" },
{ id: "week", label: "Week", command: "view-week" },
{ id: "agenda", label: "Agenda", command: "view-agenda" },
];
/**
* The range in the shortest form that is still unambiguous. `formatRange` writes "Wednesday 12
* August 2026", which is wider than the whole button it has to live in here, so the weekday and the
* month are cut to three letters and the year is only spelled out when it is not this one.
*/
function shortRange(from: number, to: number): string {
const a = new Date(from);
const b = new Date(to);
const month = (ms: number) => monthName(ms).slice(0, 3);
const year = a.getFullYear() === new Date(today()).getFullYear() ? "" : ` ${a.getFullYear()}`;
if (isSameDay(from, to)) return `${dayName(from).slice(0, 3)} ${a.getDate()} ${month(from)}${year}`;
if (a.getMonth() === b.getMonth() && a.getFullYear() === b.getFullYear())
return `${a.getDate()} to ${b.getDate()} ${month(from)}${year}`;
return `${a.getDate()} ${month(from)} to ${b.getDate()} ${month(to)}${year}`;
}
export function PhoneTopBar() {
const view = useCalendarView((s) => s.view);
const anchor = useCalendarView((s) => s.anchor);
const moveDay = useCalendarView((s) => s.moveDay);
const goToday = useCalendarView((s) => s.goToday);
const toggle = useOverlays((s) => s.toggle);
const { from, to } = spanFor(view, anchor);
// A title attribute is a hover, and there is no hover here, so every control carries its name
// rather than a tooltip nobody can summon.
return (
<header className="phonebar">
<button
type="button"
className="icon-button"
aria-label="Previous day"
onClick={() => moveDay(-1)}
>
<Icon d={CHEVRON_LEFT} size={20} />
</button>
<button type="button" className="phonebar-range" onClick={() => toggle("mini-month")}>
{shortRange(from, addDays(to, -1))}
</button>
<button
type="button"
className="icon-button"
aria-label="Next day"
onClick={() => moveDay(1)}
>
<Icon d={CHEVRON_RIGHT} size={20} />
</button>
<button type="button" className="phonebar-today" onClick={goToday}>
Today
</button>
<button
type="button"
className="icon-button"
aria-label="Menu"
onClick={() => toggle("menu")}
>
<Icon d={MORE} size={20} />
</button>
</header>
);
}
/** The primary navigation, which is why it is the one thing on screen that is never a hover away. */
export function PhoneTabBar() {
const view = useCalendarView((s) => s.view);
return (
<nav className="tabbar" aria-label="Views">
<div className="tabbar-views" role="group">
{VIEWS.map((v) => (
<button
key={v.id}
type="button"
className="tabbar-view"
data-active={view === v.id}
onClick={() => runCommand(v.command)}
>
{v.label}
</button>
))}
</div>
<button
type="button"
className="tabbar-new"
aria-label="New event"
onClick={() => runCommand("new-event")}
>
<Icon d={PLUS} size={20} />
</button>
</nav>
);
}
/** Everything the desktop reaches from the header's trailing icons or from a key it does not have. */
export function PhoneMenu() {
const open = useOverlays((s) => s.open);
const close = useOverlays((s) => s.close);
const theme = useTheme((s) => s.theme);
const items: { command: CommandId; icon: string; label: string }[] = [
{ command: "search", icon: SEARCH, label: commandLabel("search") },
{ command: "calendars", icon: CALENDAR, label: commandLabel("calendars") },
{ command: "settings", icon: GEAR, label: commandLabel("settings") },
{ command: "accounts", icon: PERSON, label: commandLabel("accounts") },
{ command: "sync-now", icon: REFRESH, label: commandLabel("sync-now") },
{
command: "toggle-theme",
icon: theme === "dark" ? SUN : MOON,
// "Toggle dark mode" reads as a state on a row this wide, and half the time it is the wrong
// one. Name the thing the tap will get you instead.
label: theme === "dark" ? "Light mode" : "Dark mode",
},
{ command: "shortcuts", icon: KEYBOARD, label: commandLabel("shortcuts") },
];
const choose = (command: CommandId) => {
runCommand(command);
const overlays = useOverlays.getState();
// Still here, so the row acted in place rather than opening a panel. Only the theme is worth
// staying for: the sheet repaints under your thumb, so changing your mind costs one tap.
if (overlays.open === "menu") {
if (command !== "toggle-theme") close();
return;
}
// A panel replaced the sheet. It was opened by a command, which cannot know it was reached
// from here, so the way back is recorded after the fact.
overlays.reachedFrom("menu");
};
return (
<Sheet open={open === "menu"} title="Menu" onClose={close}>
<div className="menu-list">
{items.map((item) => (
<button
key={item.command}
type="button"
className="menu-item"
onClick={() => choose(item.command)}
>
<Icon d={item.icon} size={18} />
<span className="menu-label">{item.label}</span>
</button>
))}
</div>
</Sheet>
);
}
+361
View File
@@ -0,0 +1,361 @@
// The quick create card: everything a dragged range needs to become an event, and a door out to
// the full editor for everything else.
//
// The title field is the palette's parser, imported rather than copied, so `standup at cafe #work`
// means here exactly what it means behind Cmd-K. When the text says a time it wins over the drag
// and the fields follow it, because the text is the more recent thing the user said. When it says
// nothing about time the drag still holds, which is the common case and must stay silent.
import { useEffect, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent, type Ref } from "react";
import { eventCreate } from "../api/events";
import { PRIMARY_LABEL, primaryHeld } from "../keys/bindings";
import {
defaultCalendar,
formatDuration,
matchCalendar,
parseEventInput,
shortDate,
writable,
} from "../palette/parse";
import { useAccounts } from "../store/useAccounts";
import { useCalendarView } from "../store/useCalendarView";
import { notify } from "../store/useToast";
import { startOfDay, toDateOnly } from "../time";
import { ColorPicker, type ColorChoice } from "./ColorPicker";
import { MIN_EVENT_MINUTES, calSlot, vars } from "./GridModel";
import { useGrid } from "./GridStore";
import { fromDateTime, toTimeOnly } from "./overlayModel";
import {
allDaySlot,
draftOf,
minutesOf,
sentenceFor,
slotFromParse,
timedSlot,
withEnd,
withStart,
type Side,
type Slot,
} from "./QuickCreateModel";
import { useEditor } from "./useEditor";
export interface QuickCreateCardProps {
ref?: Ref<HTMLFormElement>;
/** Where the anchor decided the card goes, relative to the range box it hangs off. */
style?: CSSProperties;
side: Side;
/** False until the card has been measured, so it never flashes at the wrong corner. */
placed: boolean;
/** Local midnight of the column the range was dragged on. */
dayStart: number;
slot: Slot;
onSlot: (slot: Slot) => void;
/** The range as dragged. The fields fall back to it when the text stops describing a time. */
dragged: Slot;
onClose: () => void;
}
export function QuickCreateCard({
ref,
style,
side,
placed,
dayStart,
slot,
onSlot,
dragged,
onClose,
}: QuickCreateCardProps) {
const calendars = useAccounts((s) => s.calendars);
const saving = useGrid((s) => s.saving);
const [text, setText] = useState("");
const [where, setWhere] = useState("");
const [note, setNote] = useState("");
const [color, setColor] = useState<ColorChoice>(null);
const [picked, setPicked] = useState<string | null>(null);
const title = useRef<HTMLInputElement | null>(null);
// What the fields say on their own, before the text is taken into account. Held in a ref because
// it is the base a new parse is applied to, not something a render reads.
const base = useRef<Slot>(dragged);
useEffect(() => {
title.current?.focus();
}, []);
const parsed = useMemo(
() => parseEventInput(text, Date.now(), dragged.startMs),
[text, dragged.startMs],
);
const options = useMemo(() => calendars.filter(writable), [calendars]);
const matched = useMemo(
() => matchCalendar(parsed.calendarHint, calendars),
[parsed.calendarHint, calendars],
);
// Resolved by id and looked up again, because the parser's calendar helpers only promise the
// fields they compare on and the dot needs the colour.
const wanted = matched?.id ?? picked ?? defaultCalendar(calendars)?.id ?? null;
const target = options.find((c) => c.id === wanted) ?? null;
const summary = parsed.title;
const location = where.trim() || parsed.location || null;
const description = note.trim() || null;
const calendarColor = target
? `var(--cal-${calSlot(target.colorHex, target.id)})`
: "var(--line-strong)";
const minutes = minutesOf(slot);
const elsewhere = startOfDay(slot.startMs) !== dayStart;
const ready = target !== null && summary.length > 0 && !saving;
const warning = !target
? "No calendar here can be written to."
: parsed.calendarHint && !matched
? `No calendar matches #${parsed.calendarHint}, so this goes to ${target.summary}.`
: parsed.ambiguous
? `Not sure: ${parsed.ambiguous}.`
: null;
/** The parser took something out of the text, so the card owes the user a sentence about it. */
const understood = parsed.dated || parsed.location !== null || parsed.calendarHint !== null;
// The slot is applied here rather than in an effect on `parsed`: it is derived from the text but
// overridable by the fields, and an effect would win that argument every time a key is pressed.
function retext(next: string) {
setText(next);
const read = parseEventInput(next, Date.now(), dragged.startMs);
onSlot(slotFromParse(read, base.current) ?? base.current);
}
function retime(next: Slot) {
base.current = next;
onSlot(next);
}
function toggleAllDay() {
retime(slot.allDay ? timedSlot(slot, dragged) : allDaySlot(slot));
}
async function create() {
if (!target || !ready) return;
useGrid.getState().setSaving(true);
try {
await eventCreate(
draftOf(slot, {
calendarId: target.id,
summary,
location,
description,
colorId: color,
}),
);
onClose();
void useCalendarView.getState().load();
} catch (e) {
// Nothing was written optimistically, so the revert is simply that the card stays as it was,
// with the text still in it, rather than being torn down over a failure.
notify(`Could not create “${summary}”: ${e}`);
title.current?.focus();
} finally {
useGrid.getState().setSaving(false);
}
}
/**
* Anything the card does not do lives in the editor, seeded with what has been said so far.
*/
function expand() {
onClose();
useEditor.getState().create({
startMs: slot.startMs,
endMs: slot.endMs,
allDay: slot.allDay,
calendarId: target?.id,
summary,
location: location ?? undefined,
description: description || undefined,
colorId: color ?? undefined,
});
}
function onKeyDown(e: KeyboardEvent<HTMLFormElement>) {
e.stopPropagation();
if (e.key !== "Enter") return;
if (primaryHeld(e)) {
e.preventDefault();
expand();
return;
}
// A swatch is a button, so Enter on one would pick the colour it has already picked and the
// card would sit there. Enter means create here, as it does from every field but the
// description, where a new line is the only thing it can sensibly mean.
if ((e.target as HTMLElement).getAttribute("role") === "radio") {
e.preventDefault();
void create();
}
}
return (
<form
ref={ref}
className="quick-create-card"
data-no-drag=""
data-side={side}
data-placed={placed ? "" : undefined}
style={style}
onSubmit={(e) => {
e.preventDefault();
void create();
}}
onPointerDown={(e) => e.stopPropagation()}
onKeyDown={onKeyDown}
>
<input
ref={title}
className="quick-create-title"
value={text}
placeholder="New event"
aria-label="Title"
autoComplete="off"
spellCheck={false}
disabled={saving}
onChange={(e) => retext(e.target.value)}
/>
<div className="quick-create-when">
{slot.allDay ? (
<span className="quick-create-day" data-moved={elsewhere ? "" : undefined}>
{shortDate(slot.startMs)}
</span>
) : (
<>
<input
type="time"
className="quick-create-time"
value={toTimeOnly(slot.startMs)}
step={300}
aria-label="Starts"
disabled={saving}
onChange={(e) =>
retime(
withStart(
slot,
fromDateTime(toDateOnly(slot.startMs), e.target.value),
MIN_EVENT_MINUTES,
),
)
}
/>
<span className="quick-create-to">to</span>
<input
type="time"
className="quick-create-time"
value={toTimeOnly(slot.endMs)}
step={300}
aria-label="Ends"
disabled={saving}
onChange={(e) =>
retime(
withEnd(
slot,
fromDateTime(toDateOnly(slot.startMs), e.target.value),
MIN_EVENT_MINUTES,
),
)
}
/>
<span className="quick-create-length">{formatDuration(minutes)}</span>
{elsewhere && <span className="quick-create-day" data-moved="">{shortDate(slot.startMs)}</span>}
</>
)}
<button
type="button"
className="quick-create-toggle"
data-on={slot.allDay ? "" : undefined}
aria-pressed={slot.allDay}
disabled={saving}
onClick={toggleAllDay}
>
All day
</button>
</div>
<div className="quick-create-where">
<span className="quick-create-dot" style={vars({ "--cal": calendarColor })} />
<select
className="quick-create-calendar"
value={target?.id ?? ""}
aria-label="Calendar"
disabled={saving || options.length === 0}
onChange={(e) => setPicked(e.target.value)}
>
{options.length === 0 && <option value="">Nothing you can write to</option>}
{options.map((c) => (
<option key={c.id} value={c.id}>
{c.summary || "Untitled"}
</option>
))}
</select>
<input
className="quick-create-place"
value={where}
placeholder={parsed.location ?? "Where"}
aria-label="Location"
autoComplete="off"
disabled={saving}
onChange={(e) => setWhere(e.target.value)}
/>
</div>
<div className="quick-create-colour">
<span className="quick-create-label">Colour</span>
<ColorPicker
value={color}
onChange={setColor}
calendarColor={calendarColor}
disabled={saving}
/>
</div>
<textarea
className="quick-create-note"
value={note}
rows={3}
placeholder="Description"
aria-label="Description"
disabled={saving}
onChange={(e) => setNote(e.target.value)}
/>
{text.trim().length === 0 ? (
<p className="quick-create-hint">
Title it, or say it: <code>1pm 45m</code>, <code>at the cafe</code>, <code>#calendar</code>.
</p>
) : (
understood && (
<p className="quick-create-hint">
{sentenceFor(parsed, slot, summary || "Untitled", location, target?.summary ?? null)}
</p>
)
)}
{warning && <p className="quick-create-warning">{warning}</p>}
<div className="quick-create-foot">
<button type="button" className="quick-create-link" disabled={saving} onClick={expand}>
More
<kbd className="quick-create-key">{PRIMARY_LABEL}↩</kbd>
</button>
<button type="button" className="quick-create-button" onClick={onClose}>
Cancel
</button>
<button type="submit" className="quick-create-button" data-variant="primary" disabled={!ready}>
{saving ? "Creating" : "Create"}
<kbd className="quick-create-key">↩</kbd>
</button>
</div>
</form>
);
}
export default QuickCreateCard;
+153
View File
@@ -0,0 +1,153 @@
import { describe, expect, it } from "vitest";
import { parseEventInput } from "../palette/parse";
import {
allDaySlot,
draftOf,
placeCard,
slotFromParse,
timedSlot,
withEnd,
withStart,
type Box,
type Fields,
type Slot,
} from "./QuickCreateModel";
// Monday 10 August 2026, 09:00 local, the same instant `parse.test.ts` pins itself to.
const NOW = new Date(2026, 7, 10, 9, 0, 0).getTime();
const at = (day: number, hour: number, minute = 0) =>
new Date(2026, 7, day, hour, minute, 0, 0).getTime();
/** The 9:00 to 9:30 the complaint is about. */
const DRAGGED: Slot = { startMs: at(10, 9), endMs: at(10, 9, 30), allDay: false };
describe("slotFromParse", () => {
it("says nothing about a bare title, so the drag holds", () => {
expect(slotFromParse(parseEventInput("standup", NOW, DRAGGED.startMs), DRAGGED)).toBeNull();
});
it("takes a dated phrase whole", () => {
const parsed = parseEventInput("lunch with sam tue 1pm 45m", NOW, DRAGGED.startMs);
expect(slotFromParse(parsed, DRAGGED)).toEqual({
startMs: at(11, 13),
endMs: at(11, 13, 45),
allDay: false,
});
});
it("moves only the end when all the text said was a length", () => {
const parsed = parseEventInput("standup 45m", NOW, DRAGGED.startMs);
expect(slotFromParse(parsed, DRAGGED)).toEqual({
startMs: at(10, 9),
endMs: at(10, 9, 45),
allDay: false,
});
});
it("turns an undated all-day flag into the whole of the dragged day", () => {
const parsed = parseEventInput("dentist all day", NOW, DRAGGED.startMs);
expect(slotFromParse(parsed, DRAGGED)).toEqual({
startMs: at(10, 0),
endMs: at(11, 0),
allDay: true,
});
});
});
describe("all day", () => {
it("ends at the midnight after the last day it touches", () => {
expect(allDaySlot(DRAGGED)).toEqual({ startMs: at(10, 0), endMs: at(11, 0), allDay: true });
});
it("comes back at the time of day the drag had", () => {
expect(timedSlot(allDaySlot(DRAGGED), DRAGGED)).toEqual(DRAGGED);
});
});
describe("time fields", () => {
it("drags the end along when the start moves", () => {
expect(withStart(DRAGGED, at(10, 11), 15)).toEqual({
startMs: at(10, 11),
endMs: at(10, 11, 30),
allDay: false,
});
});
it("holds the end open by the minimum rather than letting it precede the start", () => {
expect(withEnd(DRAGGED, at(10, 8), 15).endMs).toBe(at(10, 9, 15));
});
});
describe("draftOf", () => {
const fields = (over: Partial<Fields> = {}): Fields => ({
calendarId: "cal-1",
summary: "standup",
location: null,
description: null,
colorId: null,
...over,
});
it("writes a timed event with an offset", () => {
const draft = draftOf(DRAGGED, fields());
expect(draft.allDay).toBe(false);
expect(draft.start).toMatch(/^2026-08-10T09:00:00[+-]\d\d:\d\d$/);
expect(draft.summary).toBe("standup");
});
it("writes an all-day event as dates that cannot cross a zone", () => {
const draft = draftOf(allDaySlot(DRAGGED), fields({ summary: "", location: "the cafe" }));
expect(draft).toMatchObject({
allDay: true,
start: "2026-08-10",
end: "2026-08-11",
summary: "Untitled",
location: "the cafe",
});
});
it("carries the description as written, and nothing when it is empty", () => {
expect(draftOf(DRAGGED, fields({ description: "bring the laptop" }))).toMatchObject({
description: "bring the laptop",
});
expect(draftOf(DRAGGED, fields()).description).toBeNull();
});
it("sends a chosen colour and leaves the field out when the event follows its calendar", () => {
expect(draftOf(DRAGGED, fields({ colorId: "11" })).colorId).toBe("11");
expect("colorId" in draftOf(DRAGGED, fields())).toBe(false);
});
});
describe("placeCard", () => {
const bounds: Box = { left: 0, top: 0, right: 1000, bottom: 600 };
const card = { width: 300, height: 200 };
const range: Box = { left: 300, top: 100, right: 400, bottom: 160 };
it("sits beside the range and level with its top", () => {
expect(placeCard(range, card, bounds, 8)).toEqual({ left: 408, top: 100, side: "right" });
});
it("flips to the other side rather than leaving the bounds", () => {
const last: Box = { left: 850, top: 100, right: 950, bottom: 160 };
expect(placeCard(last, card, bounds, 8)).toEqual({ left: 542, top: 100, side: "left" });
});
it("drops below when neither side has room", () => {
const narrow: Box = { left: 0, top: 0, right: 420, bottom: 600 };
const middle: Box = { left: 150, top: 100, right: 250, bottom: 160 };
expect(placeCard(middle, card, narrow, 8)).toEqual({ left: 120, top: 168, side: "below" });
});
it("goes above when there is no room below either", () => {
const narrow: Box = { left: 0, top: 0, right: 420, bottom: 400 };
const low: Box = { left: 150, top: 260, right: 250, bottom: 320 };
expect(placeCard(low, card, narrow, 8)).toEqual({ left: 120, top: 52, side: "above" });
});
it("never hangs off the bottom, whatever the range does", () => {
const low: Box = { left: 300, top: 560, right: 400, bottom: 600 };
expect(placeCard(low, card, bounds, 8)).toEqual({ left: 408, top: 400, side: "right" });
});
});
+173
View File
@@ -0,0 +1,173 @@
// The pure half of the quick create card: where the card sits next to the range it is about to
// create, and what that range actually is once the parser has had its say.
//
// Nothing here holds state or touches the DOM, which is what lets the placement maths be pinned in
// a node run the way `overlayModel.ts` pins the editor's conversions.
import type { EventDraft } from "../ipc";
import { previewText, type Parsed } from "../palette/parse";
import { addDays, minutesFromMidnight, startOfDay, toDateOnly, toOffsetIso } from "../time";
import { minutesToMs } from "./GridModel";
/** The range the card will create, in epoch milliseconds. `endMs` is exclusive. */
export interface Slot {
startMs: number;
endMs: number;
allDay: boolean;
}
export const minutesOf = (slot: Slot): number => Math.round((slot.endMs - slot.startMs) / 60_000);
/**
* `DEFAULT_MINUTES` from `parse.ts`, which is not exported. It is only used to tell a duration the
* user typed from the one the parser assumes, and being wrong about that costs the dragged length,
* never the event.
*/
const PARSER_DEFAULT_MINUTES = 60;
/**
* What the typed text says about when, or null when it says nothing and the drag still holds.
*
* A chrono hit is taken whole, because `tue 1pm` is unambiguously a new time. A bare duration with
* no date only moves the end: that is the one other thing the parser knows which the drag does not.
*/
export function slotFromParse(parsed: Parsed, current: Slot): Slot | null {
if (parsed.dated) return { startMs: parsed.startMs, endMs: parsed.endMs, allDay: parsed.allDay };
if (parsed.allDay) return allDaySlot(current);
if (parsed.minutes > 0 && parsed.minutes !== PARSER_DEFAULT_MINUTES) {
return { startMs: current.startMs, endMs: current.startMs + parsed.minutes * 60_000, allDay: false };
}
return null;
}
/** Midnight to the midnight after the last day it touches, which is the exclusive end the wire wants. */
export function allDaySlot(slot: Slot): Slot {
const startMs = startOfDay(slot.startMs);
const last = startOfDay(Math.max(slot.endMs - 1, slot.startMs));
return { startMs, endMs: addDays(last, 1), allDay: true };
}
/** All-day back to a time: the same day, at the time of day and the length `template` has. */
export function timedSlot(slot: Slot, template: Slot): Slot {
const day = startOfDay(slot.startMs);
const startMin = Math.round(minutesFromMidnight(template.startMs));
const minutes = Math.max(1, minutesOf(template));
return {
startMs: minutesToMs(day, startMin),
endMs: minutesToMs(day, startMin + minutes),
allDay: false,
};
}
/** A time field edited: the start drags its end along, the end never lands before its start. */
export function withStart(slot: Slot, startMs: number, minMinutes: number): Slot {
if (!Number.isFinite(startMs)) return slot;
return { ...slot, startMs, endMs: startMs + Math.max(minMinutes, minutesOf(slot)) * 60_000 };
}
export function withEnd(slot: Slot, endMs: number, minMinutes: number): Slot {
if (!Number.isFinite(endMs)) return slot;
return { ...slot, endMs: Math.max(endMs, slot.startMs + minMinutes * 60_000) };
}
/** Everything the card knows about the event that is not when it is. */
export interface Fields {
calendarId: string;
summary: string;
location: string | null;
description: string | null;
/** Google's per-event colour id, or null to follow the calendar. */
colorId: string | null;
}
/** Built with `toOffsetIso` and `toDateOnly` so an all-day boundary never crosses a zone. */
export function draftOf(slot: Slot, fields: Fields): EventDraft {
const bounds = slot.allDay
? { start: toDateOnly(slot.startMs), end: toDateOnly(slot.endMs) }
: { start: toOffsetIso(slot.startMs), end: toOffsetIso(slot.endMs) };
return {
calendarId: fields.calendarId,
summary: fields.summary || "Untitled",
location: fields.location,
description: fields.description,
allDay: slot.allDay,
...bounds,
// Left out entirely rather than sent empty when the event follows its calendar: an absent id
// is what the wire reads as "no override", and an empty one is not a colour.
...(fields.colorId ? { colorId: fields.colorId } : {}),
};
}
/**
* The palette's own sentence, told about the slot the card will really create rather than the one
* the text described. The two differ the moment a time field is touched, and the sentence is only
* worth having if it is the one that is true.
*/
export function sentenceFor(
parsed: Parsed,
slot: Slot,
title: string,
location: string | null,
calendarName: string | null,
): string {
return previewText(
{
...parsed,
title,
location,
startMs: slot.startMs,
endMs: slot.endMs,
allDay: slot.allDay,
minutes: slot.allDay ? 0 : minutesOf(slot),
},
calendarName,
);
}
export interface Box {
left: number;
top: number;
right: number;
bottom: number;
}
export interface Size {
width: number;
height: number;
}
export type Side = "right" | "left" | "below" | "above";
export interface Placement {
left: number;
top: number;
side: Side;
}
const clamp = (n: number, lo: number, hi: number) => (n < lo ? lo : n > hi ? hi : n);
/**
* Beside the range if there is room, under or over it if there is not, and never outside `bounds`.
*
* Covering the range is the one thing it must not do. The range is the only thing the user has said
* so far, and a card sitting on top of it hides the answer to "what did I just drag?".
*/
export function placeCard(anchor: Box, card: Size, bounds: Box, gap: number): Placement {
const beside = clamp(anchor.top, bounds.top, Math.max(bounds.top, bounds.bottom - card.height));
if (anchor.right + gap + card.width <= bounds.right) {
return { left: anchor.right + gap, top: beside, side: "right" };
}
if (anchor.left - gap - card.width >= bounds.left) {
return { left: anchor.left - gap - card.width, top: beside, side: "left" };
}
const left = clamp(anchor.left, bounds.left, Math.max(bounds.left, bounds.right - card.width));
if (anchor.bottom + gap + card.height <= bounds.bottom) {
return { left, top: anchor.bottom + gap, side: "below" };
}
if (anchor.top - gap - card.height >= bounds.top) {
return { left, top: anchor.top - gap - card.height, side: "above" };
}
// Nothing fits. Hang off the top of the range rather than centring on it, so the edge still shows.
const top = clamp(anchor.top + gap, bounds.top, Math.max(bounds.top, bounds.bottom - card.height));
return { left, top, side: "below" };
}
+84
View File
@@ -0,0 +1,84 @@
// A parsed description, as elements rather than as source.
//
// The deciding is all done by the time a tree gets here: `parseDescription` in
// `EventDetailsHtml.ts` has already thrown away every tag outside its vocabulary, every attribute
// but a scheme-checked `href`, and everything `<script>` and `<style>` were carrying. This walk
// only chooses which element each node becomes, and text stays text, which React escapes. Nothing
// on this path can reach `dangerouslySetInnerHTML`, and nothing should ever be added that does.
//
// The event details card has the same walk inline. This is the shared copy, so the editor's
// read-only view and anything else that has to print a description agree on what one looks like.
import { openUrl } from "@tauri-apps/plugin-opener";
import { isTauri } from "../ipc";
import { notify } from "../store/useToast";
import type { DescNode, DescTag } from "./EventDetailsHtml";
import "../styles/rich.css";
/** The parser's vocabulary, and the only elements a description can put on screen. */
const ELEMENT: Record<Exclude<DescTag, "a">, "div" | "ul" | "ol" | "li" | "strong" | "em" | "u" | "s" | "code"> = {
block: "div",
ul: "ul",
ol: "ol",
li: "li",
b: "strong",
i: "em",
u: "u",
s: "s",
code: "code",
};
/** The keys are indices because the tree is built once from an immutable string and never reordered. */
export function RichText({ nodes }: { nodes: readonly DescNode[] }) {
return (
<>
{nodes.map((node, at) => {
if (node.kind === "text") return node.text;
if (node.kind === "break") return <br key={at} />;
if (node.tag === "a") {
// A real anchor, so it is focusable and announces itself as a link, but the navigation is
// ours: letting the webview follow it would replace the app with a web page and leave no
// way back. `openLink` sends it to the browser instead.
return (
<a
key={at}
className="rich-link"
href={node.href}
target="_blank"
rel="noreferrer noopener"
onClick={(e) => {
e.preventDefault();
if (node.href) openLink(node.href);
}}
// Middle click never reaches `onClick`, and a webview asked to open a second window
// is its own kind of stranded. Same destination, same way out.
onAuxClick={(e) => {
e.preventDefault();
if (e.button === 1 && node.href) openLink(node.href);
}}
>
<RichText nodes={node.children} />
</a>
);
}
const Tag = ELEMENT[node.tag];
return (
<Tag key={at}>
<RichText nodes={node.children} />
</Tag>
);
})}
</>
);
}
/** Tauri opens it in the real browser. In a dev browser there is no plugin, so the tab does. */
export function openLink(uri: string): void {
if (!isTauri) {
window.open(uri, "_blank", "noopener,noreferrer");
return;
}
openUrl(uri).catch((e) => notify(`Could not open the link: ${e}`));
}
export default RichText;
+42
View File
@@ -0,0 +1,42 @@
// AgendaSearch renders its own surface but deliberately not the backdrop, so this layer owns the
// dismissal. Everything else in the app goes through overlayShell's Sheet; search does not,
// because its results panel is wider than `.panel` and has no title bar.
//
// The corpus is `useSearch`, not the view's own instances, so searching is not limited to the
// nine days week view happens to have loaded.
import { useEffect } from "react";
import { useEscapeLayer } from "../escape";
import { useOverlays } from "../store/useOverlays";
import { useSearch } from "../store/useSearch";
import { AgendaSearch } from "./AgendaSearch";
import { useEditor } from "./useEditor";
export function SearchOverlay() {
const open = useOverlays((s) => s.open) === "search";
const close = useOverlays((s) => s.close);
const instances = useSearch((s) => s.instances);
const phase = useSearch((s) => s.phase);
useEscapeLayer(open, close);
useEffect(() => {
if (open) void useSearch.getState().ensure();
}, [open]);
if (!open) return null;
return (
<div
className="overlay"
onPointerDown={(e) => {
if (e.target === e.currentTarget) close();
}}
>
<AgendaSearch
instances={phase === "loading" && instances.length === 0 ? undefined : instances}
onOpen={(key) => useEditor.getState().edit(key)}
/>
</div>
);
}
export default SearchOverlay;
+111
View File
@@ -0,0 +1,111 @@
// Global preferences, and only the ones that genuinely are global. Everything else about this app
// is a key away, which is the point of it, so there is nothing here to pad the panel with.
import { useState } from "react";
import { useAccounts } from "../store/useAccounts";
import { useCalendarView } from "../store/useCalendarView";
import { useOverlays } from "../store/useOverlays";
import { useTheme } from "../store/useTheme";
import { setWeekStartDay, weekStartDay } from "../time";
import { Sheet } from "./overlayShell";
export function Settings() {
const open = useOverlays((s) => s.open);
const close = useOverlays((s) => s.close);
const push = useOverlays((s) => s.push);
const theme = useTheme((s) => s.theme);
const toggleTheme = useTheme((s) => s.toggle);
const accounts = useAccounts((s) => s.accounts);
const [start, setStart] = useState<0 | 1>(() => (weekStartDay() === 0 ? 0 : 1));
const chooseTheme = (next: "light" | "dark") => {
if (next !== theme) toggleTheme();
};
const chooseStart = (day: 0 | 1) => {
if (day === start) return;
setWeekStartDay(day);
setStart(day);
// The week span is derived from this on every read, so nudge the view into recomputing it.
useCalendarView.getState().setAnchor(useCalendarView.getState().anchor);
};
return (
<Sheet
open={open === "settings"}
title="Settings"
onClose={close}
foot={
<button type="button" className="panel-button" data-autofocus="" onClick={close}>
Done
</button>
}
>
<div className="setting-row">
<span className="setting-name">Theme</span>
<div className="segment">
<button
type="button"
className="segment-option"
data-on={theme === "light" || undefined}
onClick={() => chooseTheme("light")}
>
Light
</button>
<button
type="button"
className="segment-option"
data-on={theme === "dark" || undefined}
onClick={() => chooseTheme("dark")}
>
Dark
</button>
</div>
</div>
<div className="setting-row">
<span className="setting-text">
<span className="setting-name">Week starts on</span>
<span className="setting-note">
Week view begins on this day rather than on today. The arrows and h and l still slide
it a day at a time when you want a look either side.
</span>
</span>
<div className="segment">
<button
type="button"
className="segment-option"
data-on={start === 0 || undefined}
onClick={() => chooseStart(0)}
>
Sunday
</button>
<button
type="button"
className="segment-option"
data-on={start === 1 || undefined}
onClick={() => chooseStart(1)}
>
Monday
</button>
</div>
</div>
<div className="setting-row">
<span className="setting-text">
<span className="setting-name">Accounts</span>
<span className="setting-note">
{accounts.length === 0
? "No Google account connected yet."
: `${accounts.length} connected.`}
</span>
</span>
<button type="button" className="panel-button" onClick={() => push("accounts")}>
Manage
</button>
</div>
</Sheet>
);
}
export default Settings;
+24
View File
@@ -0,0 +1,24 @@
import { useEffect } from "react";
import { useToast } from "../store/useToast";
const DISMISS_MS = 5000;
export function Toast() {
const message = useToast((s) => s.message);
const dismiss = useToast((s) => s.dismiss);
useEffect(() => {
if (!message) return;
const id = setTimeout(dismiss, DISMISS_MS);
return () => clearTimeout(id);
}, [message, dismiss]);
if (!message) return null;
return (
<div className="toast" role="status" onClick={dismiss}>
{message}
</div>
);
}
export default Toast;
+138
View File
@@ -0,0 +1,138 @@
import { afterEach, describe, expect, it } from "vitest";
import {
addMonths,
allDayEndInput,
allDayEndWire,
attendeeTally,
canWrite,
defaultRange,
fromDateTime,
monthGrid,
monthLabel,
roleLabel,
startOfMonth,
toTimeOnly,
weekdayHeads,
} from "./overlayModel";
import { parseDateOnly, toDateOnly } from "../time";
import type { Attendee } from "../ipc";
/** `time.ts` reads the week start from a bare `localStorage`, which a node run does not have. */
function fakeStorage(seed: Record<string, string> = {}) {
const data = new Map(Object.entries(seed));
(globalThis as { localStorage?: unknown }).localStorage = {
getItem: (k: string) => data.get(k) ?? null,
setItem: (k: string, v: string) => void data.set(k, v),
removeItem: (k: string) => void data.delete(k),
};
}
afterEach(() => {
delete (globalThis as { localStorage?: unknown }).localStorage;
});
const attendee = (responseStatus: Attendee["responseStatus"]): Attendee => ({
email: `${responseStatus}@example.com`,
displayName: null,
responseStatus,
organizer: false,
self: false,
optional: false,
});
describe("month maths", () => {
it("normalises to the first, so a long month never overflows into the one after next", () => {
const halloween = new Date(2026, 9, 31).getTime();
expect(toDateOnly(addMonths(halloween, 1))).toBe("2026-11-01");
expect(toDateOnly(addMonths(new Date(2026, 0, 31).getTime(), 1))).toBe("2026-02-01");
expect(toDateOnly(addMonths(new Date(2026, 0, 15).getTime(), -1))).toBe("2025-12-01");
});
it("lays six weeks out from the configured first day of the week", () => {
fakeStorage({ "margincal-week-start": "1" });
const august = new Date(2026, 7, 12).getTime();
const grid = monthGrid(august);
expect(grid).toHaveLength(42);
expect(toDateOnly(grid[0])).toBe("2026-07-27");
expect(new Date(grid[0]).getDay()).toBe(1);
expect(grid.some((d) => toDateOnly(d) === "2026-08-01")).toBe(true);
expect(grid.some((d) => toDateOnly(d) === "2026-08-31")).toBe(true);
fakeStorage({ "margincal-week-start": "0" });
expect(new Date(monthGrid(august)[0]).getDay()).toBe(0);
});
it("rotates the weekday heads with the week start", () => {
fakeStorage({ "margincal-week-start": "0" });
expect(weekdayHeads()[0]).toBe("Su");
fakeStorage({ "margincal-week-start": "1" });
expect(weekdayHeads()[0]).toBe("Mo");
});
it("names the month and finds its first day at local midnight", () => {
const ms = new Date(2026, 7, 12, 17, 30).getTime();
expect(monthLabel(ms)).toBe("August 2026");
expect(new Date(startOfMonth(ms)).getHours()).toBe(0);
expect(toDateOnly(startOfMonth(ms))).toBe("2026-08-01");
});
});
describe("input conversions", () => {
it("round-trips a date and a time through the native inputs", () => {
const ms = new Date(2026, 7, 12, 9, 5).getTime();
expect(toTimeOnly(ms)).toBe("09:05");
expect(fromDateTime(toDateOnly(ms), toTimeOnly(ms))).toBe(ms);
});
it("gives NaN for a half-filled pair rather than a silent midnight", () => {
expect(Number.isNaN(fromDateTime("", "09:00"))).toBe(true);
expect(fromDateTime("2026-08-12", "")).toBe(new Date(2026, 7, 12).getTime());
});
it("shows the last day an all-day event covers, and sends back the exclusive end", () => {
const start = new Date(2026, 7, 12).getTime();
const oneDay = new Date(2026, 7, 13).getTime();
expect(allDayEndInput(oneDay)).toBe("2026-08-12");
expect(allDayEndWire("2026-08-12")).toBe("2026-08-13");
expect(parseDateOnly(allDayEndInput(oneDay))).toBe(start);
const threeDays = new Date(2026, 7, 15).getTime();
expect(allDayEndInput(threeDays)).toBe("2026-08-14");
expect(allDayEndWire(allDayEndInput(threeDays))).toBe("2026-08-15");
});
});
describe("a new event's default slot", () => {
it("takes the next half hour when the anchor is today", () => {
const now = new Date(2026, 7, 12, 10, 12).getTime();
const { startMs, endMs } = defaultRange(new Date(2026, 7, 12).getTime(), now);
expect(toTimeOnly(startMs)).toBe("10:30");
expect(toTimeOnly(endMs)).toBe("11:30");
});
it("takes nine in the morning on any other day", () => {
const now = new Date(2026, 7, 12, 10, 12).getTime();
const { startMs, endMs } = defaultRange(new Date(2026, 7, 20).getTime(), now);
expect(toDateOnly(startMs)).toBe("2026-08-20");
expect(toTimeOnly(startMs)).toBe("09:00");
expect(toTimeOnly(endMs)).toBe("10:00");
});
});
describe("vocabulary", () => {
it("says nothing about a calendar you own and something about every other kind", () => {
expect(roleLabel("owner")).toBeNull();
expect(roleLabel("reader")).toBe("read only");
expect(canWrite("owner")).toBe(true);
expect(canWrite("writer")).toBe(true);
expect(canWrite("reader")).toBe(false);
expect(canWrite("freeBusyReader")).toBe(false);
});
it("tallies acceptances, and stays quiet when nobody was invited", () => {
expect(attendeeTally([])).toBeNull();
expect(attendeeTally([attendee("accepted"), attendee("declined"), attendee("needsAction")])).toBe(
"1 of 3 accepted",
);
});
});
+137
View File
@@ -0,0 +1,137 @@
// Pure helpers for the overlays: the mini-month's calendar maths, the conversions between an
// `Instance` and the native date and time inputs, and the small vocabulary the panels use for
// access roles and RSVP states.
//
// Nothing here holds state or touches the DOM, which is what makes it testable in a node run.
import type { Attendee, Instance } from "../ipc";
import {
addDays,
formatRange,
formatTime,
isSameDay,
monthName,
parseDateOnly,
startOfDay,
startOfWeek,
toDateOnly,
weekStartDay,
} from "../time";
/** Local midnight on the first of the month `ms` falls in. */
export function startOfMonth(ms: number): number {
const d = new Date(ms);
d.setDate(1);
d.setHours(0, 0, 0, 0);
return d.getTime();
}
/** Months only: the day is normalised to the first, so December never lands on the 31st of nowhere. */
export function addMonths(ms: number, delta: number): number {
const d = new Date(startOfMonth(ms));
d.setMonth(d.getMonth() + delta);
return d.getTime();
}
/** Six weeks of local midnights covering the month, from the configured first day of the week. */
export function monthGrid(ms: number): number[] {
const first = startOfWeek(startOfMonth(ms));
return Array.from({ length: 42 }, (_, i) => addDays(first, i));
}
export const monthLabel = (ms: number): string => `${monthName(ms)} ${new Date(ms).getFullYear()}`;
export const isSameMonth = (a: number, b: number): boolean => startOfMonth(a) === startOfMonth(b);
const SHORT_DAYS = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"];
export function weekdayHeads(): string[] {
const start = weekStartDay();
return Array.from({ length: 7 }, (_, i) => SHORT_DAYS[(start + i) % 7]);
}
/** `HH:MM` in local terms, which is what an `<input type="time">` wants back. */
export function toTimeOnly(ms: number): string {
const d = new Date(ms);
return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
}
/** The instant the two native inputs describe, in local wall-clock terms. NaN if either is blank. */
export function fromDateTime(date: string, time: string): number {
if (!date) return NaN;
const day = parseDateOnly(date);
if (Number.isNaN(day)) return NaN;
const [h, m] = time.split(":").map(Number);
const d = new Date(day);
d.setHours(Number.isFinite(h) ? h : 0, Number.isFinite(m) ? m : 0, 0, 0);
return d.getTime();
}
/**
* All-day ends are exclusive on the wire, the way Google sends them, and inclusive in the panel,
* because nobody thinks of a one-day event as ending tomorrow.
*/
export const allDayEndInput = (endMs: number): string => toDateOnly(addDays(endMs, -1));
export const allDayEndWire = (lastDay: string): string => toDateOnly(addDays(parseDateOnly(lastDay), 1));
/** A sensible slot for a new event: the next half hour when the anchor is today, else 9am. */
export function defaultRange(anchor: number, now = Date.now()): { startMs: number; endMs: number } {
const start = new Date(isSameDay(anchor, now) ? now : startOfDay(anchor));
if (isSameDay(anchor, now)) start.setMinutes(Math.ceil(start.getMinutes() / 30) * 30, 0, 0);
else start.setHours(9, 0, 0, 0);
const end = new Date(start.getTime());
end.setMinutes(end.getMinutes() + 60);
return { startMs: start.getTime(), endMs: end.getTime() };
}
/** The one sentence a read-only event gets for its dates. */
export function whenText(instance: Instance): string {
const { startMs, endMs, allDay } = instance;
if (allDay) return `${formatRange(startMs, addDays(endMs, -1))}, all day`;
if (isSameDay(startMs, endMs)) {
return `${formatRange(startMs, startMs)}, ${formatTime(startMs)} to ${formatTime(endMs)}`;
}
return (
`${formatRange(startMs, startMs)}, ${formatTime(startMs)}` +
` to ${formatRange(endMs, endMs)}, ${formatTime(endMs)}`
);
}
/** Null for `owner`, which is the unremarkable case and does not deserve a badge. */
export function roleLabel(role: string): string | null {
switch (role) {
case "owner":
return null;
case "writer":
return "can edit";
case "reader":
return "read only";
case "freeBusyReader":
return "busy only";
default:
return role;
}
}
export const canWrite = (role: string): boolean => role === "owner" || role === "writer";
export function responseLabel(status: Attendee["responseStatus"]): string {
switch (status) {
case "accepted":
return "accepted";
case "declined":
return "declined";
case "tentative":
return "maybe";
default:
return "no reply";
}
}
/** "3 of 5 accepted", or null when nobody was invited. */
export function attendeeTally(attendees: readonly Attendee[]): string | null {
if (attendees.length === 0) return null;
const yes = attendees.filter((a) => a.responseStatus === "accepted").length;
return `${yes} of ${attendees.length} accepted`;
}
+137
View File
@@ -0,0 +1,137 @@
// The chrome every overlay shares: margin's `.overlay` and `.panel` idiom, an escape layer, a
// backdrop that dismisses on a click, and a close button carrying its own shortcut.
//
// Nothing is resident, so a closed sheet renders nothing at all and its children mount fresh on
// the next open. That is deliberate: it is what keeps the form state in each panel from having to
// be reset by hand.
import { useEffect, useRef, type ReactNode } from "react";
import { useEscapeLayer } from "../escape";
import "../styles/overlays.css";
import { useOverlays } from "../store/useOverlays";
import { Icon } from "./Icon";
const CLOSE = "M18 6 6 18M6 6l12 12";
const BACK = "M19 12H5M12 19l-7-7 7-7";
export interface SheetProps {
open: boolean;
title: string;
onClose: () => void;
/** `mini` is the month popover width, `wide` the editor. */
size?: "mini" | "default" | "wide";
foot?: ReactNode;
children: ReactNode;
}
const TITLES: Record<string, string> = {
palette: "the command palette",
search: "search",
calendars: "calendars",
"mini-month": "the calendar",
editor: "the event",
accounts: "accounts",
settings: "settings",
shortcuts: "shortcuts",
menu: "the menu",
};
export function Sheet({ open, title, onClose, size = "default", foot, children }: SheetProps) {
const trail = useOverlays((s) => s.trail);
const back = useOverlays((s) => s.back);
const canGoBack = trail.length > 0;
const backLabel = TITLES[trail[trail.length - 1] ?? ""] ?? "the last panel";
// Escape retraces the way in rather than throwing away every panel at once. With nowhere to go
// back to it closes, which is what it always did.
useEscapeLayer(open, canGoBack ? back : onClose);
const panel = useRef<HTMLDivElement | null>(null);
// Focus has to leave the grid or the first keystroke goes to the keymap instead of the panel.
useEffect(() => {
if (!open) return;
const el = panel.current;
if (!el) return;
(el.querySelector<HTMLElement>("[data-autofocus]") ?? el).focus();
}, [open]);
if (!open) return null;
return (
<div
className="overlay"
onPointerDown={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<div
className="panel overlay-panel"
data-size={size}
ref={panel}
tabIndex={-1}
role="dialog"
aria-modal="true"
aria-label={title}
>
<div className="panel-head">
{canGoBack && (
<button
type="button"
className="icon-button panel-back"
title={`Back to ${backLabel} (⎋)`}
aria-label={`Back to ${backLabel}`}
onClick={back}
>
<Icon d={BACK} />
</button>
)}
<h2>{title}</h2>
<button type="button" className="icon-button" title="Close (⎋)" onClick={onClose}>
<Icon d={CLOSE} />
</button>
</div>
<div className="panel-body">{children}</div>
{foot ? <div className="panel-foot">{foot}</div> : null}
</div>
</div>
);
}
/** A destructive step in front of the panel it belongs to. The layer above unwinds first. */
export interface ConfirmProps {
title: string;
body: ReactNode;
confirmLabel: string;
busy?: boolean;
onConfirm: () => void;
onCancel: () => void;
}
export function Confirm({ title, body, confirmLabel, busy, onConfirm, onCancel }: ConfirmProps) {
useEscapeLayer(true, onCancel);
const cancel = useRef<HTMLButtonElement | null>(null);
// The safe option takes the focus, so a stray Enter does nothing destructive.
useEffect(() => cancel.current?.focus(), []);
return (
<div className="confirm">
<h3 className="confirm-title">{title}</h3>
<div className="confirm-body">{body}</div>
<div className="confirm-actions">
<button type="button" className="panel-button" ref={cancel} onClick={onCancel}>
Cancel
</button>
<button
type="button"
className="panel-button"
data-variant="danger"
disabled={busy}
onClick={onConfirm}
>
{confirmLabel}
</button>
</div>
</div>
);
}
+19
View File
@@ -0,0 +1,19 @@
// Search query state. It lives next to the components that use it rather than in `src/store/`,
// because nothing outside the agenda has any business reading a half-typed query.
//
// There is no search command in the IPC contract and there will not be one: search filters what
// the view has already loaded.
import { create } from "zustand";
interface AgendaSearchState {
query: string;
setQuery: (query: string) => void;
clear: () => void;
}
export const useAgendaSearch = create<AgendaSearchState>((set) => ({
query: "",
setQuery: (query) => set({ query }),
clear: () => set((s) => (s.query === "" ? {} : { query: "" })),
}));
+40
View File
@@ -0,0 +1,40 @@
// The seam between "something was clicked" and "the details card is on screen".
//
// The grid and the agenda call `open` with the instance key and the on-screen rectangle of the
// thing that was clicked. The details card positions itself against that rectangle. Neither side
// imports the other, which is what lets them be built in parallel.
import { create } from "zustand";
import type { InstanceKey } from "../ipc";
/** Where the clicked block is, in viewport coordinates. A DOMRect satisfies this. */
export interface AnchorRect {
top: number;
left: number;
width: number;
height: number;
}
interface DetailsState {
key: InstanceKey | null;
anchor: AnchorRect | null;
open: (key: InstanceKey, anchor: AnchorRect) => void;
close: () => void;
}
export const useDetails = create<DetailsState>((set) => ({
key: null,
anchor: null,
open: (key, anchor) =>
set({ key, anchor: { top: anchor.top, left: anchor.left, width: anchor.width, height: anchor.height } }),
close: () => set({ key: null, anchor: null }),
}));
/** Imperative form, for a click handler that has an element to measure. */
export function openDetailsFor(key: InstanceKey, element: Element | null): void {
const rect = element?.getBoundingClientRect();
useDetails.getState().open(
key,
rect ?? { top: window.innerHeight / 2, left: window.innerWidth / 2, width: 0, height: 0 },
);
}
+45
View File
@@ -0,0 +1,45 @@
// What the event editor is pointed at. `useOverlays` models which overlay is open and nothing
// else, so the target lives here, next to the panel that reads it rather than in `src/store`.
import { create } from "zustand";
import type { InstanceKey } from "../ipc";
import { useOverlays } from "../store/useOverlays";
/** Everything a new event can be seeded with. Anything absent gets a sensible default. */
export interface EditorSeed {
startMs?: number;
endMs?: number;
allDay?: boolean;
calendarId?: string;
summary?: string;
/** Carried through from quick create, where `at <place>` may already have been parsed out. */
location?: string;
description?: string;
/** Google's per-event colour id, so a colour picked in quick create survives the hand-off. */
colorId?: string;
}
export type EditorTarget = { mode: "create"; seed: EditorSeed } | { mode: "edit"; key: InstanceKey };
interface EditorState {
target: EditorTarget | null;
create: (seed?: EditorSeed) => void;
edit: (key: InstanceKey) => void;
close: () => void;
}
export const useEditor = create<EditorState>((set) => ({
target: null,
create: (seed = {}) => {
set({ target: { mode: "create", seed } });
useOverlays.getState().show("editor");
},
edit: (key) => {
set({ target: { mode: "edit", key } });
useOverlays.getState().show("editor");
},
close: () => {
set({ target: null });
if (useOverlays.getState().open === "editor") useOverlays.getState().close();
},
}));