mirror of
https://github.com/priyanshujain/margin-calendar.git
synced 2026-10-02 19:17:04 +00:00
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.
174 lines
6.5 KiB
TypeScript
174 lines
6.5 KiB
TypeScript
// 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" };
|
|
}
|