Swipe the calendar sideways to step a day or a month

The calendar had next and previous as toolbar buttons and n/p, and nothing
for a thumb. Every other surface in the app that a finger drives already
answers to a swipe.

Day and month only. Those are the two views where a period is a page;
week and agenda scroll through a range rather than turning to the next
one, so there is nothing a sideways flick would obviously mean. Dragging
left pulls the next period in from the right, the way paper and every
phone do it.

Three things it deliberately does not do.

It draws nothing while the finger moves. The row swipe slides the row open
because the strip underneath has to name which of six actions is about to
happen; stepping a calendar has two outcomes and the direction of the
finger already says which. Translating the grid would also break the
sticky day header, since a transform makes a containing block. The
threshold is reported by the vibration motor instead, which is what the
haptics are for.

It does not start on an event. Which gesture was meant is decidable at the
moment the finger lands and only then, so dragging an event stays
available to be built later without having to be untangled from this
first.

And it asks for a longer drag than a row swipe -- not because the
consequence is bigger, since stepping back undoes it while a swiped row
has already been archived, but because this gesture has no way to change
its mind. A row reveals what it will do and can be let go early, and
offers Undo after. This shows nothing and offers nothing, so the distance
is the only chance to not mean it.

The axis lock is the shared one, keeping its bias towards the vertical:
the day grid scrolls through the hours, and a scroll misread as a swipe
throws the reader into another day. The toolbar buttons and n/p stay,
because a gesture with no visible control is one only the people who
already know about it can use.
This commit is contained in:
2026-09-01 22:13:32 -07:00
parent 34578ba426
commit 7d9a01cb93
4 changed files with 231 additions and 2 deletions
+68
View File
@@ -0,0 +1,68 @@
import { describe, expect, it } from "vitest";
import { navSwipeThreshold, swipeNavDirection, swipeThreshold, lockAxis } from "@/lib/touch";
describe("navSwipeThreshold", () => {
it("asks for more travel than a row swipe does, at every width", () => {
// Not because the consequence is bigger -- stepping back undoes it -- but
// because this gesture reveals nothing on the way and offers no Undo
// after, so the distance is the only chance to not mean it.
for (const width of [320, 360, 414, 768, 1024]) {
expect(navSwipeThreshold(width)).toBeGreaterThan(swipeThreshold(width));
}
});
it("is a share of the width, bounded at both ends", () => {
expect(navSwipeThreshold(360)).toBe(108);
expect(navSwipeThreshold(200)).toBe(80); // floor
expect(navSwipeThreshold(1000)).toBe(180); // ceiling
});
});
describe("swipeNavDirection", () => {
const W = 400; // threshold is 120 at this width
it("goes forward when the finger drags left, the way pages turn", () => {
expect(swipeNavDirection(-200, W)).toBe(1);
});
it("goes back when the finger drags right", () => {
expect(swipeNavDirection(200, W)).toBe(-1);
});
it("does nothing short of the threshold, in either direction", () => {
expect(swipeNavDirection(-60, W)).toBe(0);
expect(swipeNavDirection(60, W)).toBe(0);
expect(swipeNavDirection(0, W)).toBe(0);
});
it("fires exactly at the threshold and not a pixel before", () => {
const at = navSwipeThreshold(W);
expect(swipeNavDirection(-at, W)).toBe(1);
expect(swipeNavDirection(-(at - 1), W)).toBe(0);
expect(swipeNavDirection(at, W)).toBe(-1);
expect(swipeNavDirection(at - 1, W)).toBe(0);
});
it("scales with the width, so a tablet asks for more than a phone", () => {
// The same 120px drag commits on a narrow screen and does not on a wide one.
expect(swipeNavDirection(-120, 360)).toBe(1);
expect(swipeNavDirection(-120, 1024)).toBe(0);
});
});
describe("the axis lock this shares with the row swipe", () => {
it("keeps a mostly-vertical drag as a scroll, which is what the day grid needs", () => {
// The day view scrolls through the hours; a scroll misread as a swipe
// throws the reader into another day.
expect(lockAxis(20, 30)).toBe("y");
expect(lockAxis(30, 25)).toBe("y");
});
it("commits to sideways only when it is clearly sideways", () => {
expect(lockAxis(40, 10)).toBe("x");
});
it("is undecided until the drag has moved at all", () => {
expect(lockAxis(2, 2)).toBeNull();
});
});