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

+133
View File
@@ -0,0 +1,133 @@
import { useEffect } from "react";
import { listen } from "@tauri-apps/api/event";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { Header } from "./components/Header";
import { PhoneMenu, PhoneTabBar, PhoneTopBar } from "./components/PhoneBar";
import { GridView } from "./components/GridView";
import { AgendaView } from "./components/AgendaView";
import { MiniMonthOverlay } from "./components/MiniMonth";
import { CalendarList } from "./components/CalendarList";
import { EventEditor } from "./components/EventEditor";
import { EventDetails } from "./components/EventDetails";
import { Settings } from "./components/Settings";
import { Accounts } from "./components/Accounts";
import { CommandPalette } from "./palette/CommandPalette";
import { ShortcutsSheet } from "./keys/Shortcuts";
import { Toast } from "./components/Toast";
import { SearchOverlay } from "./components/SearchOverlay";
import { useKeymap } from "./keys/keymap";
import { handleMenuAction } from "./keys/menu";
import { useAccounts } from "./store/useAccounts";
import { useCalendarView } from "./store/useCalendarView";
import { useSearch } from "./store/useSearch";
import { useSync } from "./store/useSync";
import { notify } from "./store/useToast";
import { useCompact, usePhone, useTouch } from "./useMedia";
import { syncFlush } from "./api/sync";
import { isDesktop, isTauri, live, type AuthEvent, type SyncStatus } from "./ipc";
/** Long enough for sync_flush's own 1s lock wait plus its 4s drain budget. */
const FLUSH_TIMEOUT_MS = 5500;
function App() {
useCompact();
const phone = usePhone();
useTouch();
useKeymap();
useEffect(() => {
if (!live()) return;
void useAccounts.getState().refresh();
void useCalendarView.getState().load();
void useSync.getState().refresh();
}, []);
// `isTauri`, not `isDesktop`. A phone emits every one of these and needs `auth` most of all: the
// mobile consent flow ends with a deep link into Rust, and this listener is the only thing that
// ever learns it worked. Gating on `isDesktop` here left sign-in hanging on the spinner forever.
useEffect(() => {
if (!isTauri) return;
const menu = listen<string>("menu-action", (event) => handleMenuAction(event.payload));
const auth = listen<AuthEvent>("auth", (event) => {
void useAccounts.getState().handleAuthEvent(event.payload.ok, event.payload.error);
});
// A pass that fails in the background used to set the error and say nothing, so a calendar
// that never arrived looked like a calendar you do not have. Surface it once per distinct
// message rather than every sixty seconds.
let lastError: string | null = null;
const progress = listen<SyncStatus>("sync-progress", (event) => {
useSync.getState().apply(event.payload);
const error = event.payload.error;
if (error && error !== lastError) notify(`Sync failed: ${error}`);
if (error !== lastError) lastError = error;
});
// Purely an invalidation signal: re-request the visible range rather than trying to read the
// reason. The account list is only worth refetching when the accounts themselves moved.
const changed = listen<string>("store-changed", (event) => {
void useCalendarView.getState().load();
void useSync.getState().refresh();
useSearch.getState().invalidate();
const reason = event.payload ?? "";
if (reason.includes("account") || reason.includes("calendar")) {
void useAccounts.getState().refresh();
}
});
return () => {
void menu.then((stop) => stop());
void auth.then((stop) => stop());
void progress.then((stop) => stop());
void changed.then((stop) => stop());
};
}, []);
// Drain the outbox before quitting, racing a timeout so a deep queue or a dead network cannot
// hold the window open. Same shape as margin's close-request hook.
//
// `isDesktop` genuinely means desktop here: there is no close to intercept on a phone, and the
// window commands live in a capability that platform does not get, so this would be a rejected
// IPC call rather than a no-op. The outbox still drains on the next launch either way.
useEffect(() => {
if (!isDesktop) return;
const win = getCurrentWindow();
const unlisten = win.onCloseRequested(async (event) => {
if (useSync.getState().pendingWrites === 0) return;
event.preventDefault();
await Promise.race([
syncFlush().catch(() => {}),
new Promise((resolve) => setTimeout(resolve, FLUSH_TIMEOUT_MS)),
]);
void win.destroy();
});
return () => {
void unlisten.then((stop) => stop());
};
}, []);
return (
<div className="app">
{/* One row of chrome or two, never both: the phone's bars replace the header rather than
hiding it, so nothing off screen is still holding a drag region or the keyboard. */}
{phone ? <PhoneTopBar /> : <Header />}
<main className="stage">
<GridView />
<AgendaView />
</main>
{phone ? <PhoneTabBar /> : null}
<PhoneMenu />
<MiniMonthOverlay />
<CalendarList />
<EventDetails />
<EventEditor />
<Settings />
<Accounts />
<SearchOverlay />
<CommandPalette />
<ShortcutsSheet />
<Toast />
</div>
);
}
export default App;
+9
View File
@@ -0,0 +1,9 @@
import { call, type Account } from "../ipc";
export const accountsList = () => call<Account[]>("accounts_list");
/** Returns the consent URL. Completion arrives as the `auth` event. */
export const accountConnect = () => call<string>("account_connect");
export const accountDisconnect = (accountId: string) =>
call<void>("account_disconnect", { accountId });
+6
View File
@@ -0,0 +1,6 @@
import { call, type Calendar } from "../ipc";
export const calendarsList = () => call<Calendar[]>("calendars_list");
export const calendarSetSelected = (calendarId: string, selected: boolean) =>
call<void>("calendar_set_selected", { calendarId, selected });
+9
View File
@@ -0,0 +1,9 @@
import { call, type EventDraft, type EventPatch, type Instance, type InstanceKey, type Scope } from "../ipc";
export const eventCreate = (draft: EventDraft) => call<Instance>("event_create", { draft });
export const eventUpdate = (key: InstanceKey, patch: EventPatch, scope: Scope) =>
call<void>("event_update", { key, patch, scope });
export const eventDelete = (key: InstanceKey, scope: Scope) =>
call<void>("event_delete", { key, scope });
+5
View File
@@ -0,0 +1,5 @@
import { call, type Instance } from "../ipc";
/** Both bounds are epoch milliseconds; `toUtc` is exclusive. */
export const instancesRange = (fromUtc: number, toUtc: number) =>
call<Instance[]>("instances_range", { fromUtc, toUtc });
+8
View File
@@ -0,0 +1,8 @@
import { call, type SyncStatus } from "../ipc";
export const syncNow = () => call<SyncStatus>("sync_now");
export const syncStatus = () => call<SyncStatus>("sync_status");
/** Drains the outbox. The close-request hook races this against a timeout. */
export const syncFlush = () => call<void>("sync_flush");
+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();
},
}));
+222
View File
@@ -0,0 +1,222 @@
// A dev-only dataset that reproduces what a real Google account actually returns, because the
// bugs that shipped were all in cases a hand-made three-event fixture does not contain.
//
// Modelled on a real sync of 12,067 events: 90% of them came from a calendar the user only has
// freeBusyReader access to, so Google returns them with no summary at all. That single fact is
// what made the grid look like a wall of "Untitled".
//
// Anchored to the current week at call time, so `pnpm dev` always has something on screen.
import { EVENT_COLORS, type Account, type Calendar, type Instance } from "../ipc";
const DAY = 86_400_000;
export const devAccounts: Account[] = [
{ id: "acct-1", email: "[email protected]", connected: true },
{ id: "acct-2", email: "[email protected]", connected: true },
];
export const devCalendars: Calendar[] = [
{
id: "cal-personal",
accountId: "acct-1",
summary: "[email protected]",
description: null,
colorHex: "#6f6194",
selected: true,
accessRole: "owner",
timeZone: "Asia/Kolkata",
primary: true,
},
{
id: "cal-work",
accountId: "acct-2",
summary: "[email protected]",
description: "Free/busy only",
colorHex: "#4e6484",
selected: true,
// The one that matters: no summaries come back for this calendar at all.
accessRole: "freeBusyReader",
timeZone: "Asia/Kolkata",
primary: false,
},
{
id: "cal-meetup",
accountId: "acct-1",
summary: "Antithesis x Bengaluru Systems Meetup",
description: null,
colorHex: "#47705f",
selected: true,
accessRole: "reader",
timeZone: "Asia/Kolkata",
primary: false,
},
];
function startOfDayLocal(ms: number): number {
const d = new Date(ms);
d.setHours(0, 0, 0, 0);
return d.getTime();
}
function at(day: number, hour: number, minute = 0): number {
const d = new Date(day);
d.setHours(hour, minute, 0, 0);
return d.getTime();
}
function iso(ms: number): string {
const d = new Date(ms);
const pad = (n: number) => String(n).padStart(2, "0");
const offset = -d.getTimezoneOffset();
const sign = offset >= 0 ? "+" : "-";
const abs = Math.abs(offset);
return (
`${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` +
`T${pad(d.getHours())}:${pad(d.getMinutes())}:00${sign}${pad(Math.floor(abs / 60))}:${pad(abs % 60)}`
);
}
function dateOnly(ms: number): string {
const d = new Date(ms);
const pad = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
}
interface Seed {
cal: string;
summary: string;
day: number;
from: [number, number];
minutes: number;
location?: string;
description?: string;
status?: Instance["status"];
colorId?: string;
attendees?: number;
conference?: boolean;
recurring?: boolean;
pending?: boolean;
}
function timed(seed: Seed, index: number): Instance {
const calendar = devCalendars.find((c) => c.id === seed.cal)!;
const start = at(seed.day, seed.from[0], seed.from[1]);
const end = start + seed.minutes * 60_000;
return {
eventId: `ev-${index}`,
calendarId: calendar.id,
accountId: calendar.accountId,
originalStart: seed.recurring ? iso(start) : null,
start: iso(start),
end: iso(end),
startMs: start,
endMs: end,
allDay: false,
summary: seed.summary,
description: seed.description ?? null,
location: seed.location ?? null,
status: seed.status ?? "confirmed",
recurring: seed.recurring ?? false,
// An event colour overrides its calendar's, which is what Google does.
colorHex: EVENT_COLORS.find((c) => c.id === seed.colorId)?.hex ?? calendar.colorHex,
colorId: seed.colorId ?? null,
etag: `"etag-${index}"`,
organizer: seed.attendees ? "[email protected]" : null,
attendees: Array.from({ length: seed.attendees ?? 0 }, (_, i) => ({
email: `person${i + 1}@example.com`,
displayName: ["Sam Patel", "Riya Shah", "Dev Kumar", "Ana Rao", "Jo Mehta"][i % 5],
// Index 1 is "you". Keep it accepted: making yourself the declined one struck through
// every multi-attendee block and made a normal week look abandoned.
responseStatus:
i === 1 ? "accepted" : (["accepted", "tentative", "needsAction", "declined"] as const)[i % 4],
organizer: i === 0,
self: i === 1,
optional: i === 3,
})),
conference: seed.conference
? { kind: "hangoutsMeet", uri: "https://meet.google.com/abc-defg-hij", label: "meet.google.com/abc-defg-hij" }
: null,
readOnly: calendar.accessRole !== "owner" && calendar.accessRole !== "writer",
pending: seed.pending ?? false,
};
}
function allDay(cal: string, summary: string, day: number, days: number, index: number): Instance {
const calendar = devCalendars.find((c) => c.id === cal)!;
const start = startOfDayLocal(day);
const end = start + days * DAY;
return {
eventId: `ad-${index}`,
calendarId: calendar.id,
accountId: calendar.accountId,
originalStart: null,
start: dateOnly(start),
end: dateOnly(end),
startMs: start,
endMs: end,
allDay: true,
summary,
description: null,
location: null,
status: "confirmed",
recurring: false,
colorHex: calendar.colorHex,
colorId: null,
etag: `"etag-ad-${index}"`,
organizer: null,
attendees: [],
conference: null,
readOnly: calendar.accessRole !== "owner" && calendar.accessRole !== "writer",
pending: false,
};
}
/** Every instance overlapping [from, to). Generated per call so "today" is always populated. */
export function devInstances(from: number, to: number): Instance[] {
const week = startOfDayLocal(Date.now());
const out: Instance[] = [];
let n = 0;
for (let offset = -21; offset <= 28; offset += 1) {
const day = startOfDayLocal(week + offset * DAY);
const weekday = new Date(day).getDay();
const workday = weekday !== 0 && weekday !== 6;
// The daily recurring habit, which is what a real calendar is mostly made of.
out.push(timed({ cal: "cal-personal", summary: "Solve 2 DSA questions", day, from: [9, 0], minutes: 30, recurring: true }, n++));
out.push(timed({ cal: "cal-personal", summary: "Apply to 1 job", day, from: [18, 0], minutes: 30, recurring: true }, n++));
if (!workday) continue;
// Free/busy blocks: no summary at all, which is what freeBusyReader access returns.
out.push(timed({ cal: "cal-work", summary: "", day, from: [10, 0], minutes: 60 }, n++));
out.push(timed({ cal: "cal-work", summary: "", day, from: [14, 30], minutes: 90 }, n++));
out.push(timed({ cal: "cal-work", summary: "", day, from: [16, 0], minutes: 15 }, n++));
// Short events, the ones that collapse into an unreadable sliver.
out.push(timed({ cal: "cal-personal", summary: "Stand-up", day, from: [9, 45], minutes: 10, attendees: 5, conference: true }, n++));
out.push(timed({ cal: "cal-personal", summary: "Meds", day, from: [8, 15], minutes: 5 }, n++));
if (offset % 3 === 0) {
// An overlapping cluster three deep, plus a briefly-colliding long one.
out.push(timed({ cal: "cal-personal", summary: "Design review with the platform team", day, from: [11, 0], minutes: 60, attendees: 4, conference: true, location: "Meeting room 3", colorId: "11" }, n++));
out.push(timed({ cal: "cal-personal", summary: "1:1 with Sam", day, from: [11, 15], minutes: 30, attendees: 2, colorId: "5" }, n++));
out.push(timed({ cal: "cal-meetup", summary: "Antithesis x Bengaluru Systems Meetup: deterministic simulation testing in practice", day, from: [11, 30], minutes: 150, location: "Church Street, Bengaluru" }, n++));
}
if (offset % 5 === 0) {
out.push(timed({ cal: "cal-personal", summary: "Tentative: coffee", day, from: [15, 0], minutes: 45, status: "tentative" }, n++));
}
if (offset === 1) {
out.push(timed({ cal: "cal-personal", summary: "Just created, not pushed yet", day, from: [13, 0], minutes: 30, pending: true }, n++));
}
}
out.push(allDay("cal-personal", "Trip to Goa", week + DAY, 4, 0));
out.push(allDay("cal-personal", "Credit Card Bill ICICI", week + 2 * DAY, 1, 1));
out.push(allDay("cal-meetup", "Conference", week + 2 * DAY, 1, 2));
out.push(allDay("cal-personal", "Dentist", week + 2 * DAY, 1, 3));
const selected = new Set(devCalendars.filter((c) => c.selected).map((c) => c.id));
return out.filter((i) => selected.has(i.calendarId) && i.endMs > from && i.startMs < to);
}
+120
View File
@@ -0,0 +1,120 @@
// Serves the IPC surface from the dev fixture when the app is opened in a browser rather than in
// Tauri. This exists so the real UI can be driven and looked at, by a person or by Playwright,
// without a Google account and without a build of the Rust side.
//
// It is reachable only when `import.meta.env.DEV` is true and `isDesktop` is false, so it is
// absent from a production bundle and can never shadow the real backend inside the app.
import { EVENT_COLORS, type Calendar, type EventDraft, type Instance, type InstanceKey, type SyncStatus } from "../ipc";
import { devAccounts, devCalendars, devInstances } from "./fixture";
const calendars: Calendar[] = devCalendars.map((c) => ({ ...c }));
const created: Instance[] = [];
const patched = new Map<string, Partial<Instance>>();
const deleted = new Set<string>();
const keyOf = (key: InstanceKey) => `${key.eventId}|${key.originalStart ?? ""}`;
function apply(list: Instance[]): Instance[] {
return list
.filter((i) => !deleted.has(keyOf({ eventId: i.eventId, originalStart: i.originalStart })))
.map((i) => {
const patch = patched.get(keyOf({ eventId: i.eventId, originalStart: i.originalStart }));
return patch ? { ...i, ...patch } : i;
});
}
export async function mockCall<T>(command: string, args?: Record<string, unknown>): Promise<T> {
const a = (args ?? {}) as Record<string, never>;
switch (command) {
case "accounts_list":
return devAccounts as unknown as T;
case "calendars_list":
return calendars as unknown as T;
case "calendar_set_selected": {
const id = a.calendarId as unknown as string;
const selected = a.selected as unknown as boolean;
const found = calendars.find((c) => c.id === id);
if (found) found.selected = selected;
return undefined as T;
}
case "instances_range": {
const from = a.fromUtc as unknown as number;
const to = a.toUtc as unknown as number;
const selected = new Set(calendars.filter((c) => c.selected).map((c) => c.id));
const live = [...devInstances(from, to), ...created].filter(
(i) => selected.has(i.calendarId) && i.endMs > from && i.startMs < to,
);
return apply(live) as unknown as T;
}
case "event_create": {
const draft = a.draft as unknown as EventDraft;
const calendar = calendars.find((c) => c.id === draft.calendarId) ?? calendars[0];
const startMs = draft.allDay ? Date.parse(`${draft.start}T00:00:00`) : Date.parse(draft.start);
const endMs = draft.allDay ? Date.parse(`${draft.end}T00:00:00`) : Date.parse(draft.end);
const instance: Instance = {
eventId: `new-${created.length + 1}`,
calendarId: calendar.id,
accountId: calendar.accountId,
originalStart: null,
start: draft.start,
end: draft.end,
startMs,
endMs,
allDay: draft.allDay,
summary: draft.summary,
description: draft.description ?? null,
location: draft.location ?? null,
status: "confirmed",
recurring: (draft.recurrence?.length ?? 0) > 0,
colorHex: EVENT_COLORS.find((c) => c.id === draft.colorId)?.hex ?? calendar.colorHex,
colorId: draft.colorId ?? null,
etag: `"new-${created.length + 1}"`,
organizer: null,
attendees: [],
conference: null,
readOnly: false,
pending: false,
};
created.push(instance);
return instance as unknown as T;
}
case "event_update": {
const key = a.key as unknown as InstanceKey;
const patch = (a.patch ?? {}) as Record<string, unknown>;
// The grid positions from startMs/endMs, so a patch that moves only the strings snaps the
// block straight back. Derive the milliseconds whenever the wire times change.
const merged: Partial<Instance> = { ...(patch as Partial<Instance>) };
const allDay = (patch.allDay as boolean | undefined) ?? undefined;
if (typeof patch.start === "string") {
merged.startMs = allDay ? Date.parse(`${patch.start}T00:00:00`) : Date.parse(patch.start);
}
if (typeof patch.end === "string") {
merged.endMs = allDay ? Date.parse(`${patch.end}T00:00:00`) : Date.parse(patch.end);
}
const previous = patched.get(keyOf(key)) ?? {};
patched.set(keyOf(key), { ...previous, ...merged });
return undefined as T;
}
case "event_delete": {
deleted.add(keyOf(a.key as unknown as InstanceKey));
return undefined as T;
}
case "sync_now":
case "sync_status":
return {
phase: "idle",
lastSync: Date.now(),
error: null,
pendingWrites: 0,
message: null,
} satisfies SyncStatus as unknown as T;
case "sync_flush":
case "account_disconnect":
return undefined as T;
case "account_connect":
return "https://accounts.google.com/o/oauth2/auth?dev=1" as unknown as T;
default:
throw new Error(`dev mock has no handler for ${command}`);
}
}
+36
View File
@@ -0,0 +1,36 @@
import { useEffect, useRef } from "react";
type Handler = () => void;
const layers: Handler[] = [];
let bound = false;
function onKeyDown(e: KeyboardEvent) {
if (e.key !== "Escape" || e.isComposing || e.defaultPrevented) return;
const top = layers[layers.length - 1];
if (!top) return;
e.preventDefault();
e.stopPropagation();
top();
}
function pushLayer(handler: Handler): () => void {
if (!bound) {
window.addEventListener("keydown", onKeyDown, true);
bound = true;
}
layers.push(handler);
return () => {
const i = layers.lastIndexOf(handler);
if (i !== -1) layers.splice(i, 1);
};
}
export function useEscapeLayer(active: boolean, onEscape: () => void): void {
const latest = useRef(onEscape);
latest.current = onEscape;
useEffect(() => {
if (!active) return;
return pushLayer(() => latest.current());
}, [active]);
}
+365
View File
@@ -0,0 +1,365 @@
import { describe, expect, it } from "vitest";
import {
DEFAULT_BOUNDS,
ROW_H_MIN,
STRIP_H,
addFold,
adoptBounds,
computeBounds,
computeFit,
eventSpan,
foldAt,
isFolded,
normalizeFolds,
rawBounds,
removeFoldAt,
timeToY,
widen,
yToTime,
type FitEvent,
} from "./fit";
/** Local wall clock on a fixed weekday, which is the only clock the fit engine reads. */
const at = (hour: number, minute = 0, day = 5) =>
new Date(2026, 0, day, hour, minute, 0, 0).getTime();
const ev = (
from: [number, number],
to: [number, number],
extra: Partial<FitEvent> = {},
): FitEvent => ({
startMs: at(from[0], from[1]),
endMs: at(to[0], to[1]),
...extra,
});
describe("eventSpan", () => {
it("ignores all-day events, which live in their own band", () => {
expect(eventSpan([ev([9, 0], [10, 0], { allDay: true })])).toBeNull();
expect(eventSpan([])).toBeNull();
});
it("takes the earliest start and the latest end", () => {
const span = eventSpan([ev([13, 30], [14, 0]), ev([9, 15], [10, 0]), ev([11, 0], [16, 45])]);
expect(span).toEqual({ lo: 9 * 60 + 15, hi: 16 * 60 + 45 });
});
it("reads an event ending at midnight as ending at 24:00 the day before", () => {
expect(eventSpan([{ startMs: at(22, 0), endMs: at(0, 0, 6) }])).toEqual({
lo: 22 * 60,
hi: 1440,
});
});
it("opens the whole day for an event that crosses midnight", () => {
expect(eventSpan([{ startMs: at(22, 0), endMs: at(2, 0, 6) }])).toEqual({ lo: 0, hi: 1440 });
});
it("keeps a zero-length event as a point", () => {
expect(eventSpan([ev([9, 30], [9, 30])])).toEqual({ lo: 570, hi: 570 });
});
});
describe("rawBounds", () => {
it("floors an hour below the earliest start and ceils one above the latest end", () => {
expect(rawBounds([ev([10, 15], [17, 5])])).toEqual({ start: 9, end: 19 });
});
it("widens a short day symmetrically to the eight hour minimum", () => {
// 10:00 to 14:00 pads to 9..15, six hours, so one hour goes on each end.
expect(rawBounds([ev([10, 0], [14, 0])])).toEqual({ start: 8, end: 16 });
expect(widen(11, 15)).toEqual({ start: 9, end: 17 });
expect(widen(12, 12)).toEqual({ start: 8, end: 16 });
});
it("gives an odd hour to the later end", () => {
expect(widen(11, 14)).toEqual({ start: 9, end: 17 });
});
it("clamps at midnight and pushes the widening into the other end", () => {
expect(rawBounds([ev([0, 10], [0, 30])])).toEqual({ start: 0, end: 8 });
expect(rawBounds([ev([23, 0], [23, 59])])).toEqual({ start: 16, end: 24 });
});
it("never runs past either end of the day", () => {
const full = rawBounds([{ startMs: at(22, 0), endMs: at(2, 0, 6) }]);
expect(full).toEqual({ start: 0, end: 24 });
expect(widen(-5, 30)).toEqual({ start: 0, end: 24 });
});
it("is null with nothing visible", () => {
expect(rawBounds([])).toBeNull();
});
});
describe("adoptBounds", () => {
const previous = { start: 8, end: 20 };
it("adopts an expansion immediately", () => {
expect(adoptBounds(previous, { start: 7, end: 20 })).toEqual({ start: 7, end: 20 });
expect(adoptBounds(previous, { start: 8, end: 22 })).toEqual({ start: 8, end: 22 });
expect(adoptBounds(previous, { start: 6, end: 23 })).toEqual({ start: 6, end: 23 });
});
it("rejects a one hour contraction", () => {
expect(adoptBounds(previous, { start: 9, end: 20 })).toEqual(previous);
expect(adoptBounds(previous, { start: 8, end: 19 })).toEqual(previous);
});
it("adopts a two hour contraction", () => {
expect(adoptBounds(previous, { start: 10, end: 20 })).toEqual({ start: 10, end: 20 });
expect(adoptBounds(previous, { start: 9, end: 19 })).toEqual({ start: 9, end: 19 });
});
it("keeps the growth and holds the trim when a pass does both", () => {
// Grows an hour at the top, gives up one at the bottom: not worth a reflow.
expect(adoptBounds(previous, { start: 7, end: 19 })).toEqual({ start: 7, end: 20 });
// Same growth, but now the bottom gives up three hours, so take the new range whole.
expect(adoptBounds(previous, { start: 7, end: 17 })).toEqual({ start: 7, end: 17 });
});
});
describe("computeBounds", () => {
it("falls back to the previous bounds on an empty span", () => {
const previous = { start: 6, end: 21 };
expect(computeBounds([], previous)).toEqual(previous);
expect(computeBounds([ev([9, 0], [10, 0], { allDay: true })], previous)).toEqual(previous);
});
it("does not collapse to the default when a quiet week goes by", () => {
let bounds = computeBounds([ev([9, 0], [17, 0])], DEFAULT_BOUNDS);
expect(bounds).toEqual({ start: 8, end: 18 });
for (let i = 0; i < 5; i++) bounds = computeBounds([], bounds);
expect(bounds).toEqual({ start: 8, end: 18 });
});
it("holds still across a one hour wobble and moves on a real contraction", () => {
const start = computeBounds([ev([9, 0], [17, 0])], DEFAULT_BOUNDS);
expect(start).toEqual({ start: 8, end: 18 });
const wobble = computeBounds([ev([10, 0], [17, 0])], start);
expect(wobble).toEqual(start);
const real = computeBounds([ev([12, 0], [14, 0])], start);
expect(real).toEqual({ start: 9, end: 17 });
});
});
describe("folds", () => {
it("merges overlapping and touching ranges", () => {
expect(
normalizeFolds([
{ start: 14, end: 16 },
{ start: 9, end: 11 },
{ start: 10, end: 12 },
{ start: 12, end: 13 },
]),
).toEqual([
{ start: 9, end: 13 },
{ start: 14, end: 16 },
]);
});
it("drops empty and unusable ranges", () => {
expect(
normalizeFolds([
{ start: 10, end: 10 },
{ start: 12, end: 11 },
{ start: NaN, end: 4 },
]),
).toEqual([]);
});
it("adds and removes by the hour under the cursor", () => {
const folds = addFold([{ start: 9, end: 11 }], { start: 14, end: 16 });
expect(folds).toHaveLength(2);
expect(foldAt(folds, 15)).toEqual({ start: 14, end: 16 });
expect(foldAt(folds, 16)).toBeNull();
expect(removeFoldAt(folds, 15)).toEqual([{ start: 9, end: 11 }]);
expect(removeFoldAt(folds, 13)).toEqual(folds);
});
});
describe("computeFit", () => {
it("folds the bands outside the bounds into one strip at each end", () => {
const layout = computeFit({ bounds: { start: 8, end: 20 }, viewportHeight: 600 });
const strips = layout.segments.filter((s) => s.kind === "strip");
expect(strips.map((s) => [s.start, s.end])).toEqual([
[0, 480],
[1200, 1440],
]);
expect(layout.unfoldedHours).toBe(12);
expect(layout.rowHeight).toBeCloseTo((600 - 2 * STRIP_H) / 12, 10);
expect(layout.totalHeight).toBeCloseTo(600, 10);
expect(layout.overflow).toBe(false);
});
it("drops the end strips when the bounds are the whole day", () => {
const layout = computeFit({ bounds: { start: 0, end: 24 }, viewportHeight: 960 });
expect(layout.segments).toHaveLength(1);
expect(layout.rowHeight).toBe(40);
expect(layout.totalHeight).toBe(960);
});
it("takes an interior fold out of the unfolded total and adds a strip", () => {
const plain = computeFit({ bounds: { start: 8, end: 20 }, viewportHeight: 600 });
const folded = computeFit({
bounds: { start: 8, end: 20 },
folds: [{ start: 12, end: 14 }],
viewportHeight: 600,
});
expect(folded.unfoldedHours).toBe(plain.unfoldedHours - 2);
expect(folded.segments.filter((s) => s.kind === "strip")).toHaveLength(3);
expect(folded.rowHeight).toBeCloseTo((600 - 3 * STRIP_H) / 10, 10);
expect(folded.rowHeight).toBeGreaterThan(plain.rowHeight);
expect(folded.totalHeight).toBeCloseTo(600, 10);
expect(folded.segments.map((s) => [s.kind, s.start, s.end])).toEqual([
["strip", 0, 480],
["hours", 480, 720],
["strip", 720, 840],
["hours", 840, 1200],
["strip", 1200, 1440],
]);
});
it("clips a fold to the bounds and merges it into the end strip when it touches", () => {
const layout = computeFit({
bounds: { start: 8, end: 20 },
folds: [{ start: 6, end: 10 }],
viewportHeight: 600,
});
expect(layout.segments.map((s) => [s.kind, s.start, s.end])).toEqual([
["strip", 0, 600],
["hours", 600, 1200],
["strip", 1200, 1440],
]);
expect(layout.unfoldedHours).toBe(10);
});
it("ignores a fold that sits entirely outside the bounds", () => {
const layout = computeFit({
bounds: { start: 8, end: 20 },
folds: [{ start: 2, end: 4 }],
viewportHeight: 600,
});
expect(layout.segments.filter((s) => s.kind === "strip")).toHaveLength(2);
expect(layout.unfoldedHours).toBe(12);
});
it("floors the row height and reports overflow on a short window", () => {
const layout = computeFit({ bounds: { start: 0, end: 24 }, viewportHeight: 300 });
expect(layout.rowHeight).toBe(ROW_H_MIN);
expect(layout.totalHeight).toBe(24 * ROW_H_MIN);
expect(layout.overflow).toBe(true);
});
it("survives a viewport shorter than the strips themselves", () => {
const layout = computeFit({ bounds: { start: 8, end: 20 }, viewportHeight: 10 });
expect(layout.rowHeight).toBe(ROW_H_MIN);
expect(Number.isFinite(layout.totalHeight)).toBe(true);
expect(layout.overflow).toBe(true);
});
it("does not blow up when a fold swallows the whole visible range", () => {
const layout = computeFit({
bounds: { start: 8, end: 20 },
folds: [{ start: 8, end: 20 }],
viewportHeight: 600,
});
expect(layout.unfoldedHours).toBe(0);
expect(layout.segments).toEqual([
{ kind: "strip", start: 0, end: 1440, y: 0, height: STRIP_H },
]);
expect(layout.rowHeight).toBe(ROW_H_MIN);
expect(layout.totalHeight).toBe(STRIP_H);
expect(layout.overflow).toBe(false);
expect(timeToY(layout, 600)).toBe(0);
expect(yToTime(layout, 10)).toBe(0);
});
it("derives its bounds from the events when none are pinned", () => {
const layout = computeFit({
events: [ev([9, 30], [10, 0]), ev([15, 0], [16, 30])],
previous: DEFAULT_BOUNDS,
viewportHeight: 800,
});
expect(layout.bounds).toEqual({ start: 8, end: 18 });
});
});
describe("timeToY and yToTime", () => {
const layout = computeFit({
bounds: { start: 8, end: 20 },
folds: [{ start: 12, end: 14 }],
viewportHeight: 723,
});
const unfolded = (minutes: number) =>
layout.segments.some((s) => s.kind === "hours" && minutes >= s.start && minutes < s.end);
it("is an exact inverse everywhere the scale is linear", () => {
for (let m = 0; m <= 1440; m += 1) {
if (!unfolded(m)) continue;
expect(yToTime(layout, timeToY(layout, m))).toBeCloseTo(m, 9);
}
});
it("round trips fractional minutes too", () => {
for (const m of [480.25, 600.5, 719.75, 840.1, 1199.9]) {
expect(yToTime(layout, timeToY(layout, m))).toBeCloseTo(m, 9);
}
});
it("round trips from pixels back to pixels", () => {
for (let y = 0; y <= layout.totalHeight; y += 0.5) {
const m = yToTime(layout, y);
if (!unfolded(m)) continue;
expect(timeToY(layout, m)).toBeCloseTo(y, 9);
}
});
it("holds at the edge of every unfolded region", () => {
for (const s of layout.segments) {
if (s.kind !== "hours") continue;
expect(timeToY(layout, s.start)).toBeCloseTo(s.y, 10);
expect(yToTime(layout, timeToY(layout, s.start))).toBeCloseTo(s.start, 9);
// The last minute belongs to the next segment, and both agree on the pixel.
expect(timeToY(layout, s.end)).toBeCloseTo(s.y + s.height, 10);
expect(yToTime(layout, timeToY(layout, s.end))).toBeCloseTo(s.end, 9);
}
});
it("is linear inside an unfolded region, so twice as tall is twice as long", () => {
const hour = timeToY(layout, 900) - timeToY(layout, 840);
const twoHours = timeToY(layout, 960) - timeToY(layout, 840);
expect(twoHours).toBeCloseTo(2 * hour, 9);
expect(hour).toBeCloseTo(layout.rowHeight, 9);
});
it("collapses a time inside a folded band onto its strip", () => {
const strip = layout.segments.filter((s) => s.kind === "strip")[1];
expect(strip.start).toBe(720);
expect(isFolded(layout, 750)).toBe(true);
expect(timeToY(layout, 721)).toBe(strip.y);
expect(timeToY(layout, 839)).toBe(strip.y);
expect(yToTime(layout, strip.y + 10)).toBe(720);
});
it("never leaves the grid", () => {
expect(timeToY(layout, -60)).toBe(0);
expect(timeToY(layout, 0)).toBe(0);
expect(timeToY(layout, 1440)).toBe(layout.totalHeight);
expect(timeToY(layout, 5000)).toBe(layout.totalHeight);
expect(yToTime(layout, -10)).toBe(0);
expect(yToTime(layout, layout.totalHeight)).toBe(1440);
expect(yToTime(layout, 1e6)).toBe(1440);
});
it("never goes backwards", () => {
let last = -1;
for (let m = 0; m <= 1440; m += 3) {
const y = timeToY(layout, m);
expect(y).toBeGreaterThanOrEqual(last);
last = y;
}
});
});
+322
View File
@@ -0,0 +1,322 @@
// Vertical fit. The grid never scrolls, so the row height is derived from the window and the
// visible range: whatever you are looking at fits exactly.
//
// Everything here is pure. Times are minutes from local midnight, bounds and folds are whole
// hours, and the only clock reading is `new Date(ms)` to get an event's local wall time. The
// localStorage side of folds lives in `./folds`.
import { MINUTES_PER_DAY, isSameDay, minutesFromMidnight } from "../time";
/** Matches `--strip-h`. A folded band is this tall whatever it contains. */
export const STRIP_H = 22;
/** Matches `--row-h-min`. Below this the grid gives up and scrolls. */
export const ROW_H_MIN = 22;
export const MIN_SPAN_HOURS = 8;
/** A contraction smaller than this is ignored, so the axis holds still while you navigate. */
export const CONTRACT_HOURS = 2;
/** Cold start, before any event has been seen. */
export const DEFAULT_BOUNDS: Bounds = { start: 8, end: 20 };
/** Half-open, in whole hours from local midnight. `end` is exclusive of nothing: 24 is midnight. */
export interface HourRange {
start: number;
end: number;
}
export type Bounds = HourRange;
export type Fold = HourRange;
/** The shape the fit needs from an event. `Instance` satisfies it. */
export interface FitEvent {
startMs: number;
endMs: number;
allDay?: boolean;
}
export type SegmentKind = "hours" | "strip";
/**
* One vertical band of the day. Segments tile `[0, 1440)` in order, so their heights sum to
* `totalHeight`. An `hours` segment is drawn at `rowHeight` per hour; a `strip` is a folded band
* drawn at `stripHeight` whatever it spans.
*/
export interface Segment {
kind: SegmentKind;
/** Minutes from local midnight, `end` exclusive. */
start: number;
end: number;
y: number;
height: number;
}
export interface FitInput {
/** Everything visible in the current span. All-day events are ignored. */
events?: readonly FitEvent[];
/** The bounds adopted last pass. Hysteresis and the empty-span fallback need them. */
previous?: Bounds;
/** Interior bands the user folded with `z`, in hours. Order and overlap do not matter. */
folds?: readonly Fold[];
/** Pixels available to the grid body, below the day bar and the all-day band. */
viewportHeight: number;
/** Pins the axis, skipping both the event scan and hysteresis. Still clamped and widened. */
bounds?: Bounds;
stripHeight?: number;
minRowHeight?: number;
}
export interface FitLayout {
bounds: Bounds;
rowHeight: number;
stripHeight: number;
segments: Segment[];
/** Hours drawn at `rowHeight`, which is what the row height was solved for. */
unfoldedHours: number;
totalHeight: number;
viewportHeight: number;
/** The row height hit its floor, so the content is taller than the viewport. Let it scroll. */
overflow: boolean;
}
const clamp = (n: number, lo: number, hi: number) => (n < lo ? lo : n > hi ? hi : n);
/**
* Clamps to `[0, 24]` and widens to the eight hour minimum, splitting the shortfall between the
* two ends. An odd shortfall gives the extra hour to the later end, where the day actually is,
* and a widening that would run past midnight pushes into the other end instead.
*/
export function widen(start: number, end: number): Bounds {
let s = clamp(Math.floor(start), 0, 24);
let e = clamp(Math.ceil(end), 0, 24);
if (e < s) e = s;
const need = MIN_SPAN_HOURS - (e - s);
if (need > 0) {
s -= Math.floor(need / 2);
e += Math.ceil(need / 2);
if (s < 0) {
e -= s;
s = 0;
}
if (e > 24) {
s -= e - 24;
e = 24;
}
s = Math.max(0, s);
}
return { start: s, end: e };
}
/**
* The tightest minute range the timed events cover, or null when there are none.
*
* An event that crosses local midnight covers both ends of the day, so it returns the whole day.
* Multi-day timed events belong in the all-day band; drop them before calling if you render them
* there, otherwise they pin the axis open.
*/
export function eventSpan(events: readonly FitEvent[]): { lo: number; hi: number } | null {
let lo = Infinity;
let hi = -Infinity;
for (const e of events) {
if (e.allDay) continue;
if (!Number.isFinite(e.startMs) || !Number.isFinite(e.endMs)) continue;
const start = e.startMs;
const end = Math.max(e.endMs, start);
const endsAtMidnight = end > start && minutesFromMidnight(end) === 0;
// `endMs` is exclusive, so an event ending at midnight ends on the day before.
const lastDay = endsAtMidnight ? end - 1 : end;
let a: number;
let b: number;
if (isSameDay(start, lastDay)) {
a = minutesFromMidnight(start);
b = endsAtMidnight ? MINUTES_PER_DAY : minutesFromMidnight(end);
} else {
a = 0;
b = MINUTES_PER_DAY;
}
if (a < lo) lo = a;
if (b > hi) hi = b;
}
return lo === Infinity ? null : { lo, hi };
}
/**
* Hysteresis. Any expansion is adopted at once; a contraction is adopted only when it gives up
* two hours or more. The two are measured together, so a pass that grows one end and trims the
* other by an hour keeps the growth and holds the trim.
*/
export function adoptBounds(previous: Bounds, next: Bounds): Bounds {
const merged = {
start: Math.min(previous.start, next.start),
end: Math.max(previous.end, next.end),
};
const given = merged.end - merged.start - (next.end - next.start);
return given >= CONTRACT_HOURS ? next : merged;
}
/**
* Floor to the hour below the earliest start, ceil above the latest end, widen to the minimum.
* Null when nothing is visible, which is the caller's cue to keep what it had.
*/
export function rawBounds(events: readonly FitEvent[]): Bounds | null {
const span = eventSpan(events);
if (!span) return null;
return widen(Math.floor(span.lo / 60) - 1, Math.ceil(span.hi / 60) + 1);
}
/** `rawBounds` with the empty-span fallback and the hysteresis applied. */
export function computeBounds(events: readonly FitEvent[], previous: Bounds): Bounds {
const held = widen(previous.start, previous.end);
const raw = rawBounds(events);
return raw ? adoptBounds(held, raw) : held;
}
/** Sorted, with overlapping and touching ranges merged. */
function mergeRanges(ranges: readonly HourRange[]): HourRange[] {
const sorted = ranges.filter((r) => r.end > r.start).sort((a, b) => a.start - b.start);
const out: HourRange[] = [];
for (const r of sorted) {
const last = out[out.length - 1];
if (last && r.start <= last.end) last.end = Math.max(last.end, r.end);
else out.push({ start: r.start, end: r.end });
}
return out;
}
/** Drops the junk, clips to `[0, 24]`, merges what touches. Fold state is a set, not a list. */
export function normalizeFolds(folds: readonly Fold[]): Fold[] {
const clean = folds
.filter((f) => Number.isFinite(f.start) && Number.isFinite(f.end))
.map((f) => ({ start: clamp(f.start, 0, 24), end: clamp(f.end, 0, 24) }));
return mergeRanges(clean);
}
export function addFold(folds: readonly Fold[], range: Fold): Fold[] {
return normalizeFolds([...folds, range]);
}
/** Unfolds whatever band covers `hour`. Nothing there is not an error. */
export function removeFoldAt(folds: readonly Fold[], hour: number): Fold[] {
return normalizeFolds(folds).filter((f) => hour < f.start || hour >= f.end);
}
export function foldAt(folds: readonly Fold[], hour: number): Fold | null {
return normalizeFolds(folds).find((f) => hour >= f.start && hour < f.end) ?? null;
}
/**
* Solves the row height for the viewport and lays the day out.
*
* The bands outside the bounds fold into a strip at each end, interior folds become strips where
* they sit, and the hours left over share whatever height remains. When that share falls below
* `minRowHeight` the row height sticks there and `overflow` says the grid has to scroll.
*/
export function computeFit(input: FitInput): FitLayout {
const stripHeight = input.stripHeight ?? STRIP_H;
const minRowHeight = input.minRowHeight ?? ROW_H_MIN;
const viewportHeight = Math.max(0, input.viewportHeight);
const bounds = input.bounds
? widen(input.bounds.start, input.bounds.end)
: computeBounds(input.events ?? [], input.previous ?? DEFAULT_BOUNDS);
const boundStart = bounds.start * 60;
const boundEnd = bounds.end * 60;
const interior = normalizeFolds(input.folds ?? []).map((f) => ({
start: clamp(f.start * 60, boundStart, boundEnd),
end: clamp(f.end * 60, boundStart, boundEnd),
}));
const folded = mergeRanges([
{ start: 0, end: boundStart },
...interior,
{ start: boundEnd, end: MINUTES_PER_DAY },
]);
let foldedMinutes = 0;
for (const f of folded) foldedMinutes += f.end - f.start;
const unfoldedHours = (MINUTES_PER_DAY - foldedMinutes) / 60;
const spare = viewportHeight - folded.length * stripHeight;
let rowHeight = unfoldedHours > 0 ? spare / unfoldedHours : minRowHeight;
if (!Number.isFinite(rowHeight) || rowHeight < minRowHeight) rowHeight = minRowHeight;
const segments: Segment[] = [];
let cursor = 0;
let y = 0;
const push = (kind: SegmentKind, start: number, end: number) => {
const height = kind === "strip" ? stripHeight : ((end - start) / 60) * rowHeight;
segments.push({ kind, start, end, y, height });
y += height;
};
for (const f of folded) {
if (f.start > cursor) push("hours", cursor, f.start);
push("strip", f.start, f.end);
cursor = f.end;
}
if (cursor < MINUTES_PER_DAY) push("hours", cursor, MINUTES_PER_DAY);
const totalHeight = y;
return {
bounds,
rowHeight,
stripHeight,
segments,
unfoldedHours,
totalHeight,
viewportHeight,
// Sub-pixel spill is float noise, not a reason to grow a scrollbar.
overflow: totalHeight > viewportHeight + 0.5,
};
}
function segmentAtTime(layout: FitLayout, minutes: number): Segment | null {
for (const s of layout.segments) if (minutes >= s.start && minutes < s.end) return s;
return null;
}
function segmentAtY(layout: FitLayout, y: number): Segment | null {
for (const s of layout.segments) if (y >= s.y && y < s.y + s.height) return s;
return null;
}
/**
* Minutes from local midnight to a pixel offset from the top of the grid body.
*
* Exact inverse of `yToTime` anywhere in an unfolded band, including at its edges. A time inside
* a folded band collapses to the top of its strip, so the mapping stays monotonic but loses the
* minute: there is nowhere for it to go in 22 pixels.
*/
export function timeToY(layout: FitLayout, minutes: number): number {
if (!(minutes > 0)) return 0;
if (minutes >= MINUTES_PER_DAY) return layout.totalHeight;
const s = segmentAtTime(layout, minutes);
if (!s) return layout.totalHeight;
if (s.kind === "strip") return s.y;
return s.y + ((minutes - s.start) / 60) * layout.rowHeight;
}
/** Pixels back to minutes. A pixel inside a strip reads as the first minute the strip hides. */
export function yToTime(layout: FitLayout, y: number): number {
if (!(y > 0)) return 0;
if (y >= layout.totalHeight) return MINUTES_PER_DAY;
const s = segmentAtY(layout, y);
if (!s) return MINUTES_PER_DAY;
if (s.kind === "strip") return s.start;
return s.start + ((y - s.y) / layout.rowHeight) * 60;
}
/** True when the minute is hidden inside a strip, so a block there renders as a count. */
export function isFolded(layout: FitLayout, minutes: number): boolean {
return segmentAtTime(layout, minutes)?.kind === "strip";
}
+72
View File
@@ -0,0 +1,72 @@
import { afterEach, describe, expect, it } from "vitest";
import { DEFAULT_BOUNDS } from "./fit";
import { loadBounds, loadFolds, saveBounds, saveFolds } from "./folds";
/** A headless run has no window at all, which is also the case this module has to survive. */
function fakeStorage(seed: Record<string, string> = {}) {
const data = new Map(Object.entries(seed));
const localStorage = {
get length() {
return data.size;
},
key: (i: number) => [...data.keys()][i] ?? null,
getItem: (k: string) => data.get(k) ?? null,
setItem: (k: string, v: string) => void data.set(k, v),
removeItem: (k: string) => void data.delete(k),
clear: () => data.clear(),
};
globalThis.window = { localStorage } as unknown as Window & typeof globalThis;
return data;
}
afterEach(() => {
delete (globalThis as { window?: unknown }).window;
});
describe("fold storage", () => {
it("gives empty folds and the default bounds with no storage at all", () => {
expect(loadFolds()).toEqual([]);
expect(loadBounds()).toEqual(DEFAULT_BOUNDS);
expect(() => saveFolds([{ start: 12, end: 14 }])).not.toThrow();
expect(() => saveBounds({ start: 8, end: 20 })).not.toThrow();
});
it("round trips folds, normalized on the way in", () => {
fakeStorage();
saveFolds([
{ start: 14, end: 16 },
{ start: 12, end: 14 },
]);
expect(loadFolds()).toEqual([{ start: 12, end: 16 }]);
});
it("round trips bounds", () => {
fakeStorage();
saveBounds({ start: 7, end: 19 });
expect(loadBounds()).toEqual({ start: 7, end: 19 });
});
it("writes the shape index.html reads before first paint", () => {
const data = fakeStorage();
saveFolds([{ start: 12, end: 14 }]);
saveBounds({ start: 8, end: 20 });
expect(JSON.parse(data.get("margincal-folds") as string)).toEqual([{ start: 12, end: 14 }]);
expect(JSON.parse(data.get("margincal-bounds") as string)).toEqual({ start: 8, end: 20 });
});
it("ignores anything it did not write", () => {
fakeStorage({
"margincal-folds": '{"nope":1}',
"margincal-bounds": "not json",
});
expect(loadFolds()).toEqual([]);
expect(loadBounds()).toEqual(DEFAULT_BOUNDS);
fakeStorage({
"margincal-folds": '[{"start":12,"end":14},null,{"start":"x","end":3}]',
"margincal-bounds": '{"start":20,"end":8}',
});
expect(loadFolds()).toEqual([{ start: 12, end: 14 }]);
expect(loadBounds()).toEqual(DEFAULT_BOUNDS);
});
});
+82
View File
@@ -0,0 +1,82 @@
// The disk side of the fit: folds and the last adopted bounds survive a restart, so the axis you
// left is the axis you come back to. `fit.ts` stays pure and takes both as arguments.
//
// Both keys are read before first paint by the blocking IIFE in index.html, which writes
// --bound-start, --bound-end and --fold-count. Changing a shape here means changing it there.
import { DEFAULT_BOUNDS, normalizeFolds, type Bounds, type Fold } from "./fit";
const FOLDS_KEY = "margincal-folds";
const BOUNDS_KEY = "margincal-bounds";
// Read off `window` rather than the bare global: a headless run has neither, and Node's own
// localStorage stub warns the moment you look at it.
function store(): Storage | null {
try {
return typeof window === "undefined" ? null : window.localStorage;
} catch {
return null;
}
}
function isRange(value: unknown): value is Bounds {
const r = value as Bounds | null;
return (
typeof r === "object" &&
r !== null &&
typeof r.start === "number" &&
typeof r.end === "number" &&
Number.isFinite(r.start) &&
Number.isFinite(r.end)
);
}
function read(key: string): unknown {
const s = store();
if (!s) return null;
try {
return JSON.parse(s.getItem(key) ?? "null");
} catch {
return null;
}
}
function write(key: string, value: unknown): void {
const s = store();
if (!s) return;
try {
s.setItem(key, JSON.stringify(value));
} catch {
// A full or disabled store costs the memory of a fold, not the session.
}
}
function setVar(name: string, value: string): void {
if (typeof document === "undefined") return;
document.documentElement.style.setProperty(name, value);
}
export function loadFolds(): Fold[] {
const raw = read(FOLDS_KEY);
if (!Array.isArray(raw)) return [];
return normalizeFolds(raw.filter(isRange));
}
export function saveFolds(folds: readonly Fold[]): Fold[] {
const clean = normalizeFolds(folds);
write(FOLDS_KEY, clean);
setVar("--fold-count", String(clean.length));
return clean;
}
export function loadBounds(): Bounds {
const raw = read(BOUNDS_KEY);
if (!isRange(raw) || raw.end <= raw.start) return DEFAULT_BOUNDS;
return { start: raw.start, end: raw.end };
}
export function saveBounds(bounds: Bounds): void {
write(BOUNDS_KEY, bounds);
setVar("--bound-start", String(bounds.start));
setVar("--bound-end", String(bounds.end));
}
+213
View File
@@ -0,0 +1,213 @@
import { describe, expect, it } from "vitest";
import { MIN_SLOT_MS, packEvents, type Packed } from "./packing";
interface Ev {
id: string;
startMs: number;
endMs: number;
}
const at = (hour: number, minute = 0) => new Date(2026, 0, 5, hour, minute, 0, 0).getTime();
const ev = (id: string, from: [number, number], to: [number, number]): Ev => ({
id,
startMs: at(from[0], from[1]),
endMs: at(to[0], to[1]),
});
const byId = (packed: Packed<Ev>[]) => new Map(packed.map((p) => [p.event.id, p]));
/** Same rule the engine uses, so a zero-length event counts as colliding. */
const collides = (a: Ev, b: Ev) => {
const ae = Math.max(a.endMs, a.startMs + MIN_SLOT_MS);
const be = Math.max(b.endMs, b.startMs + MIN_SLOT_MS);
return a.startMs < be && b.startMs < ae;
};
/** The invariant the whole model exists for: two events in the same minute never share a pixel. */
function expectNoVisualOverlap(packed: Packed<Ev>[]) {
for (let i = 0; i < packed.length; i++) {
for (let j = i + 1; j < packed.length; j++) {
const a = packed[i];
const b = packed[j];
if (!collides(a.event, b.event)) continue;
const apart = a.left + a.width <= b.left + 1e-9 || b.left + b.width <= a.left + 1e-9;
expect(apart, `${a.event.id} and ${b.event.id} overlap in time and in space`).toBe(true);
}
}
}
function expectSane(packed: Packed<Ev>[]) {
for (const p of packed) {
expect(Number.isFinite(p.left)).toBe(true);
expect(Number.isFinite(p.width)).toBe(true);
expect(p.width).toBeGreaterThan(0);
expect(p.left).toBeGreaterThanOrEqual(0);
expect(p.left + p.width).toBeLessThanOrEqual(1 + 1e-9);
expect(p.span).toBeGreaterThanOrEqual(1);
expect(p.columns).toBeGreaterThanOrEqual(1);
}
expectNoVisualOverlap(packed);
}
describe("packEvents", () => {
it("gives a lone event the full width", () => {
const packed = packEvents([ev("a", [9, 0], [10, 0])]);
expect(packed).toHaveLength(1);
expect(packed[0]).toMatchObject({ column: 0, span: 1, columns: 1, left: 0, width: 1 });
});
it("handles an empty day", () => {
expect(packEvents([])).toEqual([]);
});
it("splits two fully overlapping events down the middle", () => {
const packed = packEvents([ev("a", [9, 0], [10, 0]), ev("b", [9, 0], [10, 0])]);
const by = byId(packed);
expect(by.get("a")).toMatchObject({ left: 0, width: 0.5 });
expect(by.get("b")).toMatchObject({ left: 0.5, width: 0.5 });
expectSane(packed);
});
it("gives three mutually overlapping events a third each", () => {
const packed = packEvents([
ev("a", [9, 0], [10, 0]),
ev("b", [9, 15], [10, 15]),
ev("c", [9, 30], [10, 30]),
]);
const by = byId(packed);
for (const id of ["a", "b", "c"]) expect(by.get(id)?.width).toBeCloseTo(1 / 3, 12);
expect(by.get("a")?.left).toBeCloseTo(0, 12);
expect(by.get("b")?.left).toBeCloseTo(1 / 3, 12);
expect(by.get("c")?.left).toBeCloseTo(2 / 3, 12);
expectSane(packed);
});
it("keeps a chain in one cluster where A meets B and B meets C but A misses C", () => {
const packed = packEvents([
ev("a", [9, 0], [10, 0]),
ev("b", [9, 30], [10, 30]),
ev("c", [10, 15], [11, 0]),
]);
const by = byId(packed);
expect(new Set(packed.map((p) => p.cluster)).size).toBe(1);
expect(by.get("a")?.columns).toBe(2);
// A and C never collide, so they can share a column.
expect(by.get("a")?.column).toBe(0);
expect(by.get("c")?.column).toBe(0);
expect(by.get("b")?.column).toBe(1);
for (const id of ["a", "b", "c"]) expect(by.get(id)?.width).toBe(0.5);
expectSane(packed);
});
it("gives non-overlapping events the full width and a cluster each", () => {
const packed = packEvents([
ev("a", [9, 0], [10, 0]),
ev("b", [10, 0], [11, 0]),
ev("c", [11, 0], [12, 0]),
]);
expect(packed.map((p) => p.width)).toEqual([1, 1, 1]);
expect(packed.map((p) => p.cluster)).toEqual([0, 1, 2]);
expectSane(packed);
});
it("expands rightward so a brief collision still takes most of the width", () => {
// A, B and C stack three deep early on. D only clips C, so it keeps the two free columns.
const packed = packEvents([
ev("a", [9, 0], [9, 20]),
ev("b", [9, 5], [9, 25]),
ev("c", [9, 10], [9, 30]),
ev("d", [9, 25], [10, 30]),
]);
const by = byId(packed);
expect(by.get("d")).toMatchObject({ column: 0, span: 2, columns: 3 });
expect(by.get("d")?.width).toBeCloseTo(2 / 3, 12);
for (const id of ["a", "b", "c"]) expect(by.get(id)?.width).toBeCloseTo(1 / 3, 12);
expectSane(packed);
});
it("lets a late event take the whole width when its columns are free for its whole run", () => {
const packed = packEvents([
ev("a", [9, 0], [9, 30]),
ev("b", [9, 15], [10, 0]),
ev("c", [9, 30], [10, 0]),
ev("d", [10, 0], [11, 0]),
]);
const by = byId(packed);
// D starts a fresh cluster, so nothing constrains it.
expect(by.get("d")).toMatchObject({ width: 1, columns: 1 });
expectSane(packed);
});
it("puts the longer of two events that start together on the left", () => {
const packed = packEvents([ev("short", [9, 0], [9, 30]), ev("long", [9, 0], [11, 0])]);
const by = byId(packed);
expect(by.get("long")?.column).toBe(0);
expect(by.get("short")?.column).toBe(1);
expect(packed.map((p) => p.event.id)).toEqual(["long", "short"]);
});
it("does not touch an event that ends exactly where the next begins", () => {
const packed = packEvents([ev("a", [9, 0], [10, 0]), ev("b", [10, 0], [11, 0])]);
expect(packed.every((p) => p.width === 1)).toBe(true);
});
it("gives a zero-length event a real block instead of NaN", () => {
const packed = packEvents([ev("point", [9, 0], [9, 0]), ev("hour", [9, 0], [10, 0])]);
const by = byId(packed);
expect(by.get("point")?.width).toBe(0.5);
expect(by.get("hour")?.width).toBe(0.5);
expectSane(packed);
});
it("keeps identical events apart", () => {
const packed = packEvents([
ev("a", [9, 0], [9, 0]),
ev("b", [9, 0], [9, 0]),
ev("c", [9, 0], [9, 0]),
]);
expect(packed.map((p) => p.width)).toEqual([1 / 3, 1 / 3, 1 / 3]);
expectSane(packed);
});
it("survives a broken range", () => {
const packed = packEvents([
{ id: "backwards", startMs: at(10, 0), endMs: at(9, 0) },
{ id: "nan", startMs: NaN, endMs: NaN },
]);
expectSane(packed);
});
it("returns every event once, in start order", () => {
const input = [
ev("c", [11, 0], [12, 0]),
ev("a", [9, 0], [10, 0]),
ev("b", [9, 30], [10, 30]),
];
const packed = packEvents(input);
expect(packed.map((p) => p.event.id)).toEqual(["a", "b", "c"]);
});
it("holds its invariants on a messy day", () => {
// A fixed LCG, so a failure is reproducible.
let seed = 20260105;
const rand = () => (seed = (seed * 1103515245 + 12345) % 2147483648) / 2147483648;
for (let round = 0; round < 40; round++) {
const events: Ev[] = [];
for (let i = 0; i < 30; i++) {
const start = Math.floor(rand() * 96) * 15;
const length = Math.floor(rand() * 8) * 15;
events.push({
id: `e${i}`,
startMs: at(0, 0) + start * 60_000,
endMs: at(0, 0) + (start + length) * 60_000,
});
}
const packed = packEvents(events);
expect(packed).toHaveLength(events.length);
expect(new Set(packed.map((p) => p.event.id)).size).toBe(events.length);
expectSane(packed);
}
});
});
+116
View File
@@ -0,0 +1,116 @@
// Event packing, the standard constraint model. Sort by start then by length, cut the day into
// collision clusters, give each event the leftmost column that is free where it starts, then let
// it grow rightward across every column that stays free for its whole run.
//
// Colliding events come out the same width, nothing visually overlaps, and an event that only
// clips the edge of a cluster still takes most of the width.
//
// Pure, and unaware of pixels: `left` and `width` are fractions of the day column. Call it once
// per day with that day's timed events. All-day events live in their own band and are not packed.
/** The shape packing needs from an event. `Instance` satisfies it. */
export interface PackEvent {
startMs: number;
/** Exclusive. An event ending where another starts does not collide with it. */
endMs: number;
}
/**
* A zero-length event still has to be visible, so collisions treat it as a minute long. Without
* this it would collide with nothing, take the full width and paint straight over its neighbours.
*/
export const MIN_SLOT_MS = 60_000;
export interface Packed<T> {
event: T;
/** Index of the collision cluster, in start order. Useful for keys and for debugging. */
cluster: number;
column: number;
/** Columns the event grew across, at least 1. */
span: number;
/** Columns in the cluster, so `column`/`span` can be read back as pixels. */
columns: number;
/** Fraction of the day column, in `[0, 1]`. `left + width` never exceeds 1. */
left: number;
width: number;
}
interface Slot {
index: number;
start: number;
end: number;
}
const overlaps = (a: Slot, b: Slot) => a.start < b.end && b.start < a.end;
export function packEvents<T extends PackEvent>(events: readonly T[]): Packed<T>[] {
const slots: Slot[] = events.map((e, index) => {
const start = Number.isFinite(e.startMs) ? e.startMs : 0;
const end = Number.isFinite(e.endMs) ? e.endMs : start;
return { index, start, end: Math.max(end, start + MIN_SLOT_MS) };
});
slots.sort(
(a, b) => a.start - b.start || b.end - b.start - (a.end - a.start) || a.index - b.index,
);
const packed: Packed<T>[] = [];
let cluster = 0;
let members: Slot[] = [];
let clusterEnd = -Infinity;
const flush = () => {
if (members.length === 0) return;
// Leftmost column free at the event's start. A column is free once its last event has ended.
const columnEnd: number[] = [];
const columnOf = new Map<number, number>();
const inColumn: Slot[][] = [];
for (const s of members) {
let c = 0;
while (c < columnEnd.length && columnEnd[c] > s.start) c++;
if (c === columnEnd.length) {
columnEnd.push(s.end);
inColumn.push([s]);
} else {
columnEnd[c] = Math.max(columnEnd[c], s.end);
inColumn[c].push(s);
}
columnOf.set(s.index, c);
}
const columns = columnEnd.length;
for (const s of members) {
const column = columnOf.get(s.index) as number;
let span = 1;
while (
column + span < columns &&
!inColumn[column + span].some((other) => overlaps(s, other))
) {
span++;
}
packed.push({
event: events[s.index],
cluster,
column,
span,
columns,
left: column / columns,
width: span / columns,
});
}
cluster++;
members = [];
clusterEnd = -Infinity;
};
for (const s of slots) {
if (s.start >= clusterEnd) flush();
members.push(s);
clusterEnd = Math.max(clusterEnd, s.end);
}
flush();
return packed;
}
+187
View File
@@ -0,0 +1,187 @@
// The IPC contract. Every type here mirrors a struct in src-tauri/src/dto.rs. Both sides are
// frozen once written: implementation modules add bodies, not fields.
//
// Typed per-command wrappers live in src/api/, grouped by domain.
import { invoke } from "@tauri-apps/api/core";
/** A Tauri build of any shape, phone included. There is a Rust backend behind this. */
export const isTauri = typeof window !== "undefined" && "__TAURI_INTERNALS__" in window;
const isMobileOs =
typeof navigator !== "undefined" &&
(/android|iphone|ipod/i.test(navigator.userAgent) ||
// iPadOS reports itself as a Mac and gives itself away only by having a touchscreen.
(/ipad|macintosh/i.test(navigator.userAgent) && navigator.maxTouchPoints > 1));
/**
* A Tauri build with a real window behind it: something to drag by its title bar, a maximize to
* toggle, a close to intercept before it happens.
*
* The difference from `isTauri` is not cosmetic and this is not "am I in the app". `core:window:*`
* sits in the desktop-only capability, so on a phone those commands are not no-ops, they are
* refused, and calling one is a rejected IPC command rather than nothing happening.
*/
export const isDesktop = isTauri && !isMobileOs;
/**
* True when there is a backend to answer a command: Tauri, or the dev fixture in a browser.
* Data-loading actions gate on this. Anything touching a window API must gate on `isDesktop`
* instead, and anything listening for a Tauri event on `isTauri`, because a phone emits those too.
*/
export const live = (): boolean => isTauri || import.meta.env.DEV;
export interface Account {
id: string;
email: string;
connected: boolean;
}
export interface Calendar {
id: string;
accountId: string;
summary: string;
description: string | null;
colorHex: string;
selected: boolean;
accessRole: string;
timeZone: string;
primary: boolean;
}
export interface Attendee {
email: string;
displayName: string | null;
responseStatus: "needsAction" | "declined" | "tentative" | "accepted";
organizer: boolean;
self: boolean;
optional: boolean;
}
export interface Conference {
kind: string;
uri: string | null;
label: string | null;
}
/** Identifies one occurrence of a series, stable across syncs. */
export interface InstanceKey {
eventId: string;
originalStart: string | null;
}
/**
* One event occurrence, already expanded and already converted to the local zone.
*
* `start` and `end` are RFC3339 with an offset, except when `allDay`, where they are date-only
* `YYYY-MM-DD` and must never be shifted into a zone. `startMs` and `endMs` are epoch
* milliseconds, with all-day events pinned to local midnight, and `endMs` is exclusive.
*/
export interface Instance {
eventId: string;
calendarId: string;
accountId: string;
originalStart: string | null;
start: string;
end: string;
startMs: number;
endMs: number;
allDay: boolean;
summary: string;
description: string | null;
location: string | null;
status: "confirmed" | "tentative" | "cancelled";
recurring: boolean;
/** Resolved for rendering: the event's own colour when it has one, else its calendar's. */
colorHex: string;
/** Google's per-event colour id, "1" to "11". Null means the event follows its calendar. */
colorId: string | null;
etag: string | null;
organizer: string | null;
attendees: Attendee[];
conference: Conference | null;
readOnly: boolean;
/** Written locally and still sitting in the outbox. */
pending: boolean;
}
export interface EventDraft {
calendarId: string;
summary: string;
description?: string | null;
location?: string | null;
start: string;
end: string;
allDay: boolean;
/** Raw RFC5545 lines (RRULE/RDATE/EXDATE), omitted for a one-off. */
recurrence?: string[];
/** Google's per-event colour id, "1" to "11". Omit to follow the calendar. */
colorId?: string;
}
/** An absent field means unchanged. An empty string on `description` or `location` clears it. */
export interface EventPatch {
summary?: string;
description?: string;
location?: string;
start?: string;
end?: string;
allDay?: boolean;
calendarId?: string;
recurrence?: string[];
/** An empty string clears it back to the calendar's colour. */
colorId?: string;
}
export type Scope = "this" | "following" | "all";
/**
* Google's per-event palette, keyed by `colorId`. These are Google's own hex values, so a colour
* chosen here reads the same in both apps. Rendering maps them onto this app's own hues.
*/
export const EVENT_COLORS: { id: string; hex: string; name: string }[] = [
{ id: "1", hex: "#7986cb", name: "Lavender" },
{ id: "2", hex: "#33b679", name: "Sage" },
{ id: "3", hex: "#8e24aa", name: "Grape" },
{ id: "4", hex: "#e67c73", name: "Flamingo" },
{ id: "5", hex: "#f6bf26", name: "Banana" },
{ id: "6", hex: "#f4511e", name: "Tangerine" },
{ id: "7", hex: "#039be5", name: "Peacock" },
{ id: "8", hex: "#616161", name: "Graphite" },
{ id: "9", hex: "#3f51b5", name: "Blueberry" },
{ id: "10", hex: "#0b8043", name: "Basil" },
{ id: "11", hex: "#d50000", name: "Tomato" },
];
export interface SyncStatus {
phase: "idle" | "syncing" | "error";
/** Epoch milliseconds of the last successful sync. */
lastSync: number | null;
error: string | null;
pendingWrites: number;
message: string | null;
}
/** Payload of the `auth` event. */
export interface AuthEvent {
ok: boolean;
error: string | null;
accountId: string | null;
email: string | null;
}
/** Payload of `sync-progress`. `store-changed` carries a plain reason string. */
export type SyncProgress = SyncStatus;
/**
* In Tauri this is `invoke`. Opened in a browser during development it is served from the dev
* fixture instead, so the real UI can be driven and looked at without a Google account. The
* branch is compiled out of a production bundle, and `isTauri` means it can never shadow the
* real backend inside the app, on a desktop or on a phone.
*/
export function call<T>(command: string, args?: Record<string, unknown>): Promise<T> {
if (import.meta.env.DEV && !isTauri) {
return import("./dev/mockIpc").then((m) => m.mockCall<T>(command, args));
}
return invoke<T>(command, args);
}
+49
View File
@@ -0,0 +1,49 @@
// The `?` sheet, generated from the binding table. There is no list of shortcuts anywhere in this
// file, which is the entire point: a binding that exists is documented, and one that is removed
// stops being documented, without anybody remembering to do either.
import { Sheet } from "../components/overlayShell";
import { useOverlays } from "../store/useOverlays";
import "../styles/palette.css";
import { BINDINGS, GROUPS, bindingLabel, keyLabel } from "./bindings";
import { commandLabel } from "./commands";
export function ShortcutsSheet() {
const open = useOverlays((s) => s.open);
const close = useOverlays((s) => s.close);
return (
<Sheet open={open === "shortcuts"} title="Keyboard shortcuts" size="wide" onClose={close}>
<div className="shortcuts">
{GROUPS.map((group) => {
const rows = BINDINGS.filter((binding) => binding.group === group);
if (rows.length === 0) return null;
return (
<section className="shortcuts-group" key={group}>
<h3 className="shortcuts-heading">{group}</h3>
<dl className="shortcuts-list">
{rows.map((binding) => (
<div className="shortcuts-row" key={binding.keys.join(" ")}>
<dt className="shortcuts-keys">
{binding.keys.map((key) => (
<kbd className="key" key={key}>
{keyLabel(key)}
</kbd>
))}
</dt>
<dd className="shortcuts-label">{bindingLabel(binding, commandLabel)}</dd>
</div>
))}
</dl>
</section>
);
})}
</div>
<p className="shortcuts-note">
Nothing is modal and nothing is chorded. Keys stand back while a text field has the focus.
</p>
</Sheet>
);
}
export default ShortcutsSheet;
+51
View File
@@ -0,0 +1,51 @@
// The table's own invariants. Two bindings on one combo in one context would silently shadow each
// other, and a group the sheet does not render would silently hide a key, so both are asserted
// here rather than discovered later.
//
// This imports the table alone. `commands.ts` reaches for localStorage and Tauri the moment it is
// loaded, which is why `bindingLabel` takes the lookup as an argument.
import { describe, expect, it } from "vitest";
import { BINDINGS, GROUPS, bindingLabel, keyLabel, normalizeCombo } from "./bindings";
describe("the binding table", () => {
it("never binds one combo twice in the same context", () => {
const seen = new Set<string>();
for (const binding of BINDINGS) {
for (const key of binding.keys) {
const slot = `${binding.context}:${normalizeCombo(key)}`;
expect(seen.has(slot), `${slot} is bound twice`).toBe(false);
seen.add(slot);
}
}
});
it("puts every binding in a group the sheet renders", () => {
for (const binding of BINDINGS) expect(GROUPS).toContain(binding.group);
});
it("can name every binding, including the ones it does not own", () => {
for (const binding of BINDINGS) {
expect(bindingLabel(binding, (id) => `command ${id}`)).not.toBe("");
}
});
it("documents Escape without claiming to handle it", () => {
const escape = BINDINGS.find((b) => b.keys.includes("Escape"));
expect(escape?.command).toBeNull();
});
it("reads a combo the same way the dispatcher builds one", () => {
expect(normalizeCombo("Cmd+K")).toBe("cmd+k");
expect(normalizeCombo("cmd+k")).toBe("cmd+k");
expect(normalizeCombo("H")).toBe("H");
expect(normalizeCombo("/")).toBe("/");
});
it("prints a shifted letter as a shifted letter", () => {
expect(keyLabel("H")).toBe("⇧H");
expect(keyLabel("h")).toBe("h");
expect(keyLabel("Enter")).toBe("↩");
expect(keyLabel("Escape")).toBe("⎋");
});
});
+128
View File
@@ -0,0 +1,128 @@
// The keymap, declared once. `keymap.ts` dispatches from this table and `Shortcuts.tsx` renders
// the `?` sheet from it, so a binding that exists but is undocumented is not something you can
// write: the sheet is generated, never maintained.
//
// A combo is canonical: modifiers in `cmd+ctrl+alt` order, then `KeyboardEvent.key` verbatim.
// `cmd` means the platform's primary modifier, Command on macOS and Control everywhere else, which
// is what the native menu's `CmdOrCtrl` accelerators mean too. Shift is not a modifier here: it is
// already baked into the key, so `H` is the shifted `h` and reads that way in the table.
//
// Nothing is chorded and nothing is modal. Two keys never combine into a third meaning.
import type { CommandId } from "./commands";
/**
* Which frame of the context stack a binding belongs to. `overlay` carries no bindings of its own:
* pushing it is how an open panel shadows the whole view keymap while leaving `global` reachable.
*/
export type KeyContext = "global" | "view" | "overlay";
export type BindingGroup = "Navigation" | "Views" | "Events" | "App";
interface BindingBase {
/** Every combo that runs it. The sheet shows them all; the dispatcher accepts any. */
keys: readonly string[];
context: KeyContext;
group: BindingGroup;
/** Off by default: a key must never be stolen from an input. */
allowInInput?: boolean;
}
export interface CommandBinding extends BindingBase {
command: CommandId;
}
/** A key the keymap deliberately does not own, documented so the sheet is not a half-truth. */
export interface NoteBinding extends BindingBase {
command: null;
label: string;
}
export type Binding = CommandBinding | NoteBinding;
export const BINDINGS: readonly Binding[] = [
{ keys: ["h"], command: "day-prev", context: "view", group: "Navigation" },
{ keys: ["l"], command: "day-next", context: "view", group: "Navigation" },
{ keys: ["H"], command: "week-prev", context: "view", group: "Navigation" },
{ keys: ["L"], command: "week-next", context: "view", group: "Navigation" },
{ keys: ["t", "cmd+t"], command: "today", context: "view", group: "Navigation" },
{ keys: ["j"], command: "select-next", context: "view", group: "Navigation" },
{ keys: ["k"], command: "select-prev", context: "view", group: "Navigation" },
{ keys: ["m"], command: "mini-month", context: "view", group: "Navigation" },
{ keys: ["d", "cmd+1"], command: "view-day", context: "view", group: "Views" },
{ keys: ["w", "cmd+2"], command: "view-week", context: "view", group: "Views" },
{ keys: ["a", "cmd+3"], command: "view-agenda", context: "view", group: "Views" },
{ keys: ["z"], command: "fold", context: "view", group: "Views" },
{ keys: ["Z"], command: "unfold-all", context: "view", group: "Views" },
{ keys: ["c", "cmd+n"], command: "new-event", context: "view", group: "Events" },
{ keys: ["Enter"], command: "open-selection", context: "view", group: "Events" },
{ keys: ["e"], command: "edit-selection", context: "view", group: "Events" },
{ keys: ["x"], command: "delete-selection", context: "view", group: "Events" },
{ keys: ["/", "cmd+f"], command: "search", context: "view", group: "Events" },
// The palette is the one thing a text field may not swallow: it is how you get out of anywhere.
{ keys: ["cmd+k"], command: "command-palette", context: "global", group: "App", allowInInput: true },
{ keys: ["?", "cmd+/"], command: "shortcuts", context: "view", group: "App" },
// Escape unwinds the layer stack in `src/escape.ts`, which knows about nested confirmations.
{ keys: ["Escape"], command: null, label: "Dismiss whatever is open", context: "global", group: "App" },
{ keys: ["cmd+r"], command: "sync-now", context: "view", group: "App" },
{ keys: ["cmd+,"], command: "settings", context: "view", group: "App" },
];
export const GROUPS: readonly BindingGroup[] = ["Navigation", "Views", "Events", "App"];
const isMac =
typeof navigator !== "undefined" && /mac|iphone|ipad/i.test(navigator.userAgent ?? "");
/** The primary modifier as the platform names it. */
export const PRIMARY_LABEL = isMac ? "⌘" : "Ctrl+";
/** True when the event holds the platform's primary modifier, whatever the hardware calls it. */
export const primaryHeld = (e: { metaKey: boolean; ctrlKey: boolean }): boolean =>
isMac ? e.metaKey : e.ctrlKey;
export const secondaryHeld = (e: { metaKey: boolean; ctrlKey: boolean }): boolean =>
isMac ? e.ctrlKey : e.metaKey;
/** `Cmd+K` and `cmd+k` are the same binding; the table may be written either way. */
export function normalizeCombo(combo: string): string {
const parts = combo.split("+");
const key = parts.pop() ?? "";
const mods = new Set(parts.map((p) => p.toLowerCase()));
const prefix = ["cmd", "ctrl", "alt"].filter((m) => mods.has(m)).join("+");
return prefix ? `${prefix}+${key.toLowerCase()}` : key;
}
const NAMED: Record<string, string> = {
Enter: "↩",
Escape: "⎋",
ArrowUp: "↑",
ArrowDown: "↓",
ArrowLeft: "←",
ArrowRight: "→",
Tab: "⇥",
" ": "Space",
};
/** `cmd+k` becomes ⌘K, `H` becomes ⇧H. What the sheet and the palette both print. */
export function keyLabel(combo: string): string {
const parts = normalizeCombo(combo).split("+");
const key = parts.pop() ?? "";
const mods = parts
.map((m) => (m === "cmd" ? PRIMARY_LABEL : m === "ctrl" ? "⌃" : "⌥"))
.join("");
const named = NAMED[key];
if (named) return `${mods}${named}`;
if (mods) return `${mods}${key.toUpperCase()}`;
return /^[A-Z]$/.test(key) ? `⇧${key}` : key;
}
export const bindingLabel = (binding: Binding, labelOf: (id: CommandId) => string): string =>
binding.command === null ? binding.label : labelOf(binding.command);
/** The combos a command answers to, for a palette row or a button's title attribute. */
export function keysFor(id: CommandId): readonly string[] {
return BINDINGS.find((b) => b.command === id)?.keys ?? [];
}
+222
View File
@@ -0,0 +1,222 @@
// Every action the app can be asked to perform, in one table.
//
// A key, a menu item and a palette row all end up here, which is the point: the native menu emits
// an id and that id is a command, not a second code path. The label lives on the command rather
// than on the binding so the shortcut sheet, the palette and the menu cannot describe the same
// thing in three different ways.
//
// Nothing here holds state. Side effects go through the stores and through `gridCommands`, which
// is the imperative surface the grid publishes for exactly this.
import { openUrl } from "@tauri-apps/plugin-opener";
import { eventDelete } from "../api/events";
import { gridCommands } from "../components/GridStore";
import { useEditor } from "../components/useEditor";
import type { Instance } from "../ipc";
import { defaultStart } from "../palette/parse";
import { eventTitle } from "../components/GridModel";
import { useCalendarView } from "../store/useCalendarView";
import { useOverlays } from "../store/useOverlays";
import { useSync } from "../store/useSync";
import { useTheme } from "../store/useTheme";
import { notify } from "../store/useToast";
import { minutesFromMidnight, startOfDay } from "../time";
import { checkForUpdates } from "./updates";
const ISSUES_URL = "https://github.com/priyanshujain/margin-calendar/issues";
/** New events start an hour long, whether they are dragged out or typed. */
const NEW_MINUTES = 60;
export type CommandId =
| "day-prev"
| "day-next"
| "week-prev"
| "week-next"
| "today"
| "select-next"
| "select-prev"
| "mini-month"
| "view-day"
| "view-week"
| "view-agenda"
| "fold"
| "unfold-all"
| "new-event"
| "open-selection"
| "edit-selection"
| "delete-selection"
| "search"
| "command-palette"
| "shortcuts"
| "sync-now"
| "calendars"
| "accounts"
| "settings"
| "toggle-theme"
| "check-updates"
| "report-issue";
export interface Command {
id: CommandId;
/** The words the shortcut sheet, the palette and any tooltip all use. */
label: string;
/** Whether the palette lists it. Moving one day at a time is a key, not a menu entry. */
palette: boolean;
run: () => void;
}
const view = () => useCalendarView.getState();
const overlays = () => useOverlays.getState();
function selectedInstance(): Instance | null {
const { instances, selected } = view();
if (!selected) return null;
return (
instances.find(
(i) => i.eventId === selected.eventId && i.originalStart === selected.originalStart,
) ?? null
);
}
function openSelection(): void {
const instance = selectedInstance();
// Enter on nothing at all is a request for the first event of the day, not a mistake.
if (!instance) {
view().selectAdjacent(1);
return;
}
useEditor.getState().edit({ eventId: instance.eventId, originalStart: instance.originalStart });
}
function editSelection(): void {
const instance = selectedInstance();
if (!instance) {
notify("Nothing is selected");
return;
}
useEditor.getState().edit({ eventId: instance.eventId, originalStart: instance.originalStart });
}
/**
* `this` is the only scope a keystroke may pick: deleting a whole series is a decision that
* belongs in the editor, behind a confirmation.
*/
async function deleteSelection(): Promise<void> {
const instance = selectedInstance();
if (!instance) {
notify("Nothing is selected");
return;
}
if (instance.readOnly) {
notify("That calendar is read only");
return;
}
const title = eventTitle(instance);
try {
await eventDelete(
{ eventId: instance.eventId, originalStart: instance.originalStart },
"this",
);
view().select(null);
await view().load();
notify(instance.recurring ? `Deleted this occurrence of “${title}”` : `Deleted “${title}”`);
} catch (e) {
notify(`Could not delete “${title}”: ${e}`);
}
}
/** The grid takes a typed-in-place draft when the day is on screen; the agenda has no column. */
function newEvent(): void {
const startMs = defaultStart(view().anchor, Date.now());
const dayStart = startOfDay(startMs);
if (view().view !== "agenda" && gridCommands.days().includes(dayStart)) {
gridCommands.draftOn(dayStart, Math.round(minutesFromMidnight(startMs)), NEW_MINUTES);
return;
}
useEditor.getState().create({ startMs, endMs: startMs + NEW_MINUTES * 60_000 });
}
const TABLE: Record<CommandId, Omit<Command, "id">> = {
"day-prev": { label: "Previous day", palette: false, run: () => view().moveDay(-1) },
"day-next": { label: "Next day", palette: false, run: () => view().moveDay(1) },
"week-prev": { label: "Previous week", palette: false, run: () => view().moveWeek(-1) },
"week-next": { label: "Next week", palette: false, run: () => view().moveWeek(1) },
today: { label: "Go to today", palette: true, run: () => view().goToday() },
"select-next": { label: "Select the next event", palette: false, run: () => view().selectAdjacent(1) },
"select-prev": { label: "Select the previous event", palette: false, run: () => view().selectAdjacent(-1) },
"mini-month": { label: "Jump to a date", palette: true, run: () => overlays().show("mini-month") },
"view-day": { label: "Day view", palette: true, run: () => view().setView("day") },
"view-week": { label: "Week view", palette: true, run: () => view().setView("week") },
"view-agenda": { label: "Agenda view", palette: true, run: () => view().setView("agenda") },
fold: { label: "Fold or unfold the band under the cursor", palette: false, run: () => gridCommands.toggleFold() },
"unfold-all": { label: "Unfold every band", palette: true, run: () => gridCommands.unfoldAll() },
"new-event": { label: "New event", palette: true, run: newEvent },
"open-selection": { label: "Open the selected event", palette: false, run: openSelection },
"edit-selection": { label: "Edit the selected event", palette: false, run: editSelection },
"delete-selection": {
label: "Delete the selected event",
palette: false,
run: () => {
void deleteSelection();
},
},
search: { label: "Search events", palette: true, run: () => overlays().show("search") },
"command-palette": { label: "Command palette", palette: false, run: () => overlays().show("palette") },
shortcuts: { label: "Keyboard shortcuts", palette: true, run: () => overlays().show("shortcuts") },
"sync-now": {
label: "Sync now",
palette: true,
run: () => {
void useSync.getState().run();
},
},
calendars: { label: "Calendars", palette: true, run: () => overlays().show("calendars") },
accounts: { label: "Accounts", palette: true, run: () => overlays().show("accounts") },
settings: { label: "Settings", palette: true, run: () => overlays().show("settings") },
"toggle-theme": { label: "Toggle dark mode", palette: true, run: () => useTheme.getState().toggle() },
"check-updates": {
label: "Check for updates",
palette: true,
run: () => {
void checkForUpdates();
},
},
"report-issue": {
label: "Report an issue",
palette: true,
run: () => {
openUrl(ISSUES_URL).catch(() => notify("Could not open the browser"));
},
},
};
/** Declaration order, which is the order the palette lists them in. */
export const COMMANDS: readonly Command[] = (Object.keys(TABLE) as CommandId[]).map((id) => ({
id,
...TABLE[id],
}));
export const commandLabel = (id: CommandId): string => TABLE[id].label;
export function runCommand(id: CommandId): void {
TABLE[id].run();
}
/** Case-insensitive subsequence, so `agv` finds "Agenda view" and `sync` finds "Sync now". */
export function commandMatches(label: string, query: string): boolean {
const needle = query.toLowerCase().replace(/\s+/g, "");
if (!needle) return true;
const hay = label.toLowerCase();
let at = 0;
for (const ch of needle) {
at = hay.indexOf(ch, at);
if (at === -1) return false;
at += 1;
}
return true;
}
+157
View File
@@ -0,0 +1,157 @@
// One capture-phase listener for the whole app, and a context stack that decides what it is
// allowed to do.
//
// The stack starts empty, which means the view's keymap. An overlay pushes a frame and the whole
// `view` context is shadowed until it pops, so the panel on screen owns the keyboard without any
// component having to remember to unbind anything. Escape is not part of this: `src/escape.ts`
// already stacks Escape handlers and knows about nested confirmations, so this listener steps over
// the key entirely rather than racing it.
//
// A key is never taken from a text field. The grid's in-place create input is a text field the
// keymap cannot see, so `gridCommands.isEditing()` stands in for it.
import { useEffect } from "react";
import { gridCommands } from "../components/GridStore";
import { useOverlays, type Overlay } from "../store/useOverlays";
import {
BINDINGS,
normalizeCombo,
primaryHeld,
secondaryHeld,
type Binding,
type KeyContext,
} from "./bindings";
import { runCommand } from "./commands";
const index = new Map<string, Binding[]>();
for (const binding of BINDINGS) {
for (const key of binding.keys) {
const combo = normalizeCombo(key);
const found = index.get(combo);
if (found) found.push(binding);
else index.set(combo, [binding]);
}
}
interface Frame {
context: KeyContext;
}
const stack: Frame[] = [];
const activeContext = (): KeyContext => stack[stack.length - 1]?.context ?? "view";
/** Takes the keyboard until the returned function is called. Frames are identity, never by name. */
export function pushContext(context: KeyContext): () => void {
const frame: Frame = { context };
stack.push(frame);
return () => {
const at = stack.indexOf(frame);
if (at !== -1) stack.splice(at, 1);
};
}
/** The hook form, for a component that owns the keyboard while it is on screen. */
export function useKeyContext(context: KeyContext, active = true): void {
useEffect(() => {
if (!active) return;
return pushContext(context);
}, [context, active]);
}
function comboOf(e: KeyboardEvent): string {
const mods =
(primaryHeld(e) ? "cmd+" : "") + (secondaryHeld(e) ? "ctrl+" : "") + (e.altKey ? "alt+" : "");
return mods ? `${mods}${e.key.toLowerCase()}` : e.key;
}
function resolve(combo: string): Binding | null {
const candidates = index.get(combo);
if (!candidates) return null;
const top = activeContext();
return (
candidates.find((b) => b.context === top) ??
candidates.find((b) => b.context === "global") ??
null
);
}
function isTyping(target: EventTarget | null): boolean {
const el = target as HTMLElement | null;
if (!el || typeof el.tagName !== "string") return false;
if (el.isContentEditable) return true;
return el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.tagName === "SELECT";
}
/**
* A button the user can tab to activates itself on Enter, so the keymap leaves that alone. An
* event block is `tabIndex={-1}` and only ever focused by a click, and it is the keymap's job to
* open it, so the test is the tab index rather than the tag.
*/
function isActivatable(target: EventTarget | null): boolean {
const el = target as HTMLElement | null;
if (!el || typeof el.tagName !== "string" || el.tabIndex < 0) return false;
return (
el.tagName === "BUTTON" ||
el.tagName === "A" ||
el.tagName === "SUMMARY" ||
el.getAttribute("role") === "button"
);
}
function onKeyDown(e: KeyboardEvent): void {
if (e.isComposing || e.defaultPrevented) return;
if (e.key === "Escape") return;
if ((e.key === "Enter" || e.key === " ") && isActivatable(e.target)) return;
const binding = resolve(comboOf(e));
if (!binding || binding.command === null) return;
if (!binding.allowInInput) {
if (isTyping(e.target) || isTyping(document.activeElement)) return;
if (gridCommands.isEditing()) return;
}
e.preventDefault();
e.stopPropagation();
runCommand(binding.command);
}
let overlayFrame: (() => void) | null = null;
// Every overlay is summoned through `useOverlays`, so the frame is synced here rather than asked
// of each panel: a panel that forgot to push one would silently leave the grid's keys live under
// it. Exactly one overlay is open at a time, so there is exactly one frame.
function syncOverlayFrame(open: Overlay | null): void {
if (open && !overlayFrame) overlayFrame = pushContext("overlay");
else if (!open && overlayFrame) {
overlayFrame();
overlayFrame = null;
}
}
let installs = 0;
let stopWatching: (() => void) | null = null;
/** Installs the one listener. Reference counted, so React's double effect in dev is harmless. */
export function installKeymap(): () => void {
installs += 1;
if (installs === 1) {
window.addEventListener("keydown", onKeyDown, true);
syncOverlayFrame(useOverlays.getState().open);
stopWatching = useOverlays.subscribe((s) => syncOverlayFrame(s.open));
}
return () => {
installs -= 1;
if (installs > 0) return;
window.removeEventListener("keydown", onKeyDown, true);
stopWatching?.();
stopWatching = null;
syncOverlayFrame(null);
};
}
/** Mount once, at the top of the tree. */
export function useKeymap(): void {
useEffect(() => installKeymap(), []);
}
+30
View File
@@ -0,0 +1,30 @@
// The native macOS menu emits `menu-action` with the item id it was built with. Those ids are
// command ids, so this is a guard and a lookup rather than a second dispatch table: a menu item
// and a keystroke run the same function or the build fails.
//
// The Tauri listener itself is mounted at the top of the tree; this is what it calls.
import { runCommand, type CommandId } from "./commands";
/** Exactly the ids `src-tauri/src/lib.rs` emits. */
const MENU_IDS: readonly CommandId[] = [
"new-event",
"command-palette",
"sync-now",
"accounts",
"check-updates",
"settings",
"search",
"today",
"shortcuts",
"view-day",
"view-week",
"view-agenda",
"report-issue",
];
const known = new Set<string>(MENU_IDS);
export function handleMenuAction(id: string): void {
if (known.has(id)) runCommand(id as CommandId);
}
+29
View File
@@ -0,0 +1,29 @@
// The updater, reached only through the `check-updates` command so the menu item and the palette
// row are the same path. Ported from margin's `src/updater.ts`, minus its progress dialog: there
// is no update UI here yet, so the toast carries the whole story.
import { relaunch } from "@tauri-apps/plugin-process";
import { check } from "@tauri-apps/plugin-updater";
import { isDesktop } from "../ipc";
import { notify } from "../store/useToast";
let running = false;
export async function checkForUpdates(): Promise<void> {
if (!isDesktop || running) return;
running = true;
try {
const update = await check();
if (!update) {
notify("Margin Calendar is up to date");
return;
}
notify(`Installing ${update.version}…`);
await update.downloadAndInstall();
await relaunch();
} catch (e) {
notify(`Could not check for updates: ${e}`);
} finally {
running = false;
}
}
+16
View File
@@ -0,0 +1,16 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import { trackSafeArea } from "./safeArea";
import "./styles/tokens.css";
import "./styles/fonts.css";
import "./styles/app.css";
// Before the first render, so the bars are the right height on the first paint rather than after.
trackSafeArea();
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);
+190
View File
@@ -0,0 +1,190 @@
// The command palette, which is also where events are created. Both halves are one list: a row is
// either a command or the event the text describes, and Enter runs whichever is highlighted.
//
// The parse is shown as a sentence under the input while you type, and when it is not certain it
// says so instead of quietly picking a reading. That warning is the difference between natural
// language input you can trust and a party trick.
import { useEffect, useMemo, useRef, type KeyboardEvent } from "react";
import { useEscapeLayer } from "../escape";
import { keyLabel, keysFor } from "../keys/bindings";
import { COMMANDS, commandMatches, runCommand, type CommandId } from "../keys/commands";
import { useAccounts } from "../store/useAccounts";
import { useCalendarView } from "../store/useCalendarView";
import { useOverlays } from "../store/useOverlays";
import "../styles/palette.css";
import { createFromInput } from "./create";
import {
defaultCalendar,
defaultStart,
matchCalendar,
parseEventInput,
previewText,
} from "./parse";
import { usePalette } from "./usePalette";
type Row = { kind: "create" } | { kind: "command"; id: CommandId; label: string };
export function CommandPalette() {
const open = useOverlays((s) => s.open) === "palette";
const close = useOverlays((s) => s.close);
const anchor = useCalendarView((s) => s.anchor);
const calendars = useAccounts((s) => s.calendars);
const query = usePalette((s) => s.query);
const index = usePalette((s) => s.index);
const setQuery = usePalette((s) => s.setQuery);
const setIndex = usePalette((s) => s.setIndex);
const move = usePalette((s) => s.move);
const reset = usePalette((s) => s.reset);
const input = useRef<HTMLInputElement | null>(null);
useEscapeLayer(open, close);
useEffect(() => {
if (!open) return;
reset();
input.current?.focus();
}, [open, reset]);
const parsed = useMemo(() => {
const now = Date.now();
return parseEventInput(query, now, defaultStart(anchor, now));
}, [query, anchor]);
const matched = useMemo(
() => matchCalendar(parsed.calendarHint, calendars),
[parsed.calendarHint, calendars],
);
const target = useMemo(() => matched ?? defaultCalendar(calendars), [matched, calendars]);
const commands = useMemo(
() => COMMANDS.filter((c) => c.palette && commandMatches(c.label, query)),
[query],
);
const canCreate = query.trim().length > 0 && parsed.title.length > 0 && target !== null;
const rows = useMemo<Row[]>(() => {
const list: Row[] = commands.map((c) => ({ kind: "command", id: c.id, label: c.label }));
if (!canCreate) return list;
// A dated phrase is almost certainly an event, so it leads. A bare word is probably a command.
return parsed.dated || list.length === 0
? [{ kind: "create" }, ...list]
: [...list, { kind: "create" }];
}, [commands, canCreate, parsed.dated]);
const active = rows.length === 0 ? -1 : Math.min(Math.max(index, 0), rows.length - 1);
const warning = !target
? "Connect a Google account before creating events."
: parsed.calendarHint && !matched
? `No calendar matches #${parsed.calendarHint}, so this goes to ${target.summary}.`
: parsed.ambiguous
? `Not sure: ${parsed.ambiguous}.`
: null;
const showPreview =
query.trim().length > 0 &&
(parsed.dated || commands.length === 0 || rows[active]?.kind === "create");
function run(row: Row | undefined) {
if (!row) return;
close();
reset();
if (row.kind === "command") runCommand(row.id);
else if (target) void createFromInput(parsed, target.id);
}
function onKeyDown(e: KeyboardEvent<HTMLInputElement>) {
if (e.key === "ArrowDown" || (e.key === "n" && e.ctrlKey)) {
e.preventDefault();
move(1, rows.length);
} else if (e.key === "ArrowUp" || (e.key === "p" && e.ctrlKey)) {
e.preventDefault();
move(-1, rows.length);
} else if (e.key === "Tab") {
e.preventDefault();
move(e.shiftKey ? -1 : 1, rows.length);
} else if (e.key === "Enter") {
e.preventDefault();
run(rows[active]);
}
}
if (!open) return null;
return (
<div
className="overlay"
data-align="top"
onPointerDown={(e) => {
if (e.target === e.currentTarget) close();
}}
>
<div className="panel palette" role="dialog" aria-modal="true" aria-label="Command palette">
<input
ref={input}
className="palette-input"
type="text"
value={query}
placeholder="Type a command, or an event: lunch with sam tue 1pm 45m"
autoComplete="off"
spellCheck={false}
aria-label="Command or event"
onChange={(e) => setQuery(e.target.value)}
onKeyDown={onKeyDown}
/>
{showPreview && (
<div className="palette-preview">
<p className="palette-sentence">
{parsed.title
? previewText(parsed, target?.summary ?? null)
: "Give it a title and it will be created here."}
</p>
{warning && <p className="palette-warning">{warning}</p>}
</div>
)}
{rows.length === 0 ? (
<p className="palette-empty">Nothing matches that.</p>
) : (
<ul className="palette-list" role="listbox">
{rows.map((row, at) => {
const keys = row.kind === "command" ? keysFor(row.id) : ["Enter"];
return (
<li
key={row.kind === "command" ? row.id : "create"}
className="palette-row"
role="option"
aria-selected={at === active}
data-active={at === active ? "" : undefined}
data-kind={row.kind}
onPointerMove={() => setIndex(at)}
onPointerDown={(e) => {
e.preventDefault();
run(row);
}}
>
<span className="palette-label">
{row.kind === "command" ? row.label : `Create “${parsed.title}”`}
</span>
<span className="palette-keys">
{keys.slice(0, 1).map((key) => (
<kbd className="key" key={key}>
{keyLabel(key)}
</kbd>
))}
</span>
</li>
);
})}
</ul>
)}
</div>
</div>
);
}
export default CommandPalette;
+19
View File
@@ -0,0 +1,19 @@
// The write side of the palette. Kept out of `parse.ts` so the parser stays a pure module with a
// test suite that never touches Tauri.
import { eventCreate } from "../api/events";
import { useCalendarView } from "../store/useCalendarView";
import { notify } from "../store/useToast";
import { toDraft, type Parsed } from "./parse";
export async function createFromInput(parsed: Parsed, calendarId: string): Promise<void> {
const title = parsed.title || "Untitled";
try {
await eventCreate(toDraft(parsed, calendarId));
notify(`Created “${title}”`);
// The write is optimistic in the store, so this is the fastest the grid can show it.
await useCalendarView.getState().load();
} catch (e) {
notify(`Could not create “${title}”: ${e}`);
}
}
+219
View File
@@ -0,0 +1,219 @@
import { describe, expect, it } from "vitest";
import {
defaultCalendar,
defaultStart,
formatDuration,
matchCalendar,
parseEventInput,
previewText,
toDraft,
type CalendarLike,
} from "./parse";
// Monday 10 August 2026, 09:00 local. Every expectation below is relative to this and to nothing
// else, which is why the parser takes `now` rather than reading the clock.
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();
describe("parseEventInput", () => {
it("takes a day, a time and a duration and leaves the rest as the title", () => {
const parsed = parseEventInput("lunch with sam tue 1pm 45m", NOW);
expect(parsed.title).toBe("lunch with sam");
expect(parsed.startMs).toBe(at(11, 13));
expect(parsed.endMs).toBe(at(11, 13, 45));
expect(parsed.minutes).toBe(45);
expect(parsed.allDay).toBe(false);
expect(parsed.dated).toBe(true);
expect(parsed.ambiguous).toBeNull();
});
it("reads a bare time range as both ends", () => {
const parsed = parseEventInput("standup 9-10am", NOW);
expect(parsed.title).toBe("standup");
expect(parsed.startMs).toBe(at(10, 9));
expect(parsed.endMs).toBe(at(10, 10));
expect(parsed.minutes).toBe(60);
expect(parsed.ambiguous).toBeNull();
});
it("reads a spelled range across a day", () => {
const parsed = parseEventInput("dentist friday 3pm to 4:30pm", NOW);
expect(parsed.startMs).toBe(at(14, 15));
expect(parsed.endMs).toBe(at(14, 16, 30));
expect(parsed.minutes).toBe(90);
});
it("pulls #calendar out of the title", () => {
const parsed = parseEventInput("1:1 with dana wed 2pm #work", NOW);
expect(parsed.calendarHint).toBe("work");
expect(parsed.title).toBe("1:1 with dana");
expect(parsed.startMs).toBe(at(12, 14));
});
it("pulls at <place> out of the title", () => {
const parsed = parseEventInput("coffee with jo thu 4pm at blue bottle", NOW);
expect(parsed.location).toBe("blue bottle");
expect(parsed.title).toBe("coffee with jo");
expect(parsed.startMs).toBe(at(13, 16));
});
it("does not mistake a time for a place", () => {
const parsed = parseEventInput("coffee at 5pm", NOW);
expect(parsed.location).toBeNull();
expect(parsed.title).toBe("coffee");
expect(parsed.startMs).toBe(at(10, 17));
});
it("flags an hour that could be am or pm rather than guessing", () => {
const parsed = parseEventInput("standup at 5", NOW);
expect(parsed.ambiguous).toBe("5 could be am or pm");
expect(parsed.title).toBe("standup");
});
it("flags two dates rather than picking one silently", () => {
const parsed = parseEventInput("chat 3pm friday and 4pm monday", NOW);
expect(parsed.ambiguous).toBe("more than one date in that");
});
it("flags a range and a duration together", () => {
const parsed = parseEventInput("review 9-10am 45m", NOW);
expect(parsed.ambiguous).toBe("a range and a duration");
expect(parsed.minutes).toBe(60);
});
it("keeps everything it does not recognise in the title", () => {
const parsed = parseEventInput(
"quarterly planning with the whole team about q4 roadmap next tuesday 10am 90m #work at the big room",
NOW,
);
expect(parsed.title).toBe("quarterly planning with the whole team about q4 roadmap");
expect(parsed.calendarHint).toBe("work");
expect(parsed.location).toBe("the big room");
expect(parsed.startMs).toBe(at(18, 10));
expect(parsed.minutes).toBe(90);
});
it("makes a day with no time an all-day event", () => {
const parsed = parseEventInput("review tue", NOW);
expect(parsed.allDay).toBe(true);
expect(parsed.startMs).toBe(at(11, 0));
expect(parsed.endMs).toBe(at(12, 0));
expect(parsed.minutes).toBe(0);
});
it("honours an explicit all-day flag", () => {
const parsed = parseEventInput("sam is away friday all day", NOW);
expect(parsed.allDay).toBe(true);
expect(parsed.title).toBe("sam is away");
expect(parsed.startMs).toBe(at(14, 0));
});
it("falls back to a slot when nothing is date-like", () => {
const parsed = parseEventInput("buy milk", NOW, at(10, 17));
expect(parsed.dated).toBe(false);
expect(parsed.title).toBe("buy milk");
expect(parsed.startMs).toBe(at(10, 17));
expect(parsed.endMs).toBe(at(10, 18));
expect(parsed.ambiguous).toBeNull();
});
it("reads 1h30 as ninety minutes", () => {
const parsed = parseEventInput("sprint planning monday 10am 1h30", NOW);
expect(parsed.minutes).toBe(90);
expect(parsed.title).toBe("sprint planning");
});
it("drops a dangling preposition left behind by the cut", () => {
const parsed = parseEventInput("catch up with tue 11am", NOW);
expect(parsed.title).toBe("catch up");
});
it("is empty rather than wrong on an empty input", () => {
const parsed = parseEventInput(" ", NOW, at(10, 17));
expect(parsed.title).toBe("");
expect(parsed.dated).toBe(false);
});
});
describe("defaultStart", () => {
it("takes the next half hour on today", () => {
expect(defaultStart(NOW, at(10, 9, 12))).toBe(at(10, 9, 30));
});
it("takes nine in the morning on any other day", () => {
expect(defaultStart(at(14, 0), NOW)).toBe(at(14, 9));
});
});
describe("toDraft", () => {
it("writes a timed event with an offset", () => {
const draft = toDraft(parseEventInput("lunch tue 1pm 45m", NOW), "cal-1");
expect(draft.allDay).toBe(false);
expect(draft.calendarId).toBe("cal-1");
expect(draft.summary).toBe("lunch");
expect(draft.start).toMatch(/^2026-08-11T13:00:00[+-]\d{2}:\d{2}$/);
expect(draft.end).toMatch(/^2026-08-11T13:45:00[+-]\d{2}:\d{2}$/);
});
it("writes an all-day event date-only, with the exclusive end the wire wants", () => {
const draft = toDraft(parseEventInput("review tue", NOW), "cal-1");
expect(draft.allDay).toBe(true);
expect(draft.start).toBe("2026-08-11");
expect(draft.end).toBe("2026-08-12");
});
it("carries the location through", () => {
const draft = toDraft(parseEventInput("coffee thu 4pm at blue bottle", NOW), "cal-1");
expect(draft.location).toBe("blue bottle");
});
});
const calendars: CalendarLike[] = [
{ id: "a", summary: "[email protected]", accessRole: "owner", selected: true, primary: true },
{ id: "b", summary: "Work Projects", accessRole: "writer", selected: true, primary: false },
{ id: "c", summary: "Holidays", accessRole: "reader", selected: true, primary: false },
];
describe("matchCalendar", () => {
it("matches on a prefix, ignoring case and spacing", () => {
expect(matchCalendar("work", calendars)?.id).toBe("b");
expect(matchCalendar("workprojects", calendars)?.id).toBe("b");
});
it("never targets a calendar that cannot be written to", () => {
expect(matchCalendar("holidays", calendars)).toBeNull();
});
it("is null without a hint or without a match", () => {
expect(matchCalendar(null, calendars)).toBeNull();
expect(matchCalendar("garden", calendars)).toBeNull();
});
it("falls back to the primary calendar", () => {
expect(defaultCalendar(calendars)?.id).toBe("a");
expect(defaultCalendar([])).toBeNull();
});
});
describe("previewText", () => {
it("says the date, the time, the duration and the calendar", () => {
const parsed = parseEventInput("lunch with sam tue 1pm 45m at zoe's", NOW);
expect(previewText(parsed, "Work")).toBe(
"“lunch with sam” on Tue 11 Aug, 1pm to 1:45pm, 45m, in Work, at zoe's.",
);
});
it("says all day when there is no time", () => {
expect(previewText(parseEventInput("review tue", NOW), null)).toBe(
"“review” all day on Tue 11 Aug.",
);
});
it("spells a duration in hours and minutes", () => {
expect(formatDuration(45)).toBe("45m");
expect(formatDuration(60)).toBe("1h");
expect(formatDuration(90)).toBe("1h 30m");
});
});
+249
View File
@@ -0,0 +1,249 @@
// Natural language event entry, the thing that makes the palette more than a command list.
//
// Pure by construction: no React, no Tauri, and no clock. `now` is passed in, so the preview the
// user sees and the tests that pin it are the same code path.
//
// The order of the passes is load-bearing. `#calendar`, the all-day flag and the duration come out
// first, because chrono reads `45m` as a quarter to ten and would otherwise invent a second date.
// chrono runs next and its match is cut out by the offsets it reports, never by searching for its
// own text again. `at <place>` runs last on what is left, which is what stops it stealing the `5`
// out of `coffee at 5`. Everything still standing is the title.
import { parse as parseDates } from "chrono-node";
import type { EventDraft } from "../ipc";
import { addDays, dayName, formatTime, monthName, startOfDay, toDateOnly, toOffsetIso } from "../time";
const DEFAULT_MINUTES = 60;
const MIN_MINUTES = 5;
export interface Parsed {
/** Everything the parser did not recognise, whitespace collapsed. */
title: string;
startMs: number;
/** Exclusive. For an all-day event this is local midnight after the last day, as the wire wants. */
endMs: number;
allDay: boolean;
/** Zero for an all-day event. */
minutes: number;
/** The `#work` token without its hash, still unresolved against the calendar list. */
calendarHint: string | null;
location: string | null;
/** Why the parse is not certain, in the words the preview shows. Null when it is certain. */
ambiguous: string | null;
/** False when nothing date-like was recognised and the fallback slot was used. */
dated: boolean;
}
/** The slot a bare title lands in: the next half hour on today, otherwise nine in the morning. */
export function defaultStart(anchor: number, now: number): number {
const sameDay = startOfDay(anchor) === startOfDay(now);
const d = new Date(sameDay ? now : startOfDay(anchor));
if (sameDay) d.setMinutes(Math.ceil(d.getMinutes() / 30) * 30, 0, 0);
else d.setHours(9, 0, 0, 0);
return d.getTime();
}
const CALENDAR = /(?:^|\s)#([a-z0-9][\w-]*)/i;
const ALL_DAY = /(?:^|\s)all[- ]day(?=\s|$)/i;
const HOURS = /(?:^|\s)(?:for\s+)?(\d+(?:\.\d+)?)\s*(?:h|hr|hrs|hour|hours)(?:\s*(\d{1,2})\s*(?:m|min|mins|minute|minutes)?)?(?=\s|$)/i;
const MINUTES = /(?:^|\s)(?:for\s+)?(\d+)\s*(?:m|min|mins|minute|minutes)(?=\s|$)/i;
const LOCATION = /(?:^|\s)at\s+(\S.*)$/i;
// No leading \b: the meridiem in `10am` sits against a digit, which is not a word boundary.
const MERIDIEM_WORD = /(a\.?m|p\.?m)\b|\b(noon|midday|midnight|morning|afternoon|evening|tonight|night)\b/i;
const TRAILING_JOIN = /[\s,;:.\-]*\b(?:on|at|from|by|for|with)$/i;
/** Replaces a span with one space, so cutting the middle of a phrase never welds two words. */
function cut(text: string, start: number, end: number): string {
return `${text.slice(0, start)} ${text.slice(end)}`;
}
function tidy(text: string): string {
const flat = text.replace(/\s+/g, " ").trim();
return flat.replace(TRAILING_JOIN, "").replace(/^[\s,;:.\-]+|[\s,;:.\-]+$/g, "").trim();
}
interface Take {
rest: string;
value: string | null;
}
function takeFirst(text: string, pattern: RegExp, group: number): Take {
const m = pattern.exec(text);
if (!m) return { rest: text, value: null };
const start = m.index + (/^\s/.test(m[0]) ? 1 : 0);
return { rest: cut(text, start, m.index + m[0].length), value: m[group] ?? "" };
}
function takeDuration(text: string): { rest: string; minutes: number | null } {
const hours = HOURS.exec(text);
if (hours) {
const minutes = Math.round(Number(hours[1]) * 60) + Number(hours[2] ?? 0);
const start = hours.index + (/^\s/.test(hours[0]) ? 1 : 0);
return { rest: cut(text, start, hours.index + hours[0].length), minutes };
}
const mins = MINUTES.exec(text);
if (mins) {
const start = mins.index + (/^\s/.test(mins[0]) ? 1 : 0);
return { rest: cut(text, start, mins.index + mins[0].length), minutes: Number(mins[1]) };
}
return { rest: text, minutes: null };
}
/**
* @param now Reference instant, in epoch milliseconds. Everything relative resolves against it.
* @param fallbackStart Where an undated title lands. Defaults to the next half hour after `now`.
*/
export function parseEventInput(text: string, now: number, fallbackStart?: number): Parsed {
const calendar = takeFirst(text, CALENDAR, 1);
const allDayFlag = takeFirst(calendar.rest, ALL_DAY, 0);
const duration = takeDuration(allDayFlag.rest);
const results = parseDates(duration.rest, { instant: new Date(now) }, { forwardDate: true });
const hit = results[0];
const remainder = hit ? cut(duration.rest, hit.index, hit.index + hit.text.length) : duration.rest;
const place = takeFirst(remainder, LOCATION, 1);
const timed = hit ? hit.start.isCertain("hour") : false;
const allDay = allDayFlag.value !== null || (hit ? !timed : false);
const rangeEnd = hit?.end ? hit.end.date().getTime() : null;
let startMs: number;
let endMs: number;
let minutes = 0;
if (!hit) {
startMs = fallbackStart ?? defaultStart(now, now);
if (allDay) {
startMs = startOfDay(startMs);
endMs = addDays(startMs, 1);
} else {
minutes = duration.minutes ?? DEFAULT_MINUTES;
endMs = startMs + minutes * 60_000;
}
} else if (allDay) {
startMs = startOfDay(hit.date().getTime());
endMs = addDays(startOfDay(rangeEnd ?? startMs), 1);
} else {
startMs = hit.date().getTime();
const fromRange = rangeEnd === null ? null : Math.round((rangeEnd - startMs) / 60_000);
minutes = Math.max(MIN_MINUTES, fromRange ?? duration.minutes ?? DEFAULT_MINUTES);
endMs = startMs + minutes * 60_000;
}
return {
title: tidy(place.rest),
startMs,
endMs,
allDay,
minutes: allDay ? 0 : minutes,
calendarHint: calendar.value,
location: place.value ? tidy(place.value) || null : null,
ambiguous: reasonFor(results, hit, duration.minutes, allDay),
dated: hit !== undefined,
};
}
type Hit = ReturnType<typeof parseDates>[number];
/**
* Ambiguity is reported, never resolved by guessing. `9-10am` is not ambiguous because the end
* carries the meridiem for both, but `at 5` is, and saying so is the whole point.
*/
function reasonFor(
results: readonly Hit[],
hit: Hit | undefined,
duration: number | null,
allDay: boolean,
): string | null {
if (results.length > 1) return "more than one date in that";
if (!hit) return null;
if (!allDay && !hit.start.isCertain("meridiem") && !MERIDIEM_WORD.test(hit.text)) {
const hour = hit.start.get("hour");
if (hour !== null && hour >= 1 && hour <= 11 && !hit.end?.isCertain("meridiem")) {
return `${hour} could be am or pm`;
}
}
if (hit.end && duration !== null) return "a range and a duration";
return null;
}
export function formatDuration(minutes: number): string {
const h = Math.floor(minutes / 60);
const m = minutes % 60;
if (h === 0) return `${m}m`;
return m === 0 ? `${h}h` : `${h}h ${m}m`;
}
/** `Tue 11 Aug`. No year: the user is looking at a date they just typed. */
export function shortDate(ms: number): string {
return `${dayName(ms)} ${new Date(ms).getDate()} ${monthName(ms).slice(0, 3)}`;
}
/** The plain sentence under the input. `calendarName` is null when nothing is connected. */
export function previewText(parsed: Parsed, calendarName: string | null): string {
const title = parsed.title || "Untitled";
const when = parsed.allDay
? `all day on ${allDaySpan(parsed)}`
: `on ${shortDate(parsed.startMs)}, ${formatTime(parsed.startMs)} to ${formatTime(parsed.endMs)}` +
`, ${formatDuration(parsed.minutes)}`;
const parts = [`“${title}” ${when}`];
if (calendarName) parts.push(`in ${calendarName}`);
if (parsed.location) parts.push(`at ${parsed.location}`);
return `${parts.join(", ")}.`;
}
function allDaySpan(parsed: Parsed): string {
const last = addDays(parsed.endMs, -1);
return startOfDay(last) === startOfDay(parsed.startMs)
? shortDate(parsed.startMs)
: `${shortDate(parsed.startMs)} to ${shortDate(last)}`;
}
/** Built with `toOffsetIso` and `toDateOnly` so an all-day boundary never crosses a zone. */
export function toDraft(parsed: Parsed, calendarId: string): EventDraft {
const base = {
calendarId,
summary: parsed.title || "Untitled",
location: parsed.location,
allDay: parsed.allDay,
};
return parsed.allDay
? { ...base, start: toDateOnly(parsed.startMs), end: toDateOnly(parsed.endMs) }
: { ...base, start: toOffsetIso(parsed.startMs), end: toOffsetIso(parsed.endMs) };
}
export interface CalendarLike {
id: string;
summary: string;
accessRole: string;
selected: boolean;
primary: boolean;
}
export const writable = (calendar: CalendarLike): boolean =>
calendar.accessRole === "owner" || calendar.accessRole === "writer";
const fold = (value: string): string => value.toLowerCase().replace(/[^a-z0-9]/g, "");
/** `#work` against the calendar list: exact fold, then prefix, then substring. Writable only. */
export function matchCalendar(
hint: string | null,
calendars: readonly CalendarLike[],
): CalendarLike | null {
if (!hint) return null;
const needle = fold(hint);
if (!needle) return null;
const options = calendars.filter(writable);
return (
options.find((c) => fold(c.summary) === needle) ??
options.find((c) => fold(c.summary).startsWith(needle)) ??
options.find((c) => fold(c.summary).includes(needle)) ??
null
);
}
/** Where an event goes when nothing said otherwise: the primary calendar, else anything writable. */
export function defaultCalendar(calendars: readonly CalendarLike[]): CalendarLike | null {
const options = calendars.filter(writable);
return options.find((c) => c.primary && c.selected) ?? options.find((c) => c.primary) ?? options[0] ?? null;
}
+28
View File
@@ -0,0 +1,28 @@
// What the palette is holding between keystrokes. It lives here rather than in `src/store/`
// because nothing outside the palette has any business reading a half-typed query.
import { create } from "zustand";
interface PaletteState {
query: string;
/** Highlighted row. Clamped against the row count on every move, never trusted blindly. */
index: number;
setQuery: (query: string) => void;
setIndex: (index: number) => void;
move: (delta: number, count: number) => void;
reset: () => void;
}
export const usePalette = create<PaletteState>((set) => ({
query: "",
index: 0,
setQuery: (query) => set({ query, index: 0 }),
setIndex: (index) => set({ index }),
move: (delta, count) =>
set((s) => {
if (count === 0) return {};
const at = Math.min(Math.max(s.index, 0), count - 1);
return { index: (at + delta + count) % count };
}),
reset: () => set({ query: "", index: 0 }),
}));
+37
View File
@@ -0,0 +1,37 @@
/**
* The phone chrome pads with --safe-top and --safe-bottom, which are env(safe-area-inset-*) by
* default. On iOS that is the right answer. Android's WebView reads those from the display cutout
* alone, so a phone whose status and navigation bars are ordinary bars rather than a notch reports
* 0 for both and the bars sit on top of the chrome. MainActivity measures the bars for real and
* offers them here; this copies them onto the root, where they win over the env() defaults.
*
* Everywhere else the bridge is absent and the tokens keep their env() values untouched.
*/
interface SafeAreaBridge {
top(): number;
bottom(): number;
}
declare global {
interface Window {
__androidSafeArea?: SafeAreaBridge;
}
}
export function trackSafeArea(): void {
const write = () => {
const bridge = window.__androidSafeArea;
if (!bridge) return;
// The bridge counts device pixels because that is what the platform hands it, and the page is
// laid out in CSS pixels.
const ratio = window.devicePixelRatio || 1;
const root = document.documentElement.style;
root.setProperty("--safe-top", `${bridge.top() / ratio}px`);
root.setProperty("--safe-bottom", `${bridge.bottom() / ratio}px`);
};
write();
// Fired by MainActivity whenever the insets are applied again, which is a rotation, a keyboard,
// or the navigation mode changing from a 24dp gesture pill to a 48dp button bar under the app.
window.addEventListener("androidsafearea", write);
}
+113
View File
@@ -0,0 +1,113 @@
import { create } from "zustand";
import { openUrl } from "@tauri-apps/plugin-opener";
import { accountConnect, accountDisconnect, accountsList } from "../api/accounts";
import { calendarSetSelected, calendarsList } from "../api/calendars";
import { live, type Account, type Calendar } from "../ipc";
import { notify } from "./useToast";
type Phase = "idle" | "connecting" | "working" | "error";
interface AccountsState {
accounts: Account[];
calendars: Calendar[];
phase: Phase;
error: string | null;
authUrl: string | null;
resolveConnect: ((ok: boolean) => void) | null;
refresh: () => Promise<void>;
connect: () => Promise<boolean>;
cancelConnect: () => void;
handleAuthEvent: (ok: boolean, error: string | null) => Promise<void>;
openAuthUrl: () => void;
copyAuthUrl: () => Promise<void>;
disconnect: (accountId: string) => Promise<void>;
setSelected: (calendarId: string, selected: boolean) => Promise<void>;
}
export const useAccounts = create<AccountsState>((set, get) => ({
accounts: [],
calendars: [],
phase: "idle",
error: null,
authUrl: null,
resolveConnect: null,
refresh: async () => {
if (!live()) return;
try {
const [accounts, calendars] = await Promise.all([accountsList(), calendarsList()]);
set({ accounts, calendars });
} catch (e) {
set({ error: String(e) });
}
},
// The promise holds its own resolver so a later Tauri `auth` event can settle it. Ported from
// margin/src/store/useBackup.ts:70.
connect: () =>
new Promise<boolean>((resolve) => {
get().resolveConnect?.(false);
set({ phase: "connecting", error: null, authUrl: null, resolveConnect: resolve });
accountConnect()
.then((url) => set({ authUrl: url }))
.catch((e) => {
set({ phase: "error", error: String(e), resolveConnect: null });
notify(`Could not connect: ${e}`);
resolve(false);
});
}),
cancelConnect: () => {
const resolve = get().resolveConnect;
set({ phase: "idle", authUrl: null, resolveConnect: null });
resolve?.(false);
},
handleAuthEvent: async (ok, error) => {
if (get().phase !== "connecting") return;
const resolve = get().resolveConnect;
if (ok) {
await get().refresh();
set({ phase: "idle", authUrl: null, error: null, resolveConnect: null });
notify("Connected to Google Calendar");
} else {
const message = error ?? "authorization failed";
set({ phase: "error", authUrl: null, error: message, resolveConnect: null });
notify(`Could not connect: ${message}`);
}
resolve?.(ok);
},
openAuthUrl: () => {
const url = get().authUrl;
if (url) openUrl(url).catch(() => {});
},
copyAuthUrl: async () => {
const url = get().authUrl;
if (!url) return;
try {
await navigator.clipboard.writeText(url);
notify("Authorization link copied");
} catch {
notify("Could not copy the link");
}
},
disconnect: async (accountId) => {
set({ phase: "working", error: null });
try {
await accountDisconnect(accountId);
await get().refresh();
set({ phase: "idle" });
notify("Disconnected");
} catch (e) {
set({ phase: "error", error: String(e) });
notify(`Could not disconnect: ${e}`);
}
},
setSelected: async (calendarId, selected) => {
set((s) => ({
calendars: s.calendars.map((c) => (c.id === calendarId ? { ...c, selected } : c)),
}));
try {
await calendarSetSelected(calendarId, selected);
} catch (e) {
await get().refresh();
notify(`Could not update the calendar: ${e}`);
}
},
}));
+116
View File
@@ -0,0 +1,116 @@
import { create } from "zustand";
import { instancesRange } from "../api/instances";
import { live, type Instance, type InstanceKey } from "../ipc";
import { addDays, startOfDay, startOfWeek, today, weekAnchor } from "../time";
export type View = "day" | "week" | "agenda";
const VIEW_KEY = "margincal-view";
function initialView(): View {
const attr = document.documentElement.getAttribute("data-view");
if (attr === "day" || attr === "week" || attr === "agenda") return attr;
const saved = localStorage.getItem(VIEW_KEY);
return saved === "day" || saved === "agenda" ? saved : "week";
}
/** The half-open span the view is showing, in epoch milliseconds. */
export function spanFor(view: View, anchor: number): { from: number; to: number } {
if (view === "day") return { from: anchor, to: addDays(anchor, 1) };
if (view === "agenda") return { from: anchor, to: addDays(anchor, 30) };
const from = weekAnchor(anchor);
return { from, to: addDays(from, 7) };
}
function sameKey(a: InstanceKey | null, b: InstanceKey | null): boolean {
if (!a || !b) return a === b;
return a.eventId === b.eventId && a.originalStart === b.originalStart;
}
interface CalendarViewState {
view: View;
anchor: number;
instances: Instance[];
phase: "idle" | "loading" | "error";
error: string | null;
selected: InstanceKey | null;
setView: (view: View) => void;
/** Raw. Slides the window without re-deciding where a week begins. */
setAnchor: (anchor: number) => void;
/** Shows the week containing that day, parked on the configured start of the week. */
jumpTo: (ms: number) => void;
goToday: () => void;
moveDay: (delta: number) => void;
moveWeek: (delta: number) => void;
select: (key: InstanceKey | null) => void;
selectAdjacent: (delta: number) => void;
load: () => Promise<void>;
}
export const useCalendarView = create<CalendarViewState>((set, get) => ({
view: initialView(),
// Week view opens on the configured start of the week, not on today, so a week always begins on
// the same day. The arrows and h/l slide it from there as a temporary look.
anchor: initialView() === "week" ? startOfWeek(today()) : today(),
instances: [],
phase: "idle",
error: null,
selected: null,
setView: (view) => {
localStorage.setItem(VIEW_KEY, view);
document.documentElement.setAttribute("data-view", view);
// Entering the week re-parks on a boundary, so a slide left over from day navigation does not
// decide where the week starts.
const anchor = view === "week" ? startOfWeek(get().anchor) : get().anchor;
set({ view, anchor });
void get().load();
},
setAnchor: (anchor) => {
set({ anchor: startOfDay(anchor) });
void get().load();
},
goToday: () => get().jumpTo(today()),
jumpTo: (ms) => get().setAnchor(get().view === "week" ? startOfWeek(ms) : startOfDay(ms)),
moveDay: (delta) => get().setAnchor(addDays(get().anchor, delta)),
moveWeek: (delta) => get().setAnchor(addDays(get().anchor, delta * 7)),
select: (key) => set({ selected: key }),
selectAdjacent: (delta) => {
const { instances, selected } = get();
// Day first, then all-day before timed within that day, matching what both the agenda and the
// grid put on screen. Filtering all-day out here would make j and k skip rows the user is
// looking at.
const ordered = instances
.filter((i) => i.status !== "cancelled")
.slice()
.sort(
(a, b) =>
startOfDay(a.startMs) - startOfDay(b.startMs) ||
Number(b.allDay) - Number(a.allDay) ||
a.startMs - b.startMs ||
a.eventId.localeCompare(b.eventId),
);
if (ordered.length === 0) return;
const at = ordered.findIndex((i) =>
sameKey({ eventId: i.eventId, originalStart: i.originalStart }, selected),
);
const next = at === -1 ? (delta > 0 ? 0 : ordered.length - 1) : at + delta;
const target = ordered[Math.max(0, Math.min(ordered.length - 1, next))];
set({ selected: { eventId: target.eventId, originalStart: target.originalStart } });
},
load: async () => {
if (!live()) return;
const { view, anchor } = get();
// A day of padding either side, so a block that starts before the span still renders.
const { from, to } = spanFor(view, anchor);
set({ phase: "loading", error: null });
try {
const instances = await instancesRange(addDays(from, -1), addDays(to, 1));
// Ignore a response that landed after the view moved on.
const now = get();
if (now.view !== view || now.anchor !== anchor) return;
set({ instances, phase: "idle" });
} catch (e) {
set({ phase: "error", error: String(e) });
}
},
}));
+64
View File
@@ -0,0 +1,64 @@
import { create } from "zustand";
/**
* Which overlay is on screen. Nothing here is resident: an overlay is summoned by a key or a
* hover and dismissed with Escape. Only one is open at a time, so the keymap's context stack has
* exactly one frame to shadow the grid's bindings with.
*
* `trail` is where you came from. Settings opens Accounts, and without it that panel is a dead end
* whose only way out is closing everything and starting again.
*/
export type Overlay =
| "palette"
| "search"
| "calendars"
| "mini-month"
| "editor"
| "accounts"
| "settings"
| "shortcuts"
/** The phone's overflow sheet. A desktop reaches all of this from the header row or a key. */
| "menu";
interface OverlayState {
open: Overlay | null;
/** The panels behind this one, outermost first. Empty when the current one was opened directly. */
trail: Overlay[];
/** Opens one on its own. Anything you were in is gone, not remembered. */
show: (overlay: Overlay) => void;
/** Opens one from inside another, so `back` can return to it. */
push: (overlay: Overlay) => void;
/** Returns to the panel that opened this one, or closes when there is nowhere to go. */
back: () => void;
/**
* Records where the panel that is already open was reached from.
*
* `push` cannot do this job for a panel opened by a command, because a command calls `show` and
* has no idea anything summoned it. The phone menu is exactly that case: it dispatches the same
* commands a key does, so without this, closing Settings from the menu drops you on the grid
* rather than back at the menu you were reading a moment ago.
*/
reachedFrom: (previous: Overlay) => void;
toggle: (overlay: Overlay) => void;
close: () => void;
}
export const useOverlays = create<OverlayState>((set) => ({
open: null,
trail: [],
show: (overlay) => set({ open: overlay, trail: [] }),
push: (overlay) =>
set((s) => (s.open === null ? { open: overlay, trail: [] } : { open: overlay, trail: [...s.trail, s.open] })),
back: () =>
set((s) => {
const trail = s.trail.slice();
const previous = trail.pop() ?? null;
return { open: previous, trail };
}),
// Nothing to go back to when nothing is open, and a panel is not its own way out.
reachedFrom: (previous) =>
set((s) => (s.open === null || s.open === previous ? {} : { trail: [previous] })),
toggle: (overlay) =>
set((s) => (s.open === overlay ? { open: null, trail: [] } : { open: overlay, trail: [] })),
close: () => set({ open: null, trail: [] }),
}));
+47
View File
@@ -0,0 +1,47 @@
// Search reads a corpus of its own rather than whatever the current view happens to have loaded.
// The view's span is nine days in week view, which is narrow enough that search reads as broken.
//
// There is no search command in the IPC contract and the contract is frozen, so this widens the
// window through the existing `instances_range` instead. It is one fetch when the overlay opens,
// held until something writes to the store.
import { create } from "zustand";
import { instancesRange } from "../api/instances";
import { live, type Instance } from "../ipc";
import { addDays, today } from "../time";
/** People search forward more than back, so the window is not symmetric. */
const DAYS_BACK = 180;
const DAYS_FORWARD = 365;
interface SearchState {
instances: Instance[];
phase: "idle" | "loading" | "error";
error: string | null;
loadedAt: number | null;
/** Fetches once and holds. Call on every open; it is cheap when the corpus is warm. */
ensure: () => Promise<void>;
invalidate: () => void;
}
export const useSearch = create<SearchState>((set, get) => ({
instances: [],
phase: "idle",
error: null,
loadedAt: null,
ensure: async () => {
if (!live() || get().phase === "loading" || get().loadedAt !== null) return;
const anchor = today();
set({ phase: "loading", error: null });
try {
const instances = await instancesRange(
addDays(anchor, -DAYS_BACK),
addDays(anchor, DAYS_FORWARD),
);
set({ instances, phase: "idle", loadedAt: Date.now() });
} catch (e) {
set({ phase: "error", error: String(e) });
}
},
invalidate: () => set({ loadedAt: null }),
}));
+47
View File
@@ -0,0 +1,47 @@
import { create } from "zustand";
import { syncNow, syncStatus } from "../api/sync";
import { live, type SyncStatus } from "../ipc";
import { notify } from "./useToast";
interface SyncState {
phase: SyncStatus["phase"];
lastSync: number | null;
error: string | null;
pendingWrites: number;
message: string | null;
apply: (status: SyncStatus) => void;
refresh: () => Promise<void>;
run: (silent?: boolean) => Promise<void>;
}
export const useSync = create<SyncState>((set, get) => ({
phase: "idle",
lastSync: null,
error: null,
pendingWrites: 0,
message: null,
apply: (status) =>
set({
phase: status.phase,
lastSync: status.lastSync,
error: status.error,
pendingWrites: status.pendingWrites,
message: status.message,
}),
refresh: async () => {
if (!live()) return;
try {
get().apply(await syncStatus());
} catch {}
},
run: async (silent = false) => {
if (!live() || get().phase === "syncing") return;
set({ phase: "syncing", error: null });
try {
get().apply(await syncNow());
} catch (e) {
set({ phase: "error", error: String(e) });
if (!silent) notify(`Sync failed: ${e}`);
}
},
}));
+16
View File
@@ -0,0 +1,16 @@
import { create } from "zustand";
import { applyTheme, initialTheme, type Theme } from "../theme";
interface ThemeState {
theme: Theme;
toggle: () => void;
}
export const useTheme = create<ThemeState>((set, get) => ({
theme: initialTheme(),
toggle: () => {
const next: Theme = get().theme === "dark" ? "light" : "dark";
applyTheme(next);
set({ theme: next });
},
}));
+15
View File
@@ -0,0 +1,15 @@
import { create } from "zustand";
interface ToastState {
message: string | null;
notice: (message: string) => void;
dismiss: () => void;
}
export const useToast = create<ToastState>((set) => ({
message: null,
notice: (message) => set({ message }),
dismiss: () => set({ message: null }),
}));
export const notify = (message: string) => useToast.getState().notice(message);
+336
View File
@@ -0,0 +1,336 @@
/* The agenda, and search results, which are the same list. There is no time axis here at all: a
row says when in words, so nothing is measured, nothing is solved, and the list is free to be as
long as the span. It is the one part of the app that scrolls.
Surfaces, the coloured edge and the status rules are the grid's, so the two views read as one
product rather than two. */
.agenda {
/* Agenda-local geometry. The shared block tokens live in tokens.css. */
--agenda-w: 900px;
--agenda-time-w: 124px;
--agenda-row-h: 24px;
--agenda-gap-h: 22px;
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
background: var(--paper);
}
:root[data-compact] .agenda {
--agenda-time-w: 96px;
--agenda-row-h: 22px;
}
.agenda-scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
}
.agenda-inner {
width: min(var(--agenda-w), 100%);
margin: 0 auto;
padding: 0 16px 48px;
}
.agenda-note {
margin: 0;
padding: 14px 12px 4px;
color: var(--ink-faint);
font-size: var(--t-2);
}
/* Day heading
--------------------------------------------------------------------------------------------- */
.agenda-day-head {
position: sticky;
top: 0;
z-index: 2;
display: flex;
align-items: baseline;
gap: 8px;
margin: 0;
padding: 13px 4px 4px;
background: var(--paper);
border-bottom: 1px solid var(--line);
font-weight: 400;
color: var(--ink-soft);
}
.agenda-day-name {
font-size: var(--t-2);
text-transform: lowercase;
letter-spacing: 0.02em;
}
.agenda-day-date {
font-family: var(--font-heading);
font-size: var(--t-4);
font-variant-numeric: tabular-nums;
color: var(--ink);
}
.agenda-day-month {
font-size: var(--t-2);
color: var(--ink-faint);
}
.agenda-day-rule {
flex: 1;
height: 1px;
align-self: center;
background: var(--line);
}
.agenda-day-count {
flex: none;
padding: 1px 6px;
border-radius: var(--r-sm);
background: var(--accent-wash);
color: var(--ink-soft);
font-size: var(--t-1);
font-variant-numeric: tabular-nums;
}
/* Today is the one heading that carries the accent, same pill the day bar uses. */
.agenda-day[data-today] .agenda-day-date {
padding: 1px 7px 2px;
border-radius: var(--r-lg);
background: var(--accent);
color: var(--accent-contrast);
}
/* What has already happened is still worth seeing, just not worth looking at. */
.agenda-day[data-past] .agenda-day-name,
.agenda-day[data-past] .agenda-day-date,
.agenda-day[data-past] .agenda-day-count {
color: var(--ink-faint);
}
/* Rows
--------------------------------------------------------------------------------------------- */
.agenda-rows {
display: flex;
flex-direction: column;
padding: 3px 0 12px;
}
/* The row is filled with its calendar's colour, the same as a block on the grid, a step further up
the lightness scale and with less of the hue's chroma: a row is several times the area of a
block, and a fortnight of them at the grid's strength is the wall of highlighter this view exists
to avoid. Everything the two numbers feed is derived in grid.css, which is where the two views
agree on what a calendar's colour means. */
.agenda-row {
--cal-fill: hsl(var(--cal-h) calc(46% * var(--cal-sat)) calc(91.5% + var(--cal-lift) * var(--cal-lift-step)));
--cal-fill-strong: hsl(var(--cal-h) calc(48% * var(--cal-sat)) calc(87.5% + var(--cal-lift) * var(--cal-lift-step)));
--cal-fill-quiet: hsl(var(--cal-h) calc(40% * var(--cal-sat)) calc(94% + var(--cal-lift) * var(--cal-lift-step)));
--cal-wash: hsl(var(--cal-h) calc(52% * var(--cal-sat)) 97.5%);
display: grid;
grid-template-columns: var(--agenda-time-w) minmax(0, 1fr);
align-items: baseline;
gap: 10px;
width: 100%;
min-height: var(--agenda-row-h);
padding: 3px 8px 3px 10px;
text-align: left;
background: var(--cal-fill);
outline: 1px solid var(--cal-line-quiet);
outline-offset: -1px;
border-radius: var(--r-sm);
color: var(--cal-ink);
transition: background 120ms var(--ease), box-shadow 120ms var(--ease);
}
:root[data-theme="dark"] .agenda-row {
--cal-fill: hsl(var(--cal-h) calc(24% * var(--cal-sat)) calc(19% + var(--cal-lift) * var(--cal-lift-step)));
--cal-fill-strong: hsl(var(--cal-h) calc(26% * var(--cal-sat)) calc(24% + var(--cal-lift) * var(--cal-lift-step)));
--cal-fill-quiet: hsl(var(--cal-h) calc(20% * var(--cal-sat)) calc(15.5% + var(--cal-lift) * var(--cal-lift-step)));
--cal-wash: hsl(var(--cal-h) calc(22% * var(--cal-sat)) 12%);
}
.agenda-row:hover {
background: var(--cal-fill-strong);
}
.agenda-row[data-readonly] {
background: var(--cal-fill-quiet);
}
.agenda-row[data-selected] {
background: var(--cal-fill-strong);
box-shadow: 0 0 0 1px var(--accent);
}
.agenda-row-time {
font-size: var(--t-1);
font-variant-numeric: tabular-nums;
color: var(--cal-ink-soft);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.agenda-row[data-allday] .agenda-row-time {
letter-spacing: 0.02em;
}
.agenda-row-text {
display: flex;
align-items: baseline;
gap: 9px;
min-width: 0;
}
.agenda-row-title {
flex: 0 1 auto;
min-width: 0;
font-size: var(--t-3);
line-height: 1.3;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.agenda-row-where {
flex: 0 5 auto;
min-width: 0;
font-size: var(--t-1);
/* The row is a coloured surface now, so the quiet layer is the block's own quiet ink. A neutral
grey that was readable on paper is not readable on this. */
color: var(--cal-ink-soft);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Not confirmed yet: the fill drops back to a wash and the rim breaks, same as on the grid. */
.agenda-row[data-status="tentative"] {
background: var(--cal-wash);
outline-style: dashed;
outline-color: var(--cal-line);
}
.agenda-row[data-status="tentative"] .agenda-row-title {
color: var(--cal-ink-soft);
}
/* Written locally, still in the outbox, so it is provisional in a way tentative is not: filled,
because the hour is real, with a broken rim and italic type because the event is not whole. */
.agenda-row[data-pending] {
outline-style: dotted;
outline-color: var(--cal-line);
}
.agenda-row[data-pending] .agenda-row-title {
font-style: italic;
color: var(--cal-ink-soft);
}
.agenda-row[data-pending] .agenda-row-time {
font-style: italic;
}
/* What has already happened keeps its colour, drawn at the strength somebody else's calendar is
drawn at. Greying the type instead would put a neutral on a coloured surface. */
.agenda-day[data-past] .agenda-row {
background: var(--cal-fill-quiet);
}
.agenda-day[data-past] .agenda-row-title {
color: var(--cal-ink-soft);
}
/* Empty days
--------------------------------------------------------------------------------------------- */
.agenda-gap {
display: flex;
align-items: center;
gap: 9px;
min-height: var(--agenda-gap-h);
padding: 0 4px;
color: var(--ink-faint);
font-size: var(--t-1);
}
.agenda-gap[data-past] {
color: var(--line-strong);
}
.agenda-gap-dates {
flex: none;
font-variant-numeric: tabular-nums;
}
.agenda-gap-rule {
flex: 1;
height: 1px;
background: var(--line);
}
.agenda-gap-text {
flex: none;
}
/* Search
--------------------------------------------------------------------------------------------- */
.agenda-search {
--agenda-w: 100%;
width: min(680px, calc(100vw - 32px));
max-height: 100%;
flex: none;
border: 1px solid var(--line);
border-radius: var(--r-lg);
box-shadow: var(--shadow-pop);
overflow: hidden;
}
.agenda-search .agenda-inner {
padding: 0 12px 16px;
}
.agenda-search-field {
flex: none;
display: flex;
align-items: center;
gap: 10px;
padding: 14px 18px;
border-bottom: 1px solid var(--line);
}
.agenda-search-input {
flex: 1;
min-width: 0;
padding: 0;
border: 0;
background: none;
outline: none;
font-size: var(--t-4);
}
.agenda-search-input::placeholder {
color: var(--ink-faint);
}
.agenda-search-count {
flex: none;
color: var(--ink-faint);
font-size: var(--t-1);
font-variant-numeric: tabular-nums;
}
.agenda-mark {
padding: 0 1px;
border-radius: var(--r-sm);
background: var(--accent-wash);
color: var(--ink);
}
+469
View File
@@ -0,0 +1,469 @@
* {
box-sizing: border-box;
}
html,
body,
#root {
height: 100%;
}
html {
-webkit-text-size-adjust: 100%;
text-size-adjust: 100%;
}
body {
margin: 0;
position: fixed;
inset: 0;
overflow: hidden;
overscroll-behavior: none;
background: var(--shell);
color: var(--ink);
font-family: var(--font-ui);
font-size: var(--t-3);
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
::selection {
background: var(--selection);
}
/* A webview reads every tap as the start of a text selection: a grey flash over the button, a
callout on a long press, and a caret in whatever it decided you meant. None of that belongs on
chrome. All of it belongs in a field, so the fields ask for it back. */
html {
-webkit-tap-highlight-color: transparent;
}
:root[data-touch] body {
-webkit-user-select: none;
user-select: none;
-webkit-touch-callout: none;
}
:root[data-touch] input,
:root[data-touch] textarea,
:root[data-touch] [contenteditable] {
-webkit-user-select: text;
user-select: text;
-webkit-touch-callout: default;
}
/* iOS Safari zooms the page in on a focused field under 16px, and the viewport is locked, so
there is no gesture that undoes it. */
:root[data-phone] input,
:root[data-phone] textarea,
:root[data-phone] select {
font-size: var(--t-5);
}
button {
font-family: inherit;
color: inherit;
border: 0;
background: none;
cursor: pointer;
padding: 0;
}
input,
textarea,
select {
font-family: inherit;
font-size: inherit;
color: inherit;
}
:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
/* The grid owns the window: one header row, no permanent sidebar, nothing else resident. */
.app {
display: flex;
flex-direction: column;
/* Fill whatever the layout viewport turns out to be, rather than naming a unit that claims to
know it. html, body and #root are already exactly that box, so this inherits the right answer
on every platform without being told.
Deliberately not a viewport unit. UIKit shrinks that box by the safe areas while leaving it
anchored at y 0, and `stop_uikit_shrinking_the_viewport` in lib.rs is the only thing stopping
it. Should that ever regress, a unit naming the whole screen would lay the tab bar out below
the clip `body { position: fixed }` imposes, where it is not merely misplaced but invisible
and untappable. `100%` cannot drift that way. */
height: 100%;
overflow: hidden;
}
.titlebar {
flex: none;
position: relative;
z-index: 45;
height: var(--titlebar-h);
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
padding: 0 14px 0 var(--traffic-pad);
background: var(--shell);
border-bottom: 1px solid var(--line);
}
.titlebar .lead {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
overflow: hidden;
}
.titlebar .trail {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
}
.icon-button {
display: grid;
place-items: center;
width: 28px;
height: 28px;
border-radius: var(--r-sm);
color: var(--ink-soft);
transition: background 120ms var(--ease), color 120ms var(--ease);
}
.icon-button:hover {
background: var(--accent-wash);
color: var(--ink);
}
.icon-button[data-active="true"] {
background: var(--accent-wash);
color: var(--ink);
}
/* 28px is a pointer's target. A finger is nearer 44, and a tablet gets the desktop layout with
none of the desktop's aim. */
:root[data-touch] .icon-button {
width: var(--touch-h);
height: var(--touch-h);
}
/* Phone chrome
----------------------------------------------------------------------------------------------
The header's three groups do not fit a 390px row, so they split: where you are goes to the top,
what you are looking at goes to the bottom where the thumb already is, and the rest goes behind
the overflow. Both bars are sized for a finger whether or not the pointer says it is one, since
a phone-width window is a phone-shaped layout either way. */
.phonebar {
flex: none;
position: relative;
z-index: 45;
height: calc(var(--phonebar-h) + var(--safe-top));
padding: var(--safe-top) 6px 0;
display: flex;
align-items: center;
gap: 2px;
background: var(--shell);
border-bottom: 1px solid var(--line);
}
.phonebar .icon-button {
flex: none;
width: var(--touch-h);
height: var(--touch-h);
color: var(--ink);
}
/* Takes what the fixed controls leave and trims the range rather than pushing them off the edge. */
.phonebar-range {
flex: 1;
min-width: 0;
height: var(--touch-h);
padding: 0 6px;
border-radius: var(--r-sm);
font-size: var(--t-4);
font-weight: 500;
color: var(--ink);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.phonebar-today {
flex: none;
min-width: var(--touch-h);
height: var(--touch-h);
padding: 0 8px;
border-radius: var(--r-sm);
color: var(--ink-soft);
white-space: nowrap;
}
/* There is no hover to light up, so the press itself is the feedback. */
.phonebar-range:active,
.phonebar-today:active,
.tabbar-view:active {
background: var(--accent-wash);
}
.tabbar {
flex: none;
position: relative;
z-index: 45;
height: calc(var(--tabbar-h) + var(--safe-bottom));
padding: 0 10px var(--safe-bottom);
display: flex;
align-items: center;
gap: 10px;
background: var(--shell);
border-top: 1px solid var(--line);
}
.tabbar-views {
flex: 1;
min-width: 0;
display: flex;
gap: 2px;
padding: 2px;
border-radius: var(--r-md);
background: var(--accent-wash);
}
.tabbar-view {
flex: 1;
min-width: 0;
height: var(--touch-h);
border-radius: var(--r-sm);
font-size: var(--t-3);
color: var(--ink-soft);
transition: background 120ms var(--ease), color 120ms var(--ease);
}
.tabbar-view[data-active="true"] {
background: var(--paper);
color: var(--ink);
}
/* The only action with a permanent button of its own. Everything else is a row in the overflow. */
.tabbar-new {
flex: none;
display: grid;
place-items: center;
width: var(--touch-h);
height: var(--touch-h);
border-radius: var(--r-pill);
background: var(--accent);
color: var(--accent-contrast);
transition: background 120ms var(--ease);
}
.tabbar-new:active {
background: var(--accent-ink);
}
.stage {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
background: var(--paper);
}
/* Overlays, margin's idiom: summoned by a key, dismissed with Escape, never resident. */
.overlay {
position: fixed;
inset: 0;
z-index: 20;
background: var(--scrim);
-webkit-backdrop-filter: blur(2px);
backdrop-filter: blur(2px);
display: grid;
place-items: center;
padding: 40px;
}
.panel {
width: min(480px, calc(100vw - 32px));
max-height: 100%;
display: flex;
flex-direction: column;
background: var(--paper);
border: 1px solid var(--line);
border-radius: var(--r-lg);
box-shadow: var(--shadow-pop);
overflow: hidden;
}
/* On a phone the same panel docks to the bottom edge: a centred box with a 40px moat around it
spends the only screen there is on scrim, and puts its buttons at the far end of the reach. */
:root[data-phone] .overlay {
/* Over the bars rather than under them. The desktop leaves its titlebar lit above the scrim
because the traffic lights live there, but a sheet on the bottom edge would otherwise come up
underneath the tab bar and lose its last row to it. */
z-index: 50;
padding: 0;
place-items: end center;
}
/* Search is the one overlay child that is not a `.panel`: it brings its own surface and its own
width, so all it wants from the phone is to be clear of the home indicator. */
:root[data-phone] .overlay > :not(.panel) {
margin-bottom: var(--safe-bottom);
}
:root[data-phone] .panel {
width: 100%;
max-height: var(--sheet-max-h);
border-width: 1px 0 0;
border-radius: var(--r-lg) var(--r-lg) 0 0;
/* The home indicator sits over the foot of the sheet, so the last row stops above it. */
padding-bottom: var(--safe-bottom);
animation: sheet-up 180ms var(--ease);
}
/* A list that has run out of rows must not start scrolling the page underneath it. */
:root[data-phone] .panel-body {
overscroll-behavior: contain;
}
@keyframes sheet-up {
from {
transform: translateY(100%);
}
}
.panel-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 14px 16px 22px;
border-bottom: 1px solid var(--line);
}
.panel-head h2 {
margin: 0;
font-family: var(--font-heading);
font-weight: 500;
font-size: 18px;
}
.panel-body {
display: flex;
flex-direction: column;
gap: 15px;
padding: 20px 22px 24px;
overflow-y: auto;
}
.panel-foot {
display: flex;
justify-content: flex-end;
gap: 10px;
padding: 14px 22px;
border-top: 1px solid var(--line);
}
.field {
display: flex;
flex-direction: column;
gap: 6px;
}
.field-label {
font-size: var(--t-1);
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--ink-faint);
}
.toast {
position: fixed;
left: 50%;
bottom: 24px;
transform: translateX(-50%);
z-index: 60;
max-width: min(560px, calc(100vw - 48px));
padding: 10px 16px;
background: var(--glass);
-webkit-backdrop-filter: blur(8px);
backdrop-filter: blur(8px);
border: 1px solid var(--line);
border-radius: var(--r-md);
box-shadow: var(--shadow-pop);
color: var(--ink);
white-space: pre-wrap;
}
/* Header. The grid owns the window, so this row is the only persistent chrome there is. */
.header-today,
.header-range {
height: 26px;
padding: 0 9px;
border-radius: var(--r-sm);
color: var(--ink-soft);
white-space: nowrap;
transition: background 120ms var(--ease), color 120ms var(--ease);
}
.header-today:hover,
.header-range:hover {
background: var(--accent-wash);
color: var(--ink);
}
/* At the minimum window size the agenda's range is long enough to overrun its 1fr column and
paint under the view switcher, so it ellipsizes rather than collides. */
.header-range {
font-size: var(--t-4);
color: var(--ink);
font-weight: 500;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.view-switch {
display: flex;
gap: 2px;
padding: 2px;
border-radius: var(--r-md);
background: var(--accent-wash);
}
.view-option {
height: 22px;
padding: 0 11px;
border-radius: var(--r-sm);
font-size: var(--t-2);
color: var(--ink-soft);
transition: background 120ms var(--ease), color 120ms var(--ease);
}
.view-option:hover {
color: var(--ink);
}
.view-option[data-active="true"] {
background: var(--paper);
color: var(--ink);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);
}
.header-hours {
font-size: var(--t-1);
letter-spacing: 0.04em;
color: var(--ink-faint);
white-space: nowrap;
}
:root[data-compact] .header-hours {
display: none;
}
+458
View File
@@ -0,0 +1,458 @@
/* The in-place create card. It hangs off the range that was dragged, so the geometry comes from
the same two custom properties the rest of the grid uses: --days for the column count and --i
for the column this one is on. JS only hands it the offsets it had to measure.
The `.swatch` family at the bottom is the one thing here that is not the card: the full editor
imports this file for it, so the colour choice is the same control in both places.
--qc-w is in tokens.css now, and overridden here: the card grew a description and a palette, and
292px was a column of squeezed rows. */
.quick-create {
position: absolute;
z-index: 6;
left: calc(100% * var(--i) / var(--days));
width: calc(100% / var(--days) - var(--event-gap));
pointer-events: none;
--qc-w: 340px;
}
:root[data-compact] .quick-create {
--qc-w: 276px;
}
/* What the user drew, still visible under the card. */
.quick-create-range {
position: absolute;
inset: 0;
min-height: var(--event-min-h);
background: var(--accent-wash);
border: 1px dashed var(--accent);
border-radius: var(--r-sm);
}
.quick-create-range[data-elsewhere] {
border-style: dotted;
opacity: 0.4;
}
.quick-create-card {
position: absolute;
width: var(--qc-w);
display: flex;
flex-direction: column;
gap: 9px;
padding: 12px 13px 11px;
pointer-events: auto;
user-select: text;
-webkit-user-select: text;
background: var(--paper);
border: 1px solid var(--line);
border-radius: var(--r-md);
box-shadow: var(--shadow-pop);
color-scheme: light;
animation: quick-create-in 120ms var(--ease);
}
:root[data-theme="dark"] .quick-create-card {
color-scheme: dark;
}
/* Measured before it is placed, so it must not be seen at the wrong corner first. Transparent
rather than hidden, because the title field takes focus on that first frame. */
.quick-create-card:not([data-placed]) {
opacity: 0;
}
@keyframes quick-create-in {
from {
opacity: 0;
transform: translateY(-2px);
}
}
.quick-create-title {
width: 100%;
padding: 1px 1px 4px;
border: 0;
border-bottom: 1px solid var(--line);
background: none;
outline: none;
font-size: var(--t-4);
transition: border-color 120ms var(--ease);
}
/* This field is focused the instant the card opens, so the app's focus ring would be on screen
before the user has done anything. It underlines itself instead. */
.quick-create-title:focus-visible {
outline: none;
}
.quick-create-title:focus {
border-bottom-color: var(--accent);
}
.quick-create-title::placeholder {
color: var(--ink-faint);
}
/* When
--------------------------------------------------------------------------------------------- */
/* Wraps rather than squeezing: a narrow window that clips the meridiem off a time field is worse
than one that puts the all-day button on its own line. */
.quick-create-when {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 5px;
}
.quick-create-time {
flex: none;
padding: 3px 5px;
border: 1px solid var(--line-strong);
border-radius: var(--r-sm);
background: var(--raised);
font-size: var(--t-2);
font-variant-numeric: tabular-nums;
transition: border-color 120ms var(--ease);
}
.quick-create-time:hover {
border-color: var(--ink-faint);
}
/* The field is small and the picker is a WebKit lump. Typing is the point here. */
.quick-create-time::-webkit-calendar-picker-indicator {
display: none;
}
.quick-create-to {
color: var(--ink-faint);
font-size: var(--t-1);
}
.quick-create-length {
color: var(--ink-soft);
font-size: var(--t-1);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.quick-create-day {
padding: 2px 6px;
border-radius: var(--r-sm);
background: var(--accent-wash);
color: var(--ink-soft);
font-size: var(--t-1);
white-space: nowrap;
}
.quick-create-day[data-moved] {
color: var(--ink);
}
.quick-create-toggle {
margin-left: auto;
flex: none;
padding: 3px 9px;
border: 1px solid var(--line);
border-radius: var(--r-pill);
color: var(--ink-faint);
font-size: var(--t-1);
transition: background 120ms var(--ease), color 120ms var(--ease), border-color 120ms var(--ease);
}
.quick-create-toggle:hover {
color: var(--ink);
border-color: var(--line-strong);
}
.quick-create-toggle[data-on] {
background: var(--accent-wash);
border-color: var(--line-strong);
color: var(--ink);
}
/* Where
--------------------------------------------------------------------------------------------- */
.quick-create-where {
display: flex;
align-items: center;
gap: 6px;
}
.quick-create-dot {
flex: none;
width: 7px;
height: 7px;
border-radius: var(--r-pill);
background: var(--cal);
}
.quick-create-calendar,
.quick-create-place {
min-width: 0;
flex: 1 1 0;
padding: 3px 6px;
border: 1px solid var(--line-strong);
border-radius: var(--r-sm);
background: var(--raised);
font-size: var(--t-2);
transition: border-color 120ms var(--ease);
}
.quick-create-calendar:hover,
.quick-create-place:hover {
border-color: var(--ink-faint);
}
.quick-create-calendar {
appearance: none;
padding-right: 20px;
background-image: linear-gradient(45deg, transparent 50%, currentcolor 50%),
linear-gradient(135deg, currentcolor 50%, transparent 50%);
background-position: calc(100% - 11px) 10px, calc(100% - 7px) 10px;
background-size: 4px 4px, 4px 4px;
background-repeat: no-repeat;
}
.quick-create-place::placeholder {
color: var(--ink-faint);
}
/* Colour and description
--------------------------------------------------------------------------------------------- */
.quick-create-colour {
display: flex;
align-items: center;
gap: 8px;
}
.quick-create-label {
flex: none;
color: var(--ink-faint);
font-size: var(--t-1);
}
/* Narrow: the label takes its own line rather than pushing two swatches onto a second one, which
is the difference between a palette and a ragged pile of dots. */
:root[data-compact] .quick-create-colour {
flex-direction: column;
align-items: flex-start;
gap: 5px;
}
/* Three rows deep, which is enough to see a paragraph without the card becoming the editor. It
still grows by hand, and the anchor re-measures when it does. */
.quick-create-note {
width: 100%;
min-height: 54px;
padding: 5px 7px;
border: 1px solid var(--line-strong);
border-radius: var(--r-sm);
background: var(--raised);
font-family: inherit;
font-size: var(--t-2);
line-height: 1.45;
resize: vertical;
transition: border-color 120ms var(--ease);
}
.quick-create-note:hover {
border-color: var(--ink-faint);
}
.quick-create-note::placeholder {
color: var(--ink-faint);
}
/* What it will make
--------------------------------------------------------------------------------------------- */
.quick-create-hint,
.quick-create-warning {
margin: 0;
font-size: var(--t-1);
line-height: 1.45;
}
.quick-create-hint {
color: var(--ink-soft);
}
.quick-create-hint code {
padding: 1px 4px;
border-radius: var(--r-sm);
background: var(--accent-wash);
font-family: inherit;
}
.quick-create-warning {
color: var(--danger-ink);
}
/* Foot
--------------------------------------------------------------------------------------------- */
.quick-create-foot {
display: flex;
align-items: center;
gap: 6px;
margin-top: 1px;
padding-top: 8px;
border-top: 1px solid var(--line);
}
.quick-create-link {
margin-right: auto;
display: flex;
align-items: center;
gap: 5px;
padding: 4px 2px;
color: var(--ink-faint);
font-size: var(--t-2);
transition: color 120ms var(--ease);
}
.quick-create-link:hover {
color: var(--ink);
}
.quick-create-button {
display: flex;
align-items: center;
gap: 5px;
padding: 4px 10px;
border-radius: var(--r-sm);
color: var(--ink-soft);
font-size: var(--t-2);
transition: background 120ms var(--ease), color 120ms var(--ease), opacity 120ms var(--ease);
}
.quick-create-button:hover {
background: var(--accent-wash);
color: var(--ink);
}
.quick-create-button[data-variant="primary"] {
background: var(--accent);
color: var(--accent-contrast);
}
.quick-create-button[data-variant="primary"]:hover {
background: var(--accent-ink);
color: var(--accent-contrast);
}
.quick-create-button:disabled,
.quick-create-link:disabled {
opacity: 0.45;
cursor: default;
}
.quick-create-button:disabled:hover,
.quick-create-link:disabled:hover {
background: none;
color: var(--ink-soft);
}
.quick-create-button[data-variant="primary"]:disabled:hover {
background: var(--accent);
color: var(--accent-contrast);
}
.quick-create-key {
padding: 1px 4px;
border-radius: var(--r-sm);
background: var(--accent-wash);
font-family: var(--font-ui);
font-size: var(--t-1);
line-height: 1.3;
}
.quick-create-button[data-variant="primary"] .quick-create-key {
background: var(--selection);
color: var(--accent-contrast);
}
/* Narrow: the keycaps are the first thing that can go. */
:root[data-compact] .quick-create-key {
display: none;
}
/* The editor's when row
---------------------------------------------------------------------------------------------
The two date fields take the slack in that row, and `flex: 0 1 auto` let the time fields give it
to them: at the editor's width they were squeezed to about 48px, which in Chromium is the picker
icon and none of the digits. A time you cannot read is not a field. They hold their content
width now and the dates, which have far more room than they need, do the shrinking. */
.editor .field-row .field-input[type="time"] {
flex: 0 0 auto;
width: auto;
min-width: max-content;
}
/* Swatches
---------------------------------------------------------------------------------------------
Shared with the event editor, which imports this file for them. Twelve dots on one line: the
eleven Google keeps against an event and the one that is not a colour, which is a ring in the
calendar's own hue rather than a filled dot, because following the calendar is not a choice of
paint. They wrap rather than shrink when the surface is too narrow for a line of them. */
.swatches {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 5px;
}
.swatch {
flex: none;
width: 15px;
height: 15px;
padding: 0;
border: 0;
border-radius: var(--r-pill);
background: var(--swatch);
cursor: pointer;
transition: transform 120ms var(--ease), box-shadow 120ms var(--ease);
}
.swatch[data-default] {
background: none;
box-shadow: inset 0 0 0 2px var(--swatch);
}
.swatch:hover {
transform: scale(1.12);
}
/* The ring is drawn in the card's own surface first so it reads as a gap, then in ink. Two shadows
rather than an outline, because an outline on a round button is a square on WebKit. */
.swatch[data-on] {
box-shadow: 0 0 0 2px var(--paper), 0 0 0 3px var(--ink);
}
.swatch[data-default][data-on] {
box-shadow: inset 0 0 0 2px var(--swatch), 0 0 0 2px var(--paper), 0 0 0 3px var(--ink);
}
.swatch:focus-visible {
outline: none;
box-shadow: 0 0 0 2px var(--paper), 0 0 0 3px var(--accent);
}
.swatch[data-default]:focus-visible {
box-shadow: inset 0 0 0 2px var(--swatch), 0 0 0 2px var(--paper), 0 0 0 3px var(--accent);
}
.swatch:disabled {
opacity: 0.45;
cursor: default;
transform: none;
}
+280
View File
@@ -0,0 +1,280 @@
/* The event details card. It is a popover pinned to the block that was clicked, not an overlay:
no backdrop, no centring, and it positions itself in JS because only JS knows where the block
is. Everything visual it needs beyond this file comes from the shell in app.css and the
`.panel-button`, `.scope-*`, `.guest-*` and `.confirm-*` families in overlays.css, which it
shares with the editor rather than restating. */
/* Wide enough for a paragraph rather than a caption: a description is the only thing in here that
is prose, and at 336px a pasted link was four cramped lines. Tall enough to show one without
scrolling, and capped well short of the window so the thing stays a popover pinned to its block
instead of drifting into being a modal. It only grows to what is in it, so an event with a title
and a time is still a short card. */
.details-card {
position: fixed;
z-index: 18;
width: min(408px, calc(100vw - 16px));
max-height: min(560px, calc(100vh - var(--titlebar-h) - 16px));
display: flex;
flex-direction: column;
background: var(--paper);
border: 1px solid var(--line);
border-left: var(--edge-w) solid var(--cal, var(--line-strong));
border-radius: var(--r-lg);
box-shadow: var(--shadow-pop);
overflow: hidden;
outline: none;
/* Invisible for the one frame between mounting and being measured, so it never flashes at 0,0.
Opacity rather than visibility, because a hidden element cannot take the focus. */
opacity: 0;
}
.details-card[data-placed] {
opacity: 1;
animation: details-in 110ms var(--ease);
}
@keyframes details-in {
from {
opacity: 0;
transform: translateY(-2px);
}
}
:root[data-compact] .details-card {
width: min(344px, calc(100vw - 16px));
max-height: min(480px, calc(100vh - var(--titlebar-h) - 16px));
}
/* The one scroll region. The footer is outside it, so however long the description runs, Edit,
Delete and Close are where they were. */
.details-body {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
gap: 10px;
padding: 15px 17px 16px;
overflow-y: auto;
overscroll-behavior: contain;
}
.details-head {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 2px;
}
.details-title {
margin: 0;
font-family: var(--font-heading);
font-weight: 500;
font-size: var(--t-4);
line-height: 1.35;
overflow-wrap: anywhere;
}
.details-badges {
display: flex;
flex-wrap: wrap;
gap: 5px;
}
.details-badge {
padding: 1px 6px;
border-radius: var(--r-sm);
background: var(--accent-wash);
color: var(--ink-faint);
font-size: var(--t-1);
}
/* One icon column, one text column, so the rows line up whatever is in them. */
.details-row {
display: grid;
grid-template-columns: 14px minmax(0, 1fr);
gap: 9px;
align-items: center;
color: var(--ink-soft);
}
.details-row[data-block] {
align-items: start;
}
.details-row[data-block] > svg {
margin-top: 2px;
}
.details-row > svg {
color: var(--ink-faint);
}
.details-dot {
width: 8px;
height: 8px;
margin: 0 3px;
border-radius: 50%;
background: var(--cal, var(--line-strong));
}
.details-text {
margin: 0;
min-width: 0;
color: var(--ink);
font-size: var(--t-2);
line-height: 1.45;
overflow-wrap: anywhere;
}
.details-text[data-soft] {
color: var(--ink-faint);
font-size: var(--t-1);
}
/* The description, which arrives from Google as html and is rendered from a parsed tree rather
than as source. It is the only prose in the card, so it gets the larger of the two type sizes
and a looser line. It does not scroll on its own: a region that scrolls inside a card that also
scrolls is two things to aim at with a trackpad and a half-cut line of text saying neither. */
.details-desc {
min-width: 0;
color: var(--ink);
font-size: var(--t-3);
line-height: 1.55;
/* Pasted Drive URLs are one word two hundred characters long. */
overflow-wrap: anywhere;
}
/* Plain text keeps the spacing it was written with. Html has had its own collapsed already and
carries its structure in elements, where pre-wrap would double every gap in the source. */
.details-desc[data-plain] {
white-space: pre-wrap;
}
.details-desc :is(div, ul, ol) {
margin: 7px 0 0;
}
.details-desc :is(div, ul, ol):first-child {
margin-top: 0;
}
.details-desc :is(ul, ol) {
padding-left: 20px;
}
.details-desc li + li {
margin-top: 3px;
}
.details-desc code {
padding: 0 4px;
border-radius: var(--r-sm);
background: var(--accent-wash);
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.92em;
}
/* A shade heavier than the conference link's underline, because these sit inside a paragraph and
have to be findable in the middle of a sentence rather than on a line of their own. */
.rich-link,
.desc-link {
color: var(--ink);
text-decoration: underline;
text-decoration-color: var(--ink-faint);
text-underline-offset: 2px;
transition: text-decoration-color 120ms var(--ease), color 120ms var(--ease);
}
.rich-link:hover,
.desc-link:hover {
color: var(--accent-ink);
text-decoration-color: currentcolor;
}
.details-link {
justify-self: start;
min-width: 0;
max-width: 100%;
padding: 0;
color: var(--ink);
font-size: var(--t-2);
text-align: left;
text-decoration: underline;
text-decoration-color: var(--line-strong);
text-underline-offset: 3px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
transition: text-decoration-color 120ms var(--ease), color 120ms var(--ease);
}
.details-link:hover {
color: var(--accent-ink);
text-decoration-color: currentcolor;
}
.details-guests {
display: flex;
flex-direction: column;
gap: 5px;
min-width: 0;
}
.details-note {
margin: 0;
color: var(--ink-soft);
font-size: var(--t-2);
line-height: 1.5;
}
.details-foot {
flex: none;
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
border-top: 1px solid var(--line);
background: var(--raised);
}
.details-foot .panel-button[data-side="trail"] {
margin-left: auto;
}
/* The two families borrowed from the editor are sized for a 560px panel. */
.details-card .scope-option {
padding: 8px 11px;
}
.details-card .scope-name {
font-size: var(--t-3);
}
.details-card .guest-list {
max-height: 148px;
}
/* Escape and a click outside both close the card, but neither is discoverable, and the footer
Close sits below a description long enough to scroll. */
.details-close {
position: absolute;
top: 10px;
right: 10px;
z-index: 1;
display: grid;
place-items: center;
width: 26px;
height: 26px;
border-radius: var(--r-sm);
color: var(--ink-faint);
transition: background 120ms var(--ease), color 120ms var(--ease);
}
.details-close:hover {
background: var(--accent-wash);
color: var(--ink);
}
.details-head {
padding-right: 34px;
}
+153
View File
@@ -0,0 +1,153 @@
/* Both directions of a fold, with the mouse.
------------------------------------------------------------------------------------------- */
/* The chip on an empty run. It hangs off the left edge of the columns rather than sitting in the
middle of the run, because the middle of an empty afternoon is where you drag out an event and
a control there would take the gesture the gap exists for. It is also out of the gutter, whose
hour labels are right-aligned into exactly the space a chip would want.
The run itself is inert: the wash and the rule are a preview of the strip, not a target. */
.grid-gap {
/* The one value this layer needs that tokens.css does not have: how brightly an empty run is lit
while you are pointing at it, which is most of a hovered control's wash spread over a band a
hundred times the size. Derived from --accent-wash rather than written as a literal, and it
belongs in tokens.css once that file can move. --fold-surface would have been the literal
preview of the strip, but in the dark palette it is within a shade of the paper and shows
nothing at all. */
--fold-hint: color-mix(in srgb, var(--accent-wash) 60%, transparent);
position: absolute;
z-index: 2;
left: var(--gutter-w);
right: 0;
pointer-events: none;
background: var(--fold-hint);
animation: grid-gap-in 110ms var(--ease);
}
@keyframes grid-gap-in {
from {
opacity: 0;
}
}
.grid-gap-bar {
position: absolute;
top: 50%;
left: 0;
right: 0;
display: flex;
align-items: center;
gap: 9px;
padding-right: 10px;
transform: translateY(-50%);
}
/* Where the strip would land, drawn the way the strip draws its own rule. */
.grid-gap-rule {
flex: 1;
height: 1px;
background: var(--line-strong);
}
/* The one interactive thing on this layer, and the only part of it that is not a preview. */
.grid-gap-fold {
flex: none;
display: inline-flex;
align-items: center;
gap: 4px;
margin-left: 7px;
padding: 2px 8px 2px 6px;
pointer-events: auto;
border: 1px solid var(--line-strong);
border-radius: var(--r-pill);
background: var(--raised);
color: var(--ink-soft);
font-size: var(--t-1);
white-space: nowrap;
transition: background 120ms var(--ease), color 120ms var(--ease),
border-color 120ms var(--ease);
}
.grid-gap-fold:hover {
background: var(--fold-surface);
border-color: var(--fold-line);
color: var(--ink);
}
/* The other direction. The strip is already the button, so this is a label rather than a target,
and it takes its emphasis from the strip it sits on. */
.grid-strip-mark {
flex: none;
display: inline-flex;
align-items: center;
gap: 4px;
color: var(--ink-faint);
white-space: nowrap;
transition: color 120ms var(--ease);
}
.grid-strip:hover .grid-strip-mark {
color: var(--ink);
}
/* The strip already says "12am to 8am" and its whole surface is the button, so the verb is a third
signal for the same thing. The icon stays, and the word waits until you are actually pointing at
it, which is when a first-timer needs telling that a strip is clickable. */
.grid-strip .grid-fold-verb {
max-width: 0;
overflow: hidden;
opacity: 0;
transition: max-width 140ms var(--ease), opacity 140ms var(--ease);
}
.grid-strip:hover .grid-fold-verb,
.grid-strip:focus-visible .grid-fold-verb {
max-width: 6em;
opacity: 1;
}
/* At the smallest window a column is 119px wide and a chip carrying a word is most of it, so the
chip drops to its arrows and keeps the whole phrase in its title. The strip has the width either
way and keeps its verb. */
:root[data-compact] .grid-gap-fold .grid-fold-verb {
display: none;
}
:root[data-compact] .grid-gap-fold {
padding: 2px 5px;
}
/* With a finger
-------------------------------------------------------------------------------------------
Both halves of this file were written for a pointer that can rest on something without pressing
it. A touchscreen has no such pointer: the verb never arrives, and the chip is drawn only for
the run under the cursor, of which there is never one. So both stop waiting to be pointed at.
GridGaps puts a chip on every empty run to match, since there is no hovered run to choose. */
:root[data-touch] .grid-strip .grid-fold-verb {
max-width: 6em;
opacity: 1;
}
:root[data-touch] .grid-strip-mark {
color: var(--ink-soft);
}
/* The run is empty on every visible day, which is what makes it a run, so the chip is over nothing
and can take the width it needs. That is also why this outranks the compact rule above: the word
was dropped there to spare a 119px column, and there is no column under this one to spare. */
:root[data-touch] .grid-gap {
background: none;
animation: none;
}
:root[data-touch] .grid-gap-fold .grid-fold-verb {
display: inline;
}
:root[data-touch] .grid-gap-fold {
min-height: var(--touch-target);
padding: 4px 12px 4px 9px;
}
+31
View File
@@ -0,0 +1,31 @@
@font-face {
font-family: "Hanken Grotesk";
src: url("/fonts/HankenGrotesk-VF.ttf") format("truetype-variations");
font-weight: 100 900;
font-style: normal;
font-display: block;
}
@font-face {
font-family: "Hanken Grotesk";
src: url("/fonts/HankenGrotesk-Italic-VF.ttf") format("truetype-variations");
font-weight: 100 900;
font-style: italic;
font-display: block;
}
@font-face {
font-family: "Literata";
src: url("/fonts/Literata-VF.ttf") format("truetype-variations");
font-weight: 200 900;
font-style: normal;
font-display: block;
}
@font-face {
font-family: "Literata";
src: url("/fonts/Literata-Italic-VF.ttf") format("truetype-variations");
font-weight: 200 900;
font-style: italic;
font-display: block;
}
+901
View File
@@ -0,0 +1,901 @@
/* The week and day grid. Geometry comes from the tokens and from two custom properties the fit
writes on `.grid`: --row-h for the solved row height and --days for the column count. Nothing
here measures anything; JS hands CSS the numbers and CSS does the arithmetic. */
/* Two numbers the finger layer needs that tokens.css does not have yet, and that belong there
once that file can move: the smallest control a thumb lands on reliably, and the all-day row's
touch height. The second is scoped to the band rather than written over --allday-h at the root,
because that token is read in three other places that have their own reasons for their size. */
:root {
--touch-target: 32px;
--allday-h-touch: 32px;
}
.grid {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
background: var(--paper);
user-select: none;
-webkit-user-select: none;
/* A press on a block is a gesture, not a text selection, and iOS answers a long press over
anything selectable with a callout menu right where the drag was meant to start. Overridden
on the draft's input below, which is the one piece of real text on the grid. */
-webkit-touch-callout: none;
}
/* Day bar
------------------------------------------------------------------------------------------- */
.grid-head {
flex: none;
display: grid;
grid-template-columns: var(--gutter-w) minmax(0, 1fr);
height: var(--daybar-h);
border-bottom: 1px solid var(--line);
}
.grid-head-gutter {
border-right: 1px solid var(--line);
}
.grid-head-days {
display: grid;
grid-template-columns: repeat(var(--days), minmax(0, 1fr));
}
/* A button, because tapping a day opens it. That is the escape hatch that makes a seven column
week on a phone worth putting up at all, and it costs the mouse nothing: the same click does
the same thing with a pointer. */
.grid-head-day {
display: flex;
align-items: center;
justify-content: center;
gap: 7px;
min-width: 0;
border-left: 1px solid var(--line);
color: var(--ink-soft);
font-size: var(--t-2);
transition: color 120ms var(--ease);
}
.grid-head-day:first-child {
border-left: 0;
}
.grid-head-day[data-past] {
color: var(--ink-faint);
}
.grid-head-day[data-today],
.grid-head-day:hover {
color: var(--ink);
}
.grid-head-name {
text-transform: lowercase;
letter-spacing: 0.02em;
}
.grid-head-date {
font-family: var(--font-heading);
font-size: var(--t-4);
font-variant-numeric: tabular-nums;
}
.grid-head-day[data-today] .grid-head-date {
padding: 1px 7px 2px;
border-radius: var(--r-pill);
background: var(--accent);
color: var(--accent-contrast);
}
/* All-day band
------------------------------------------------------------------------------------------- */
.grid-allday {
flex: none;
display: grid;
grid-template-columns: var(--gutter-w) minmax(0, 1fr);
border-bottom: 1px solid var(--line);
background: var(--paper);
}
.grid-allday-gutter {
display: flex;
align-items: flex-start;
justify-content: flex-end;
padding: 6px 7px 0 0;
border-right: 1px solid var(--line);
font-size: var(--t-1);
color: var(--ink-faint);
}
.grid-allday-wrap {
position: relative;
height: calc(var(--allday-h) * var(--rows) + 5px);
}
/* A chip is a row tall and the row is fixed, so the only way to make one hittable is to make the
row taller. That is safe here and nowhere else on the grid: the band is measured, not computed,
and the axis below is solved from whatever is left, so the six pixels come out of the day's
scale rather than out of the fit's arithmetic. */
:root[data-touch] .grid-allday-wrap {
--allday-h: var(--allday-h-touch);
}
.grid-allday-rows {
position: absolute;
top: 0;
left: 0;
right: 0;
max-height: 100%;
overflow: hidden;
display: grid;
grid-template-columns: repeat(var(--days), minmax(0, 1fr));
grid-auto-rows: var(--allday-h);
padding: 2px 0 3px;
}
.grid-allday-rows[data-expanded] {
z-index: 15;
max-height: 42vh;
overflow-y: auto;
background: var(--paper);
border-bottom: 1px solid var(--line);
box-shadow: var(--shadow-pop);
}
/* Every surface and every piece of type on a block, built from the numbers `calTint` hands it.
These belong in tokens.css beside --cal-1..8 once that file can move; they are declared here for
the same reason --cal-quiet was, which is that the colour is set per block and cannot be resolved
anywhere above it. `.agenda-row` is in the
list because the two views are meant to read as one product; agenda.css has always taken its
surfaces from here, and it sets its own fills below.
The whole pill carries its calendar's colour, but not the raw hue: eight saturated blocks make a
week of grid unreadable, and --cal-1..8 were chosen to sit on warm paper rather than to be read
off. So a block is that hue held at a fixed lightness, rather than the hue mixed towards the
surface. Mixing was the first attempt and it fails on the cool half of the palette: #4e6484
blended into #f6f2e8 comes out literally grey, because the blue and the paper's yellow cancel.
Holding the hue and choosing the lightness keeps eight calendars telling themselves apart, and a
fixed lightness per level means every calendar clears the same contrast rather than eight hues
each landing somewhere different.
Every level is written in hsl() rather than color-mix() or oklch() on purpose. hsl() with a
substituted hue computes to plain rgb(), which is what anything reading a block back out of the
DOM and compositing the layers behind it, the contrast harness included, can actually see.
Three numbers arrive per block, from `calTint`: --cal-h, the hue; --cal-sat, how much of it,
which is zero for a colour that has no hue so Graphite comes out grey; and --cal-lift, how deep,
in points of lightness towards the paper. An event that follows its calendar lifts by nothing
and one of the eight hues at the levels below is exactly what it gets. An event with a colour of
its own arrives with the depth of that colour, which is the only thing separating Lavender from
Blueberry or Sage from Basil in Google's palette. A point of lift is worth a point of lightness
on paper and rather less in the dark, where the band a readable fill can live in is narrower. */
.grid-event,
.grid-chip,
.agenda-row {
--cal-lift-step: 1%;
/* The rim. Carries more of the hue than the fill on purpose: it is what separates two blocks
that touch, and what still reads at the height a five minute event renders at. */
--cal-line: hsl(var(--cal-h) calc(34% * var(--cal-sat)) calc(64% + var(--cal-lift) * var(--cal-lift-step)));
--cal-line-quiet: hsl(var(--cal-h) calc(30% * var(--cal-sat)) calc(78% + var(--cal-lift) * var(--cal-lift-step)));
/* The type does not lift. It is the one thing that has to clear the same contrast whichever
colour the event carries, so it stays at a fixed lightness and only takes the hue. */
--cal-ink: hsl(var(--cal-h) calc(24% * var(--cal-sat)) 20%);
/* The second line, one step quieter. A filled surface has less room to be quiet in than a
neutral one had: much further than this and the time stops clearing 4.5:1. */
--cal-ink-soft: hsl(var(--cal-h) calc(20% * var(--cal-sat)) 27%);
}
:root[data-theme="dark"] :is(.grid-event, .grid-chip, .agenda-row) {
--cal-lift-step: -0.6%;
--cal-line: hsl(var(--cal-h) calc(30% * var(--cal-sat)) calc(45% + var(--cal-lift) * var(--cal-lift-step)));
--cal-line-quiet: hsl(var(--cal-h) calc(24% * var(--cal-sat)) calc(33% + var(--cal-lift) * var(--cal-lift-step)));
--cal-ink: hsl(var(--cal-h) calc(30% * var(--cal-sat)) 90%);
--cal-ink-soft: hsl(var(--cal-h) calc(22% * var(--cal-sat)) 81%);
}
/* The four surfaces a block is drawn on. The two themes are not mirror images of each other, so
neither the lightness nor the saturation carries over; the agenda sets its own four a step
quieter, because a row is several times the area of a block. */
.grid-event,
.grid-chip {
--cal-fill: hsl(var(--cal-h) calc(52% * var(--cal-sat)) calc(87% + var(--cal-lift) * var(--cal-lift-step)));
/* Hover, and the surface under the selection. */
--cal-fill-strong: hsl(var(--cal-h) calc(54% * var(--cal-sat)) calc(82% + var(--cal-lift) * var(--cal-lift-step)));
/* Somebody else's calendar. Still plainly that calendar's colour, just not drawn at the strength
of an event you can pick up. */
--cal-fill-quiet: hsl(var(--cal-h) calc(44% * var(--cal-sat)) calc(91% + var(--cal-lift) * var(--cal-lift-step)));
/* An event that is not solid yet: enough hue to name the calendar, not enough to look booked.
This one does not lift, because it is already as close to the surface as a fill can get. */
--cal-wash: hsl(var(--cal-h) calc(56% * var(--cal-sat)) 96.5%);
}
:root[data-theme="dark"] :is(.grid-event, .grid-chip) {
--cal-fill: hsl(var(--cal-h) calc(27% * var(--cal-sat)) calc(21% + var(--cal-lift) * var(--cal-lift-step)));
--cal-fill-strong: hsl(var(--cal-h) calc(29% * var(--cal-sat)) calc(26% + var(--cal-lift) * var(--cal-lift-step)));
--cal-fill-quiet: hsl(var(--cal-h) calc(23% * var(--cal-sat)) calc(16.5% + var(--cal-lift) * var(--cal-lift-step)));
--cal-wash: hsl(var(--cal-h) calc(25% * var(--cal-sat)) 12.5%);
}
/* Pinned to the band's right edge rather than given a grid row of its own, so the count cannot
make the band taller. The band's height is the axis's input and has to stay constant. */
.grid-chip-more {
position: absolute;
top: 1px;
right: 4px;
z-index: 2;
background: var(--paper);
box-shadow: -8px 0 8px -4px var(--paper);
}
.grid-chip,
.grid-chip-more,
.grid-chip-less {
min-width: 0;
margin: 1px 2px;
padding: 0 8px;
height: calc(var(--allday-h) - 4px);
line-height: calc(var(--allday-h) - 4px);
text-align: left;
border-radius: var(--r-sm);
font-size: var(--t-2);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.grid-chip {
background: var(--cal-fill);
outline: 1px solid var(--cal-line);
outline-offset: -1px;
color: var(--cal-ink);
cursor: pointer;
transition: background 120ms var(--ease), box-shadow 120ms var(--ease);
}
.grid-chip:hover {
background: var(--cal-fill-strong);
}
/* A calendar shared free/busy only. Same rule as the blocks below: it keeps its surface, because
the day really is taken, and gives up its ink, because it carries no information. */
.grid-chip[data-busy] {
color: var(--cal-ink-soft);
}
.grid-chip[data-readonly] {
background: var(--cal-fill-quiet);
outline-color: var(--cal-line-quiet);
}
.grid-chip[data-status="tentative"] {
background: var(--cal-wash);
outline-style: dashed;
}
.grid-chip[data-declined] {
background: transparent;
outline-color: var(--line-strong);
color: var(--cal-ink-soft);
text-decoration: line-through;
}
.grid-chip[data-pending] {
outline-style: dotted;
font-style: italic;
}
.grid-chip[data-selected] {
background: var(--cal-fill-strong);
box-shadow: 0 0 0 1px var(--accent);
}
.grid-chip-more,
.grid-chip-less {
color: var(--ink-faint);
font-size: var(--t-1);
transition: color 120ms var(--ease);
}
.grid-chip-more:hover,
.grid-chip-less:hover {
color: var(--ink);
}
/* Body
------------------------------------------------------------------------------------------- */
.grid-body {
position: relative;
flex: 1;
min-height: 0;
overflow: hidden;
}
/* The escape hatch: the row height hit its floor, so this is the one case that scrolls. */
.grid[data-overflow] .grid-body {
overflow-y: auto;
}
/* Every gesture on this surface is the grid's, so the webview is told to keep its hands off: a
press that the browser decides halfway through was a pan arrives as a pointercancel, and the
drag it was three pixels into disappears. */
.grid-canvas {
position: relative;
width: 100%;
touch-action: none;
}
/* Except in the one state where the body really does scroll, which is the fit giving up. There a
finger has to be able to reach the hours below, so vertical panning goes back to the browser and
the sideways half of the gesture stays ours. */
.grid[data-overflow] .grid-canvas {
touch-action: pan-y;
}
.grid-gutter {
position: absolute;
top: 0;
bottom: 0;
left: 0;
z-index: 0;
width: var(--gutter-w);
border-right: 1px solid var(--line);
pointer-events: none;
}
.grid-gutter-label {
position: absolute;
right: 7px;
transform: translateY(1px);
font-size: var(--t-1);
font-variant-numeric: tabular-nums;
color: var(--ink-faint);
}
.grid-rules {
position: absolute;
top: 0;
bottom: 0;
left: var(--gutter-w);
right: 0;
z-index: 0;
pointer-events: none;
}
.grid-rule {
position: absolute;
left: 0;
right: 0;
height: 1px;
background: var(--grid-hour);
}
.grid-rule[data-half] {
background: var(--grid-half);
}
/* Folded strips
------------------------------------------------------------------------------------------- */
.grid-strip {
position: absolute;
left: 0;
right: 0;
z-index: 2;
display: flex;
align-items: center;
gap: 9px;
padding-right: 10px;
background: var(--fold-surface);
border-top: 1px solid var(--fold-line);
border-bottom: 1px solid var(--fold-line);
color: var(--ink-faint);
font-size: var(--t-1);
transition: background 120ms var(--ease), color 120ms var(--ease);
}
.grid-strip:hover {
background: var(--event-surface-strong);
color: var(--ink-soft);
}
.grid-strip-range {
flex: none;
padding-left: 10px;
white-space: nowrap;
}
.grid-strip-rule {
flex: 1;
height: 1px;
background: var(--fold-line);
}
.grid-strip-count {
flex: none;
padding: 1px 6px;
border-radius: var(--r-sm);
background: var(--accent-wash);
color: var(--ink-soft);
font-variant-numeric: tabular-nums;
}
/* Columns
------------------------------------------------------------------------------------------- */
.grid-cols {
position: absolute;
top: 0;
bottom: 0;
left: var(--gutter-w);
right: 0;
z-index: 1;
display: grid;
grid-template-columns: repeat(var(--days), minmax(0, 1fr));
/* Empty grid is a creation surface, so pointer events fall through to the canvas. */
pointer-events: none;
}
.grid-col {
position: relative;
z-index: 1;
border-left: 1px solid var(--line);
}
.grid-col:first-child {
border-left: 0;
}
.grid-col[data-today] {
background: var(--grid-today);
}
.grid-col[data-past] {
background: var(--grid-past);
}
/* Event blocks
------------------------------------------------------------------------------------------- */
.grid-event {
/* Two more values that belong in tokens.css: how wide a block has to be before it is worth
reading, and how long the pointer has to mean it. The width is set above the 150px the title
gives up its second line at, so a block that leans forward clears the breakpoint rather than
landing on it. */
--lean-w: 200px;
--lean-delay: 110ms;
/* The selection ring and the lift are separate layers of one shadow so that a block which is
both selected and leaning keeps both, without either rule having to know about the other. */
--ring: 0 0 0 0 transparent;
--lift: 0 0 0 0 transparent;
--w-own: calc(var(--w) * 100% - var(--event-gap));
position: absolute;
left: calc(var(--l) * 100%);
width: var(--w-own);
min-height: var(--event-min-h);
/* The block's own width is the only thing that knows how many lines a title can really use,
and in a three deep cluster that is a quarter of what the column suggests. */
container-type: inline-size;
display: flex;
align-items: flex-start;
padding: 2px 6px;
overflow: hidden;
pointer-events: auto;
cursor: pointer;
background: var(--cal-fill);
/* An outline rather than a border, so the rim costs the block no width and every state below can
restyle it without moving the text a pixel. */
outline: 1px solid var(--cal-line);
outline-offset: -1px;
border-radius: var(--r-sm);
color: var(--cal-ink);
box-shadow: var(--ring), var(--lift);
/* No delay here, so a block falls back the moment the pointer leaves. The delay going the other
way is in the lean rule below. */
transition: width 130ms var(--ease), left 130ms var(--ease), background 120ms var(--ease),
box-shadow 120ms var(--ease), opacity 120ms var(--ease);
}
.grid-event:hover {
z-index: 3;
background: var(--cal-fill-strong);
}
/* Leaning forward
-------------------------------------------------------------------------------------------
A three deep cluster splits a day column into thirds and no title survives that. So a block
too narrow to read grows, on demand and temporarily, to the width it needs, over its
neighbours rather than pushing them: every block on the axis is absolutely positioned, so
nothing else moves, the row height is untouched and the axis does not shift by a pixel.
The arithmetic is the whole rule. --w-lean is the block's own width, or --lean-w if that is
wider, and never more than the column: a block that is already wide enough resolves to its own
width and does not move at all, and one in a narrow window grows to the column and stops. The
left edge stays put unless the wider block would hang off the column, in which case it slides
back by exactly the overhang. Both are pure `min`/`max`, so there is nothing to measure and
nothing to keep in sync with the fit.
Selection leans too, so `j` and `k` are worth as much as the pointer, but only while the
pointer is out of the grid. With a mouse in play hover is what says which block you mean, and
a block held open under the pointer would cover the one you were reaching for.
On a touchscreen selection is the only one of the two that exists, and the guard has to go with
the hover it was guarding: a tap leaves :hover stuck on whatever was tapped, so the canvas would
count as hovered for the rest of the session and a tapped block would never open. */
.grid-event:not([data-dragging]):hover,
.grid-canvas:not(:hover) .grid-event:not([data-dragging])[data-selected],
:root[data-touch] .grid-event:not([data-dragging])[data-selected] {
--w-lean: min(calc(100% - var(--event-gap)), max(var(--w-own), var(--lean-w)));
--lift: var(--shadow-pop);
z-index: 5;
width: var(--w-lean);
left: min(calc(var(--l) * 100%), calc(100% - var(--w-lean) - var(--event-gap)));
/* Long enough that sweeping the pointer across a busy day does not set every block off, short
enough that meaning it feels immediate. */
transition: width 130ms var(--ease) var(--lean-delay), left 130ms var(--ease) var(--lean-delay),
background 120ms var(--ease), box-shadow 120ms var(--ease) var(--lean-delay),
opacity 120ms var(--ease);
}
/* A five minute event. Small type, centred, one line. The fill does the work the thickened edge
used to: at 12 pixels tall the block is mostly colour, which is exactly when colour reads. */
.grid-event[data-size="bar"] {
align-items: center;
padding: 0 5px;
}
.grid-event[data-size="bar"] .grid-event-title {
font-size: var(--t-1);
line-height: 1;
}
.grid-event[data-dragging] {
opacity: 0.35;
}
/* Free/busy. A calendar shared at freeBusyReader returns no title at all, and on a real account
that is most of the grid. The block keeps its full surface, because the hour really is taken,
and gives up its ink and its second line, because it carries nothing else: a wall you cannot
book over rather than a thing you attend. */
.grid-event[data-busy] .grid-event-title {
color: var(--cal-ink-soft);
letter-spacing: 0.02em;
}
/* Somebody else's calendar: no grips, and the whole block is drawn a shade under an event you can
pick up. Still obviously that calendar's colour; most of a real account is read-only, and a grid
that greyed all of it out would have no colour left in it. */
.grid-event[data-readonly] {
background: var(--cal-fill-quiet);
outline-color: var(--cal-line-quiet);
}
.grid-event[data-readonly]:hover {
background: var(--cal-fill);
}
/* Not confirmed yet: the fill drops back to a wash and a dashed rim holds the shape, so the block
reads as an outline of an event rather than a booked hour. */
.grid-event[data-status="tentative"] {
background: var(--cal-wash);
outline-style: dashed;
outline-color: var(--cal-line);
}
.grid-event[data-status="tentative"]:hover {
background: var(--cal-fill);
}
/* Turned down. Still on the grid, because the hole in the day is real, but plainly not happening:
no fill at all, a neutral rim, and the title struck through. It is the only state that gives up
its colour, which is what makes it the one that has stopped being an event. */
.grid-event[data-declined] {
background: transparent;
outline-color: var(--line-strong);
color: var(--cal-ink-soft);
}
.grid-event[data-declined] .grid-event-title {
color: var(--cal-ink-soft);
text-decoration: line-through;
text-decoration-thickness: 1px;
}
.grid-event[data-declined]:hover {
background: var(--cal-wash);
}
/* Written locally, still in the outbox. Filled, because it is a real hour of your day, with a
broken rim and italic type because it is not whole yet. Tentative is hollow and dashed; this is
solid and dotted, so the two never read as the same thing. */
.grid-event[data-pending] {
outline-style: dotted;
}
.grid-event[data-pending] .grid-event-title {
font-style: italic;
}
/* Last, so selection wins over every state above it. */
.grid-event[data-selected] {
--ring: 0 0 0 1px var(--accent);
z-index: 4;
background: var(--cal-fill-strong);
}
.grid-event-text {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 1px;
}
/* Clamped to the lines the block has room for, never wrapped into a column of fragments. */
.grid-event-title {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: var(--lines, 1);
overflow: hidden;
font-size: var(--t-2);
line-height: 1.25;
}
.grid-event-time {
font-size: var(--t-1);
line-height: 1.2;
color: var(--cal-ink-soft);
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
/* Narrow enough that a second line would be one word long: one line and an ellipsis reads better
than six fragments, and the whole title is a click away in the card. This is the block's own
width, not the window's, which is why it is a container query and not a media query. */
@container (max-width: 150px) {
.grid-event-title {
-webkit-line-clamp: 1;
}
/* "11am · Church Str…" is worse than "11am". The place goes, the time stays. */
.grid-event-where {
display: none;
}
}
/* Three deep in a narrow week. A truncated "11…" under a truncated title is two ellipses saying
nothing, so the title keeps whatever room there is. */
@container (max-width: 92px) {
.grid-event-time {
display: none;
}
}
/* Full width to grab, with a small handle that only appears on a block you are allowed to resize.
That is also the quickest read of whether an event is yours. */
.grid-event-grip {
position: absolute;
left: 0;
right: 0;
height: var(--grip-h);
cursor: ns-resize;
}
.grid-event-grip::after {
content: "";
position: absolute;
left: 50%;
width: 22px;
height: 2px;
margin-left: -11px;
border-radius: var(--r-pill);
/* On a filled block a neutral grey handle disappears into the surface, so the grip is drawn in
the same hue as the rim. Ink would read as a rule struck through the title. */
background: var(--cal-line);
opacity: 0;
transition: opacity 120ms var(--ease);
}
.grid-event:hover .grid-event-grip::after,
.grid-event[data-selected] .grid-event-grip::after {
opacity: 1;
}
.grid-event-grip[data-edge="start"] {
top: 0;
}
.grid-event-grip[data-edge="start"]::after {
top: 1px;
}
.grid-event-grip[data-edge="end"] {
bottom: 0;
}
.grid-event-grip[data-edge="end"]::after {
bottom: 1px;
}
/* Dropped on a touchscreen rather than enlarged. A grip a finger could hit is around 24px, which
is most of an hour long block and all of a short one, and two of them would swallow the tap that
opens the event: the block would become a thing you can only resize. A press is also no longer
free here, since it has to survive the long press before it means anything, so trading the
resize for a whole-block target is the better half of the trade. Resizing on a phone is the
editor's job, where an end time is a field rather than seven pixels of edge. */
:root[data-touch] .grid-event-grip {
display: none;
}
/* Now line, and the wash over what has already gone
------------------------------------------------------------------------------------------- */
.grid-now {
position: absolute;
z-index: 3;
left: calc(100% * var(--i) / var(--days));
width: calc(100% / var(--days));
height: 0;
border-top: 1px solid var(--grid-now);
pointer-events: none;
}
.grid-now::before {
content: "";
position: absolute;
top: -3px;
left: 0;
width: 6px;
height: 6px;
border-radius: var(--r-pill);
background: var(--grid-now);
}
.grid-now-past {
position: absolute;
z-index: 0;
top: 0;
left: calc(100% * var(--i) / var(--days));
width: calc(100% / var(--days));
background: var(--grid-past);
pointer-events: none;
}
/* Drag preview and the in-place create input
------------------------------------------------------------------------------------------- */
/* The ghost is the range you are dragging out, and the label is the only thing in it, so the label
sits on the box's centre line rather than at the top of it.
That is also what stops it being sliced in half. A fifteen minute drag is a thirteen pixel box
with about nine pixels inside the border, which is less than a line of type; the box is the
truth about the range and must not be padded out to fit its own caption, so the caption is
centred and allowed to overhang by the pixel or so it needs. It overhangs evenly, which keeps it
on screen at the bottom of the grid as well as the top. Sideways it still has to be contained,
or a long range would print across the next day, so the clipping moved onto the label itself,
where it costs the glyphs nothing. */
.grid-ghost {
position: absolute;
z-index: 4;
left: calc(100% * var(--i) / var(--days));
width: calc(100% / var(--days) - var(--event-gap));
min-height: var(--event-min-h);
display: flex;
align-items: center;
padding: 0 6px;
overflow: visible;
pointer-events: none;
background: var(--event-surface-strong);
border: 1px dashed var(--accent);
border-radius: var(--r-sm);
}
.grid-ghost-time {
max-width: 100%;
font-size: var(--t-1);
/* Tall enough that the label's own box holds the whole glyph, descenders included, because that
box is where the sideways clipping happens. */
line-height: 1.3;
color: var(--ink-soft);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.grid-draft {
position: absolute;
z-index: 5;
left: calc(100% * var(--i) / var(--days));
width: calc(100% / var(--days) - var(--event-gap));
min-height: var(--allday-h);
display: flex;
flex-direction: column;
justify-content: center;
gap: 1px;
padding: 3px 7px;
pointer-events: auto;
background: var(--raised);
border-left: var(--edge-w) solid var(--accent);
border-radius: 0 var(--r-sm) var(--r-sm) 0;
box-shadow: var(--shadow-pop);
}
/* The one piece of text on the grid you are meant to be able to put a cursor in, so it takes back
both of the things `.grid` turned off for the surface around it. */
.grid-draft-input {
width: 100%;
padding: 0;
border: 0;
background: none;
outline: none;
font-size: var(--t-2);
user-select: text;
-webkit-user-select: text;
-webkit-touch-callout: default;
}
.grid-draft-input::placeholder {
color: var(--ink-faint);
}
.grid-draft-time {
font-size: var(--t-1);
color: var(--ink-faint);
white-space: nowrap;
}
/* A week on a phone
-------------------------------------------------------------------------------------------
Seven columns in three hundred and ninety pixels is fifty pixels a day. Nothing is readable at
that width and pretending otherwise is what produces a week of ellipses, so the week stops
trying to be a reading view and becomes what it can be: where the events are, which days are
busy, and a header you tap to open the day itself.
The day view is untouched. It has a whole phone to itself and reads exactly as it does on a
desktop. */
:root[data-phone] .grid[data-view="week"] .grid-head-day {
gap: 4px;
font-size: var(--t-1);
}
/* One letter, and it is a letter rather than a lowercase abbreviation cut short: M T W T F S S is
a shape you read across, which is all a single character can be. */
:root[data-phone] .grid[data-view="week"] .grid-head-name {
text-transform: uppercase;
letter-spacing: 0.04em;
}
:root[data-phone] .grid[data-view="week"] .grid-head-date {
font-size: var(--t-3);
}
:root[data-phone] .grid[data-view="week"] .grid-head-day[data-today] .grid-head-date {
padding: 1px 5px 2px;
}
/* The block gives up its padding before it gives up its title. The time line and the place are
already gone by this width, dropped by the container queries above, which measure the block
itself rather than the window and so were right about this before a phone existed. */
:root[data-phone] .grid[data-view="week"] .grid-event {
padding: 1px 4px;
}
:root[data-phone] .grid[data-view="week"] .grid-event-title {
font-size: var(--t-1);
line-height: 1.2;
}
+715
View File
@@ -0,0 +1,715 @@
/* The overlays: the mini-month, the calendar list, the event editor, settings and accounts. The
shell itself (.overlay, .panel, .panel-head, .panel-body, .panel-foot, .field, .field-label)
lives in app.css and is shared with everything else; what follows is only what those five
panels add on top of it. */
/* Panel shell
--------------------------------------------------------------------------------------------- */
.overlay-panel[data-size="mini"] {
width: min(292px, calc(100vw - 32px));
}
.overlay-panel[data-size="wide"] {
width: min(560px, calc(100vw - 32px));
}
.overlay-panel[data-size="mini"] .panel-body {
gap: 10px;
padding: 12px 12px 14px;
}
/* Four inputs in a row is one too many once the window is narrow. */
:root[data-compact] .field-row {
flex-wrap: wrap;
}
.panel-note {
margin: 0;
display: flex;
align-items: baseline;
gap: 7px;
color: var(--ink-soft);
line-height: 1.5;
}
.panel-note[data-icon] svg {
flex: none;
transform: translateY(2px);
}
.panel-note[data-error] {
color: var(--danger-ink);
}
.panel-empty {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 12px;
color: var(--ink-soft);
}
.panel-empty p {
margin: 0;
}
/* Buttons. app.css only carries .icon-button, so the panels bring their own text button and
keep it in the same .panel-* family. */
.panel-button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 7px;
min-height: 30px;
padding: 0 13px;
border: 1px solid var(--line-strong);
border-radius: var(--r-sm);
background: var(--raised);
color: var(--ink);
font-size: var(--t-3);
white-space: nowrap;
transition: background 120ms var(--ease), color 120ms var(--ease), border-color 120ms var(--ease);
}
.panel-button:hover {
background: var(--accent-wash);
}
/* A panel's own buttons are the last thing left at pointer size once the chrome has grown. */
:root[data-touch] .panel-button {
min-height: var(--touch-h);
}
.panel-button:disabled {
color: var(--ink-faint);
cursor: default;
background: var(--raised);
}
.panel-button[data-variant="primary"] {
border-color: var(--accent);
background: var(--accent);
color: var(--accent-contrast);
}
.panel-button[data-variant="primary"]:hover {
background: var(--accent-ink);
}
.panel-button[data-variant="primary"]:disabled {
border-color: var(--line-strong);
background: var(--raised);
color: var(--ink-faint);
}
.panel-button[data-variant="danger"] {
border-color: var(--line-strong);
color: var(--danger-ink);
}
.panel-button[data-variant="danger"]:hover {
border-color: var(--danger);
background: var(--danger-wash);
}
.panel-button[data-variant="ghost"] {
border-color: transparent;
background: none;
color: var(--ink-soft);
}
.panel-button[data-variant="ghost"]:hover {
background: var(--accent-wash);
color: var(--ink);
}
/* The one action that is not a reply to the others sits away from them. */
.panel-button[data-side="lead"] {
margin-right: auto;
}
.panel-button[data-align="lead"] {
align-self: flex-start;
}
/* Fields
--------------------------------------------------------------------------------------------- */
.field-input,
.field-select,
.field-textarea {
width: 100%;
min-width: 0;
padding: 6px 9px;
border: 1px solid var(--line-strong);
border-radius: var(--r-sm);
background: var(--raised);
transition: border-color 120ms var(--ease);
}
.field-input:hover,
.field-select:hover,
.field-textarea:hover {
border-color: var(--ink-faint);
}
.field-textarea {
resize: vertical;
line-height: 1.5;
}
.field-select {
appearance: none;
padding-right: 26px;
background-image: linear-gradient(45deg, transparent 50%, currentcolor 50%),
linear-gradient(135deg, currentcolor 50%, transparent 50%);
background-position: calc(100% - 14px) 13px, calc(100% - 10px) 13px;
background-size: 4px 4px, 4px 4px;
background-repeat: no-repeat;
}
.field-row {
display: flex;
align-items: center;
gap: 6px;
}
.field-row .field-input[type="date"] {
flex: 1 1 auto;
}
.field-row .field-input[type="time"] {
flex: 0 1 auto;
width: auto;
}
.field-to {
flex: none;
color: var(--ink-faint);
font-size: var(--t-2);
}
.field-check {
display: flex;
align-items: center;
gap: 7px;
color: var(--ink-soft);
font-size: var(--t-2);
}
.field-check input {
accent-color: var(--accent);
}
.field-hint {
display: flex;
align-items: center;
gap: 6px;
color: var(--ink-faint);
font-size: var(--t-2);
}
.field-hint[data-error] {
color: var(--danger-ink);
}
.field-static {
color: var(--ink);
line-height: 1.5;
}
.field-static[data-wrap] {
white-space: pre-wrap;
max-height: 180px;
overflow-y: auto;
}
/* Mini month
--------------------------------------------------------------------------------------------- */
.mini {
--mini-cell: 32px;
display: flex;
flex-direction: column;
gap: 4px;
outline: none;
}
.mini-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 4px;
}
.mini-label {
font-family: var(--font-heading);
font-size: var(--t-4);
}
.mini-week,
.mini-grid {
display: grid;
grid-template-columns: repeat(7, minmax(0, 1fr));
}
.mini-weekday {
padding-bottom: 2px;
text-align: center;
font-size: var(--t-1);
color: var(--ink-faint);
text-transform: lowercase;
}
.mini-grid {
gap: 1px;
}
.mini-day {
height: var(--mini-cell);
border-radius: var(--r-sm);
color: var(--ink);
font-size: var(--t-2);
font-variant-numeric: tabular-nums;
transition: background 120ms var(--ease), color 120ms var(--ease), box-shadow 120ms var(--ease);
}
.mini-day[data-outside] {
color: var(--ink-faint);
}
.mini-day[data-span] {
background: var(--accent-wash);
}
.mini-day:hover {
background: var(--selection);
}
.mini-day[data-today] {
color: var(--grid-now);
font-weight: 600;
}
.mini-day[data-cursor] {
box-shadow: inset 0 0 0 1px var(--accent);
}
.mini:focus-visible {
outline: none;
}
.mini:focus-visible .mini-day[data-cursor] {
box-shadow: inset 0 0 0 2px var(--accent);
}
.mini-foot {
display: flex;
justify-content: flex-end;
}
/* The month is where the phone's date button goes, so its cells are the most tapped thing in the
app on a device with no keys to jump with. A 32px cell is a mis-tap on a neighbouring day. */
:root[data-touch] .mini {
--mini-cell: var(--touch-h);
}
/* The hover form, for a header that wants the month without the backdrop. */
.mini-pop {
position: absolute;
z-index: 40;
padding: 10px 12px 12px;
background: var(--paper);
border: 1px solid var(--line);
border-radius: var(--r-lg);
box-shadow: var(--shadow-pop);
}
/* Calendars
--------------------------------------------------------------------------------------------- */
.cal-group {
display: flex;
flex-direction: column;
gap: 3px;
}
.cal-group .field-label {
margin-bottom: 4px;
}
.cal-row {
display: flex;
align-items: center;
gap: 9px;
min-width: 0;
padding: 5px 7px;
margin: 0 -7px;
border-radius: var(--r-sm);
cursor: pointer;
transition: background 120ms var(--ease);
}
.cal-row:hover {
background: var(--accent-wash);
}
.cal-check {
flex: none;
accent-color: var(--accent);
}
.cal-dot {
flex: none;
width: 9px;
height: 9px;
border-radius: 50%;
}
.cal-name {
flex: 1;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.cal-role {
flex: none;
padding: 1px 6px;
border-radius: var(--r-sm);
background: var(--accent-wash);
color: var(--ink-faint);
font-size: var(--t-1);
}
/* Event editor
--------------------------------------------------------------------------------------------- */
.editor {
display: flex;
flex-direction: column;
gap: 15px;
}
.scope-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.scope-option {
display: flex;
flex-direction: column;
gap: 3px;
padding: 10px 13px;
text-align: left;
border: 1px solid var(--line-strong);
border-radius: var(--r-md);
background: var(--raised);
transition: background 120ms var(--ease), border-color 120ms var(--ease);
}
.scope-option:hover {
border-color: var(--accent);
background: var(--accent-wash);
}
.scope-option[data-variant="danger"]:hover {
border-color: var(--danger);
background: var(--danger-wash);
}
.scope-option:disabled {
color: var(--ink-faint);
cursor: default;
}
.scope-name {
font-size: var(--t-4);
}
.scope-note {
color: var(--ink-soft);
font-size: var(--t-2);
line-height: 1.4;
}
.guest-list {
display: flex;
flex-direction: column;
gap: 5px;
margin: 2px 0 0;
padding: 0;
list-style: none;
max-height: 168px;
overflow-y: auto;
}
.guest {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
font-size: var(--t-2);
}
.guest-dot {
flex: none;
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--ink-faint);
}
.guest[data-response="accepted"] .guest-dot {
background: var(--ink);
}
.guest[data-response="declined"] .guest-dot {
background: var(--danger);
}
.guest[data-response="tentative"] .guest-dot {
background: var(--ink-soft);
box-shadow: inset 0 0 0 1px var(--paper);
}
.guest-name {
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.guest-tag {
flex: none;
padding: 1px 5px;
border-radius: var(--r-sm);
background: var(--accent-wash);
color: var(--ink-faint);
font-size: var(--t-1);
}
.guest-response {
flex: none;
margin-left: auto;
color: var(--ink-faint);
font-size: var(--t-1);
}
.guest[data-response="declined"] .guest-name {
color: var(--ink-soft);
text-decoration: line-through;
}
/* Settings
--------------------------------------------------------------------------------------------- */
.setting-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
min-height: 32px;
}
.setting-text {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.setting-name {
color: var(--ink);
}
.setting-note {
color: var(--ink-faint);
font-size: var(--t-2);
line-height: 1.45;
}
.segment {
display: flex;
flex: none;
border: 1px solid var(--line-strong);
border-radius: var(--r-sm);
overflow: hidden;
}
.segment-option {
padding: 5px 13px;
color: var(--ink-soft);
font-size: var(--t-2);
border-left: 1px solid var(--line-strong);
transition: background 120ms var(--ease), color 120ms var(--ease);
}
.segment-option:first-child {
border-left: 0;
}
:root[data-touch] .segment-option {
min-height: var(--touch-h);
padding: 0 16px;
}
.segment-option:hover {
background: var(--accent-wash);
color: var(--ink);
}
.segment-option[data-on] {
background: var(--accent);
color: var(--accent-contrast);
}
/* Accounts
--------------------------------------------------------------------------------------------- */
.account-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 10px 0;
border-top: 1px solid var(--line);
}
.account-row:first-of-type {
border-top: 0;
}
.account-text {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.account-email {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.account-meta {
color: var(--ink-faint);
font-size: var(--t-2);
}
.connect-pending {
display: flex;
flex-direction: column;
gap: 8px;
padding: 13px;
border: 1px solid var(--line-strong);
border-radius: var(--r-md);
background: var(--raised);
}
.connect-line {
color: var(--ink);
}
.connect-note {
color: var(--ink-soft);
font-size: var(--t-2);
line-height: 1.45;
}
.connect-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding-top: 2px;
}
/* Menu
--------------------------------------------------------------------------------------------- */
/* The phone's overflow: rows with labels rather than the header's row of icons, because on a bar
that holds five things there is no room left to guess what a glyph meant. */
.menu-list {
display: flex;
flex-direction: column;
gap: 2px;
margin: -6px -12px;
}
.menu-item {
display: flex;
align-items: center;
gap: 12px;
min-height: var(--touch-h);
padding: 0 12px;
border-radius: var(--r-sm);
color: var(--ink-soft);
text-align: left;
transition: background 120ms var(--ease), color 120ms var(--ease);
}
.menu-item:hover,
.menu-item:active {
background: var(--accent-wash);
color: var(--ink);
}
.menu-label {
color: var(--ink);
font-size: var(--t-4);
}
/* Confirmations
--------------------------------------------------------------------------------------------- */
.confirm {
display: flex;
flex-direction: column;
gap: 10px;
}
.confirm-title {
margin: 0;
font-family: var(--font-heading);
font-weight: 500;
font-size: var(--t-4);
}
.confirm-body {
color: var(--ink-soft);
line-height: 1.5;
}
.confirm-body p {
margin: 0;
}
.confirm-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
padding-top: 4px;
}
/* Only present when a panel was opened from another one, so the head has to cope with two or
three children without the title jumping. */
.panel-head:has(.panel-back) {
padding-left: 12px;
}
/* The head is space-between, so a third child would centre the title. Keep it beside the back
control and let the close button hold the right edge. */
.panel-head:has(.panel-back) h2 {
flex: 1;
}
.panel-back {
flex: none;
margin-right: 2px;
}
+176
View File
@@ -0,0 +1,176 @@
/* The command palette and the generated shortcut sheet. Both lean on `.overlay` and `.panel` from
app.css and only add what they genuinely need on top. */
.overlay[data-align="top"] {
align-items: start;
padding-top: 12vh;
}
:root[data-compact] .overlay[data-align="top"] {
padding: 16px;
}
.panel.palette {
width: min(620px, calc(100vw - 32px));
max-height: min(560px, 76vh);
}
.palette-input {
flex: none;
width: 100%;
padding: 18px 22px;
border: 0;
border-bottom: 1px solid var(--line);
background: none;
font-size: var(--t-4);
outline: none;
}
.palette-input::placeholder {
color: var(--ink-faint);
}
.palette-preview {
flex: none;
display: flex;
flex-direction: column;
gap: 4px;
padding: 12px 22px;
border-bottom: 1px solid var(--line);
background: var(--raised);
}
.palette-sentence {
margin: 0;
color: var(--ink-soft);
font-size: var(--t-2);
line-height: 1.5;
}
.palette-warning {
margin: 0;
color: var(--danger-ink);
font-size: var(--t-2);
}
.palette-empty {
margin: 0;
padding: 16px 22px 20px;
color: var(--ink-faint);
font-size: var(--t-2);
}
.palette-list {
flex: 1;
min-height: 0;
margin: 0;
padding: 6px;
list-style: none;
overflow-y: auto;
}
.palette-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 8px 16px;
border-radius: var(--r-sm);
cursor: pointer;
transition: background 100ms var(--ease);
}
.palette-row[data-active] {
background: var(--accent-wash);
}
.palette-row[data-kind="create"] .palette-label {
font-weight: 500;
}
.palette-label {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.palette-keys {
flex: none;
display: flex;
gap: 4px;
}
/* One keycap, shared by the palette rows and the sheet. */
.key {
display: inline-block;
min-width: 20px;
padding: 2px 6px;
border: 1px solid var(--line-strong);
border-radius: var(--r-sm);
background: var(--raised);
color: var(--ink-soft);
font-family: var(--font-ui);
font-size: var(--t-1);
line-height: 1.4;
text-align: center;
white-space: nowrap;
}
.shortcuts {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 22px 30px;
align-items: start;
}
:root[data-compact] .shortcuts {
grid-template-columns: 1fr;
}
.shortcuts-group {
min-width: 0;
}
.shortcuts-heading {
margin: 0 0 8px;
color: var(--ink-faint);
font-size: var(--t-1);
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.shortcuts-list {
display: flex;
flex-direction: column;
gap: 6px;
margin: 0;
}
.shortcuts-row {
display: grid;
grid-template-columns: 82px 1fr;
gap: 10px;
align-items: baseline;
}
.shortcuts-keys {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 4px;
}
.shortcuts-label {
margin: 0;
color: var(--ink-soft);
font-size: var(--t-2);
line-height: 1.5;
}
.shortcuts-note {
margin: 0;
color: var(--ink-faint);
font-size: var(--t-1);
}
+58
View File
@@ -0,0 +1,58 @@
/* A description printed as elements, wherever one is read rather than edited. The rules are the
same shape as the details card's, kept here so the component carries its own look and does not
depend on whichever panel happened to load first. */
.rich {
min-width: 0;
color: var(--ink);
line-height: 1.55;
/* Pasted Drive URLs are one word two hundred characters long. */
overflow-wrap: anywhere;
/* Long enough to read a paragraph of, short enough that the buttons under it stay on screen. */
max-height: 200px;
overflow-y: auto;
}
/* Plain text keeps the spacing it was written with. Html has had its own collapsed already and
carries its structure in elements, where pre-wrap would double every gap in the source. */
.rich[data-plain] {
white-space: pre-wrap;
}
.rich :is(div, ul, ol) {
margin: 7px 0 0;
}
.rich :is(div, ul, ol):first-child {
margin-top: 0;
}
.rich :is(ul, ol) {
padding-left: 20px;
}
.rich li + li {
margin-top: 3px;
}
.rich code {
padding: 0 4px;
border-radius: var(--r-sm);
background: var(--accent-wash);
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.92em;
}
/* Findable in the middle of a sentence rather than only on a line of its own. */
.rich-link {
color: var(--ink);
text-decoration: underline;
text-decoration-color: var(--ink-faint);
text-underline-offset: 2px;
transition: text-decoration-color 120ms var(--ease), color 120ms var(--ease);
}
.rich-link:hover {
color: var(--accent-ink);
text-decoration-color: currentcolor;
}
+168
View File
@@ -0,0 +1,168 @@
:root {
--font-ui: "Hanken Grotesk", ui-sans-serif, system-ui, -apple-system, sans-serif;
--font-heading: "Literata", Georgia, "Times New Roman", serif;
--r-sm: 5px;
--r-md: 8px;
--r-lg: 12px;
--r-pill: 999px;
--titlebar-h: 46px;
--traffic-pad: 84px;
/* Phone chrome. The bars are the only thing standing between the app and a notch or a home
indicator, so the insets are read once here and everything else pads with these. A browser
that has never heard of env() falls back to nothing, which is the desktop. */
--safe-top: env(safe-area-inset-top, 0px);
--safe-bottom: env(safe-area-inset-bottom, 0px);
--phonebar-h: 48px;
--tabbar-h: 56px;
/* Apple's floor for anything a finger has to hit, and the reason phone chrome is as tall as it
is. Desktop keeps its 28px controls. */
--touch-h: 44px;
/* Enough of the page left showing that a sheet reads as a layer over it rather than a screen.
A cap rather than a height, and on a phone it rarely binds: the sheet's bottom is pinned by
the fixed overlay, so this only bites when the content is tall enough to want more. */
--sheet-max-h: 88dvh;
--t-1: 11px;
--t-2: 12px;
--t-3: 13px;
--t-4: 15px;
/* iOS Safari zooms the page when a field under 16px takes the focus, and a locked viewport
leaves no way back out of that zoom. Phone fields take this one. */
--t-5: 16px;
--ease: cubic-bezier(0.22, 0.61, 0.36, 1);
/* Grid geometry. --row-h is written by the fit engine on every layout pass; the rest are
fixed and the fit maths reads them back. */
--gutter-w: 58px;
--daybar-h: 40px;
--allday-h: 26px;
--strip-h: 22px;
--row-h: 48px;
--row-h-min: 22px;
/* Event blocks and agenda rows. --event-min-h is mirrored as MIN_BLOCK_H in GridModel.tsx the
way --strip-h is mirrored in fit.ts, so the two must move together. */
--qc-w: 292px;
--event-min-h: 12px;
--event-gap: 3px;
--edge-w: 2px;
--grip-h: 7px;
}
:root[data-compact] {
--gutter-w: 44px;
--daybar-h: 34px;
--qc-w: 236px;
--traffic-pad: 84px;
}
:root,
:root[data-theme="light"] {
--paper: #fcfbf7;
--shell: #f1ece2;
--raised: #fbfaf6;
--ink: #23201b;
--ink-soft: #6b6458;
/* Clears 4.5:1 on every light surface it lands on, including the folded strip, which is
the darkest. The hour axis is the text you read most in a calendar. */
--ink-faint: #6e675b;
--line: #e3ddce;
--line-strong: #d6cfbd;
--accent: #2a2622;
--accent-ink: #100e0b;
--accent-wash: rgba(35, 32, 27, 0.08);
--accent-contrast: #faf7f0;
--selection: rgba(35, 32, 27, 0.14);
--glass: rgba(252, 251, 247, 0.86);
--scrim: rgba(35, 32, 27, 0.28);
--danger: #b4453a;
--danger-ink: #963327;
--danger-wash: rgba(180, 69, 58, 0.1);
--danger-contrast: #faf7f0;
--shadow-page: 0 1px 2px rgba(35, 32, 27, 0.06), 0 14px 30px rgba(35, 32, 27, 0.09);
--shadow-pop: 0 8px 24px rgba(35, 32, 27, 0.14);
--grid-hour: #e6e0d1;
--grid-half: #efeade;
--grid-now: #b4453a;
--grid-past: rgba(35, 32, 27, 0.03);
--grid-today: rgba(35, 32, 27, 0.035);
--fold-surface: #ece6da;
--fold-line: #ddd6c6;
--event-surface: #f6f2e8;
--event-surface-strong: #efe9dc;
--event-ink: #23201b;
--cal-1: #6f6194;
--cal-2: #47705f;
--cal-3: #a06044;
--cal-4: #4e6484;
--cal-5: #856434;
--cal-6: #955767;
--cal-7: #3a6b78;
--cal-8: #667141;
}
:root[data-theme="dark"] {
--paper: #1d1a16;
--shell: #151310;
--raised: #232019;
--ink: #ece6da;
--ink-soft: #a89f8e;
/* Clears 4.5:1 on every dark surface, including a folded strip. The old #756d5e was
3.39:1, which only went unnoticed while today was always the first column and the dimmer
past-day headers never led the week. */
--ink-faint: #8e8677;
--line: #2c2823;
--line-strong: #3a352d;
--accent: #efe9dc;
--accent-ink: #ffffff;
--accent-wash: rgba(239, 233, 220, 0.12);
--accent-contrast: #1a1714;
--selection: rgba(239, 233, 220, 0.18);
--glass: rgba(33, 30, 25, 0.88);
/* The light scrim over a dark page dimmed nothing, so panels floated on shadow alone. */
--scrim: rgba(0, 0, 0, 0.58);
--danger: #d97a6f;
--danger-ink: #e58e84;
--danger-wash: rgba(217, 122, 111, 0.16);
--danger-contrast: #1a1714;
--shadow-page: 0 1px 2px rgba(0, 0, 0, 0.4), 0 14px 34px rgba(0, 0, 0, 0.5);
--shadow-pop: 0 8px 24px rgba(0, 0, 0, 0.55);
--grid-hour: #2e2a24;
--grid-half: #24211c;
--grid-now: #d97a6f;
--grid-past: rgba(0, 0, 0, 0.16);
--grid-today: rgba(239, 233, 220, 0.04);
--fold-surface: #1a1713;
--fold-line: #302b24;
--event-surface: #272319;
--event-surface-strong: #2f2a20;
--event-ink: #ece6da;
--cal-1: #a396c8;
--cal-2: #7fae98;
--cal-3: #d19a7c;
--cal-4: #8ba2c4;
--cal-5: #c4a267;
--cal-6: #cb909e;
--cal-7: #75a9b6;
--cal-8: #a2ae77;
}
+16
View File
@@ -0,0 +1,16 @@
export type Theme = "light" | "dark";
const KEY = "margincal-theme";
export function initialTheme(): Theme {
const attr = document.documentElement.getAttribute("data-theme");
if (attr === "light" || attr === "dark") return attr;
const saved = localStorage.getItem(KEY);
if (saved === "light" || saved === "dark") return saved;
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
}
export function applyTheme(theme: Theme) {
document.documentElement.setAttribute("data-theme", theme);
localStorage.setItem(KEY, theme);
}
+136
View File
@@ -0,0 +1,136 @@
// Local wall-clock date maths. The grid renders in the local zone, so everything here works in
// terms of the browser's local time and epoch milliseconds, never UTC components.
//
// All-day events are date-only and must never be shifted into a zone: use `parseDateOnly` and
// `toDateOnly` for those, never `new Date(isoString)`, which would drag them across a boundary.
export const MINUTES_PER_DAY = 1440;
const WEEK_START_KEY = "margincal-week-start";
/**
* 0 is Sunday, 1 is Monday, defaulting to Monday. Compared as a string on purpose: an absent key
* reads as null, and `Number(null)` is 0, which would silently make Sunday the default.
*/
export function weekStartDay(): number {
return localStorage.getItem(WEEK_START_KEY) === "0" ? 0 : 1;
}
export function setWeekStartDay(day: 0 | 1): void {
localStorage.setItem(WEEK_START_KEY, String(day));
}
export function startOfDay(ms: number): number {
const d = new Date(ms);
d.setHours(0, 0, 0, 0);
return d.getTime();
}
export function addDays(ms: number, days: number): number {
const d = new Date(ms);
d.setDate(d.getDate() + days);
return d.getTime();
}
/**
* The first column of a week view. It is simply wherever the anchor is, because the anchor is
* already parked on a week boundary by `jumpTo`, `goToday` and switching into the view.
*
* Snapping here instead would make a one-day step invisible six times out of seven. Keeping it
* raw is what lets `h`/`l` and the single chevrons slide the window a day at a time as a temporary
* look, without changing which day a week starts on.
*/
export function weekAnchor(ms: number): number {
return startOfDay(ms);
}
/** Anchored to the configured first day of the week, at local midnight. */
export function startOfWeek(ms: number): number {
const start = weekStartDay();
const d = new Date(startOfDay(ms));
const shift = (d.getDay() - start + 7) % 7;
d.setDate(d.getDate() - shift);
return d.getTime();
}
export function today(): number {
return startOfDay(Date.now());
}
export function isSameDay(a: number, b: number): boolean {
return startOfDay(a) === startOfDay(b);
}
/** Minutes from local midnight. A DST day is 23 or 25 hours, so this can exceed 1440. */
export function minutesFromMidnight(ms: number): number {
const d = new Date(ms);
return d.getHours() * 60 + d.getMinutes() + d.getSeconds() / 60;
}
/** `YYYY-MM-DD` in local terms, for an all-day boundary. */
export function toDateOnly(ms: number): string {
const d = new Date(ms);
const month = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${d.getFullYear()}-${month}-${day}`;
}
/** Local midnight of a `YYYY-MM-DD` string, never parsed as UTC. */
export function parseDateOnly(value: string): number {
const [y, m, d] = value.split("-").map(Number);
return new Date(y, (m ?? 1) - 1, d ?? 1).getTime();
}
/** RFC3339 with the local offset, which is what the Rust side expects for a timed event. */
export function toOffsetIso(ms: number): string {
const d = new Date(ms);
const pad = (n: number) => String(n).padStart(2, "0");
const offset = -d.getTimezoneOffset();
const sign = offset >= 0 ? "+" : "-";
const abs = Math.abs(offset);
return (
`${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` +
`T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}` +
`${sign}${pad(Math.floor(abs / 60))}:${pad(abs % 60)}`
);
}
const dayNames = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
const monthNames = [
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
];
export const dayName = (ms: number) => dayNames[new Date(ms).getDay()];
export const monthName = (ms: number) => monthNames[new Date(ms).getMonth()];
export function formatTime(ms: number): string {
const d = new Date(ms);
const h = d.getHours();
const m = d.getMinutes();
const suffix = h < 12 ? "am" : "pm";
const hour = h % 12 === 0 ? 12 : h % 12;
return m === 0 ? `${hour}${suffix}` : `${hour}:${String(m).padStart(2, "0")}${suffix}`;
}
/** The header's date range, collapsed where the two ends share a month or a year. */
export function formatRange(from: number, to: number): string {
const a = new Date(from);
const b = new Date(to);
if (isSameDay(from, to)) return `${dayName(from)} ${a.getDate()} ${monthName(from)} ${a.getFullYear()}`;
if (a.getMonth() === b.getMonth() && a.getFullYear() === b.getFullYear())
return `${a.getDate()} to ${b.getDate()} ${monthName(from)} ${a.getFullYear()}`;
if (a.getFullYear() === b.getFullYear())
return `${a.getDate()} ${monthName(from)} to ${b.getDate()} ${monthName(to)} ${a.getFullYear()}`;
return `${a.getDate()} ${monthName(from)} ${a.getFullYear()} to ${b.getDate()} ${monthName(to)} ${b.getFullYear()}`;
}
+63
View File
@@ -0,0 +1,63 @@
import { useEffect, useState } from "react";
export function useMediaQuery(query: string): boolean {
const [matches, setMatches] = useState(() => window.matchMedia(query).matches);
useEffect(() => {
const mq = window.matchMedia(query);
const onChange = () => setMatches(mq.matches);
onChange();
mq.addEventListener("change", onChange);
return () => mq.removeEventListener("change", onChange);
}, [query]);
return matches;
}
/**
* Responsiveness is JS-driven: this writes `data-compact` on the root and the stylesheet keys off
* that attribute, so there are essentially no media queries in app.css.
*/
export function useCompact(): boolean {
const compact = useMediaQuery("(max-width: 899px)");
useEffect(() => {
document.documentElement.toggleAttribute("data-compact", compact);
}, [compact]);
return compact;
}
/** Kept in step with the same two queries in index.html's boot script. */
export const PHONE_QUERY = "(max-width: 640px)";
export const TOUCH_QUERY = "(pointer: coarse)";
/**
* A phone, meaning a window too narrow for the header's three groups and the week's seven columns
* to be worth putting up. The chrome moves to a top bar and a bottom tab bar and the default view
* becomes the day.
*
* Deliberately measured in CSS pixels rather than sniffed off the user agent: a phone in landscape
* is 850 wide and wants the desktop layout back, a tablet at 768 has always wanted it, and a
* narrow desktop window is a free way to exercise all of this without a device.
*/
export function usePhone(): boolean {
const phone = useMediaQuery(PHONE_QUERY);
useEffect(() => {
document.documentElement.toggleAttribute("data-phone", phone);
}, [phone]);
return phone;
}
/**
* A coarse pointer, which is a different question from `usePhone`: a tablet is touch and not a
* phone, and a narrow desktop window is a phone and not touch.
*
* This is the one that governs interaction rather than layout. Anything that only appears on hover
* is unreachable here and has to have a tap that does the same job, and a gesture that starts on
* pointerdown has to wait for a long press instead, because a finger cannot press without also
* intending to maybe scroll.
*/
export function useTouch(): boolean {
const touch = useMediaQuery(TOUCH_QUERY);
useEffect(() => {
document.documentElement.toggleAttribute("data-touch", touch);
}, [touch]);
return touch;
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />