Margin Calendar: a Google Calendar client for desktop and phone

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

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

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

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

No files matched your search

+159
View File
@@ -0,0 +1,159 @@
// Connecting and disconnecting Google accounts. The connect flow lives entirely in `useAccounts`:
// this panel calls it and then shows what is happening, including the two escape hatches for when
// the system browser did not open by itself.
//
// Disconnecting is the destructive one. It revokes the token and wipes every row belonging to the
// account, so it asks first and says what goes.
import { useEffect, useState } from "react";
import { useEscapeLayer } from "../escape";
import type { Account } from "../ipc";
import { useAccounts } from "../store/useAccounts";
import { useCalendarView } from "../store/useCalendarView";
import { useOverlays } from "../store/useOverlays";
import { Icon } from "./Icon";
import { Confirm, Sheet } from "./overlayShell";
const COPY = "M9 9h10v11H9zM5 15H4V4h11v1";
const OPEN = "M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6M15 3h6v6M10 14 21 3";
export function Accounts() {
const open = useOverlays((s) => s.open);
const close = useOverlays((s) => s.close);
const accounts = useAccounts((s) => s.accounts);
const calendars = useAccounts((s) => s.calendars);
const phase = useAccounts((s) => s.phase);
const error = useAccounts((s) => s.error);
const authUrl = useAccounts((s) => s.authUrl);
const connect = useAccounts((s) => s.connect);
const cancelConnect = useAccounts((s) => s.cancelConnect);
const openAuthUrl = useAccounts((s) => s.openAuthUrl);
const copyAuthUrl = useAccounts((s) => s.copyAuthUrl);
const disconnect = useAccounts((s) => s.disconnect);
const refresh = useAccounts((s) => s.refresh);
const [confirming, setConfirming] = useState<Account | null>(null);
const showing = open === "accounts";
const connecting = phase === "connecting";
// While the browser has the flow, Escape belongs to the flow rather than to the panel.
useEscapeLayer(showing && connecting, cancelConnect);
useEscapeLayer(showing && confirming !== null, () => setConfirming(null));
useEffect(() => {
if (showing) void refresh();
}, [showing, refresh]);
const remove = async (account: Account) => {
setConfirming(null);
await disconnect(account.id);
void useCalendarView.getState().load();
};
const countFor = (accountId: string) => calendars.filter((c) => c.accountId === accountId).length;
return (
<Sheet
open={showing}
title="Accounts"
onClose={close}
foot={
confirming ? undefined : (
<button
type="button"
className="panel-button"
data-variant="primary"
data-autofocus=""
disabled={connecting || phase === "working"}
onClick={() => void connect()}
>
Connect an account
</button>
)
}
>
{confirming ? (
<Confirm
title={`Disconnect ${confirming.email}?`}
body={
<p>
The token is revoked and every calendar, event and pending write stored on this
computer for that account is deleted. Nothing changes in Google Calendar itself, and
you can connect the account again afterwards.
</p>
}
confirmLabel="Disconnect"
busy={phase === "working"}
onConfirm={() => void remove(confirming)}
onCancel={() => setConfirming(null)}
/>
) : (
<>
{connecting && (
<div className="connect-pending">
<span className="connect-line">Waiting for Google in your browser.</span>
<span className="connect-note">
{authUrl
? "If nothing opened, use the link. Finish there and this comes back on its own."
: "Building the consent link."}
</span>
<div className="connect-actions">
<button
type="button"
className="panel-button"
disabled={!authUrl}
onClick={openAuthUrl}
>
<Icon d={OPEN} size={14} />
Open the link
</button>
<button
type="button"
className="panel-button"
disabled={!authUrl}
onClick={() => void copyAuthUrl()}
>
<Icon d={COPY} size={14} />
Copy the link
</button>
<button type="button" className="panel-button" onClick={cancelConnect}>
Cancel
</button>
</div>
</div>
)}
{phase === "error" && error && <p className="panel-note" data-error="">{error}</p>}
{accounts.length === 0 && !connecting ? (
<p className="panel-note">
No account is connected, so there is nothing to show on the grid yet.
</p>
) : (
accounts.map((account) => (
<div className="account-row" key={account.id}>
<span className="account-text">
<span className="account-email">{account.email}</span>
<span className="account-meta">
{account.connected ? `${countFor(account.id)} calendar${countFor(account.id) === 1 ? "" : "s"}` : "Needs reconnecting"}
</span>
</span>
<button
type="button"
className="panel-button"
data-variant="danger"
disabled={phase === "working"}
onClick={() => setConfirming(account)}
>
Disconnect
</button>
</div>
))
)}
</>
)}
</Sheet>
);
}
export default Accounts;