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

+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;
}
}