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

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