Files
pj 661100dfdc 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.
2026-08-12 17:09:21 +05:30

105 lines
4.1 KiB
TypeScript

// Day and agenda, which are the two views that are not the week and so are the two nobody looks
// at until they are broken.
import { expect, test } from "@playwright/test";
import { blocks, box, gridFit, openApp } from "./app";
test.describe("day view", () => {
test("renders one day, full of the day's events, filling the window", async ({ page }) => {
await openApp(page, { view: "day" });
await expect(page.locator(".grid-col")).toHaveCount(1);
await expect(page.locator(".grid-head-day")).toHaveCount(1);
await expect(page.locator(".grid-allday")).toBeVisible();
const all = await blocks(page);
expect(all.length).toBeGreaterThan(3);
for (const block of all) expect(block.name.trim()).not.toBe("");
const fit = await gridFit(page);
expect(fit.canvasHeight).toBeCloseTo(fit.bodyHeight, 0);
expect(fit.overflow).toBe(false);
});
test("one column means wide blocks, not a week's worth of slivers", async ({ page }) => {
await openApp(page, { view: "day" });
const column = await box(page.locator('.grid-col[data-index="0"]'));
const all = await blocks(page);
// Nothing overlaps three deep all day, so the widest block should be most of the column.
const widest = Math.max(...all.map((b) => b.width));
expect(widest).toBeGreaterThan(column.width * 0.6);
});
});
test.describe("agenda view", () => {
test("groups by day, in order, with a count that matches the rows", async ({ page }) => {
await openApp(page, { view: "agenda" });
const days = page.locator(".agenda-day");
expect(await days.count()).toBeGreaterThan(5);
const first = days.first();
await expect(first.locator(".agenda-day-name")).toHaveText(/^(Mon|Tue|Wed|Thu|Fri|Sat|Sun)$/);
const rows = await first.locator(".agenda-row").count();
expect(rows).toBeGreaterThan(0);
await expect(first.locator(".agenda-day-count")).toHaveText(String(rows));
// The dates run forwards.
const dates = await page.locator(".agenda-day-date").allTextContents();
expect(dates.length).toBeGreaterThan(5);
expect(new Set(dates.slice(0, 5)).size).toBe(5);
});
test("all-day events are on the list, ahead of the timed ones on their day", async ({ page }) => {
await openApp(page, { view: "agenda" });
const trip = page.locator(".agenda-row[data-allday]", { hasText: "Trip to Goa" }).first();
await expect(trip).toBeVisible();
await expect(trip.locator(".agenda-row-time")).toHaveText(/all day/i);
const day = page.locator(".agenda-day").filter({ hasText: "Trip to Goa" }).first();
const kinds = await day
.locator(".agenda-row")
.evaluateAll((rows) => rows.map((row) => row.hasAttribute("data-allday")));
expect(kinds.indexOf(true)).toBe(0);
// All-day rows come first and are not interleaved with the timed ones.
expect(kinds.lastIndexOf(true)).toBeLessThan(kinds.indexOf(false));
});
test("the day's rows are in chronological order", async ({ page }) => {
await openApp(page, { view: "agenda" });
const times = await page
.locator(".agenda-day")
.first()
.locator(".agenda-row:not([data-allday]) .agenda-row-time")
.allTextContents();
expect(times.length).toBeGreaterThan(1);
const minutes = times.map((text) => {
const match = text.match(/^(\d+)(?::(\d+))?(am|pm)/i);
if (!match) return -1;
const hour = Number(match[1]) % 12 + (/pm/i.test(match[3]) ? 12 : 0);
return hour * 60 + Number(match[2] ?? 0);
});
expect(minutes).not.toContain(-1);
expect([...minutes].sort((a, b) => a - b)).toEqual(minutes);
});
test("the agenda scrolls rather than being cut off at the window", async ({ page }) => {
await openApp(page, { view: "agenda" });
const scroller = page.locator(".agenda-scroll");
const scrolled = await scroller.evaluate((el) => {
el.scrollTop = el.scrollHeight;
return el.scrollTop;
});
expect(scrolled).toBeGreaterThan(0);
// And the window itself still does not scroll.
expect(
await page.evaluate(
() => document.documentElement.scrollHeight - document.documentElement.clientHeight,
),
).toBeLessThanOrEqual(0);
});
});