diff --git a/.gitignore b/.gitignore index 117c18b..9ab5d28 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,6 @@ dist/ server/data/ .vite/ coverage/ + +# Worktrees used by parallel agents; never part of a commit. +.claude/worktrees/ diff --git a/FEATURES.md b/FEATURES.md index 0a2b46b..f78ce3e 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -110,6 +110,21 @@ every drag. - **Hold a folder** in the drawer for the menu its ⋮ button opens. - **Pull the message list** down to refresh it. - **Drag in from the left edge** of a conversation to go back to the list. +- **Swipe the calendar sideways** in day or month view to step to the next + period or back — dragging left pulls the next one in from the right, the way + paper and every phone do it. Week and agenda scroll through a range rather + than turning to the next one, so a sideways flick would not obviously mean + anything there and does nothing. A drag that begins on an event is left + alone, which keeps dragging an event to move it available to be built later + without having to be untangled from this first. + + It asks for a longer drag than a row swipe does, and not because the + consequence is bigger — stepping back undoes it, while a swiped row has + already been archived. It is because this gesture has no way to change its + mind: a row slides open as it goes, so the strip underneath names what will + happen and letting go early calls it off, and a toast offers Undo afterwards. + Stepping the calendar shows nothing on the way and offers nothing after, so + the distance is the only chance to not mean it. The toolbar's refresh button and the thread's back arrow both stay. A gesture with no visible control is one only the people who already know about it can @@ -150,6 +165,21 @@ Archive, delete, spam, star, mark read/unread, move and label all offer **Undo** in the toast that follows, and the undo restores the previous state rather than guessing at an inverse. +**Archive by date** files into `Archive/` or `Archive//`, +creating the folders as needed and reusing them after that — including ones +made by hand or by another client. The names are numeric and zero-padded +(`2026`, `2026/09`) rather than month names, because these are real server-side +mailboxes: every other client sees them, a folder created as "September" by +someone reading in English stays "September" for the same account read in +Japanese, and `09` sorts between `08` and `10` where a name does not. The date +is read in the reader's own timezone, so it agrees with the date shown against +the message in the list. + +A selection spanning two months is two destinations, not one, and both are +written; the menu names the folder where there is a single answer and describes +the rule where there is not, and the toast afterwards says how many folders it +touched. One Undo puts the whole selection back wherever it came from. + `Delete` moves to the bin. **Empty** destroys, and is offered only on Deleted Items and Junk Mail — enforced where the action happens, not merely hidden in the menu. Emptying Junk destroys rather than moving to the bin, because routing @@ -321,7 +351,16 @@ minimisable and maximisable; full-screen on mobile. identity. Signature images live in Files too and are turned into inline `cid:` parts when the message is sent. - **Templates**: named subject + body, inserted into any draft, managed in - Settings. + Settings. Both carry **placeholders** — `{{recipientName}}`, + `{{recipientFirstName}}`, `{{recipientEmail}}`, `{{myName}}`, `{{myEmail}}`, + `{{subject}}`, `{{date}}` and `{{time}}` — filled at the moment the template + is inserted, so what they came to is visible and editable before anything is + sent rather than changing under the message afterwards. Dates and times + follow the same format settings as the rest of the app. A placeholder that + cannot be answered yet — a recipient's name on a draft nobody has addressed — + is **left in the body exactly as written**, because substituting an empty + string there produces "Hi ,", a greeting that is wrong rather than one that + is visibly unfinished. A name that is not a placeholder is left alone too. - **Attachments** by picking or dragging onto the composer, with progress per file and the size limit the server states (`MAX_UPLOAD_BYTES`, 50 MB by default). A pasted image is inserted inline instead, and pasted HTML is diff --git a/web/src/lib/__tests__/archiveDate.test.ts b/web/src/lib/__tests__/archiveDate.test.ts new file mode 100644 index 0000000..ee9304c --- /dev/null +++ b/web/src/lib/__tests__/archiveDate.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from "vitest"; +import { archiveSegments, archivePath, groupByArchivePath } from "@/lib/archiveDate"; + +/** + * The dates below are written as local-time strings on purpose. The segments + * follow the reader's timezone, so a test pinned to UTC instants would pass or + * fail depending on where it ran. + */ +describe("archiveSegments", () => { + it("gives the year, and the zero-padded month", () => { + expect(archiveSegments("2026-09-04T10:00:00", "year")).toEqual(["2026"]); + expect(archiveSegments("2026-09-04T10:00:00", "month")).toEqual(["2026", "09"]); + }); + + it("zero-pads every month below October, so the folders sort", () => { + expect(archiveSegments("2026-01-15T10:00:00", "month")).toEqual(["2026", "01"]); + expect(archiveSegments("2026-10-15T10:00:00", "month")).toEqual(["2026", "10"]); + expect(archiveSegments("2026-12-15T10:00:00", "month")).toEqual(["2026", "12"]); + }); + + it("returns nothing to append when the date cannot be read", () => { + // Archive itself, rather than a folder named after a guess. + expect(archiveSegments(null, "month")).toEqual([]); + expect(archiveSegments(undefined, "month")).toEqual([]); + expect(archiveSegments("", "month")).toEqual([]); + expect(archiveSegments("not a date", "month")).toEqual([]); + }); + + it("joins to a path", () => { + expect(archivePath(["2026", "09"])).toBe("2026/09"); + expect(archivePath([])).toBe(""); + }); +}); + +describe("groupByArchivePath", () => { + it("keeps one destination for a selection from one month", () => { + const groups = groupByArchivePath( + [ + { id: "a", receivedAt: "2026-09-04T10:00:00" }, + { id: "b", receivedAt: "2026-09-28T10:00:00" }, + ], + "month", + ); + expect(groups).toHaveLength(1); + expect(groups[0]!.segments).toEqual(["2026", "09"]); + expect(groups[0]!.ids).toEqual(["a", "b"]); + }); + + it("splits a selection that spans months, which is the case that matters", () => { + const groups = groupByArchivePath( + [ + { id: "a", receivedAt: "2026-09-04T10:00:00" }, + { id: "b", receivedAt: "2026-08-30T10:00:00" }, + { id: "c", receivedAt: "2026-09-01T10:00:00" }, + ], + "month", + ); + expect(groups.map((g) => g.segments)).toEqual([ + ["2026", "09"], + ["2026", "08"], + ]); + expect(groups[0]!.ids).toEqual(["a", "c"]); + expect(groups[1]!.ids).toEqual(["b"]); + }); + + it("collapses the same span back to one group at year granularity", () => { + const entries = [ + { id: "a", receivedAt: "2026-09-04T10:00:00" }, + { id: "b", receivedAt: "2026-02-28T10:00:00" }, + ]; + expect(groupByArchivePath(entries, "month")).toHaveLength(2); + expect(groupByArchivePath(entries, "year")).toHaveLength(1); + }); + + it("orders groups by where their first message appeared", () => { + const groups = groupByArchivePath( + [ + { id: "a", receivedAt: "2024-01-04T10:00:00" }, + { id: "b", receivedAt: "2026-01-04T10:00:00" }, + ], + "year", + ); + expect(groups.map((g) => archivePath(g.segments))).toEqual(["2024", "2026"]); + }); + + it("gathers the undatable ones into their own group, bound for Archive itself", () => { + const groups = groupByArchivePath( + [ + { id: "a", receivedAt: "2026-09-04T10:00:00" }, + { id: "b", receivedAt: null }, + { id: "c", receivedAt: "bad" }, + ], + "month", + ); + expect(groups).toHaveLength(2); + expect(groups[1]!.segments).toEqual([]); + expect(groups[1]!.ids).toEqual(["b", "c"]); + }); + + it("has nothing to do with an empty selection", () => { + expect(groupByArchivePath([], "month")).toEqual([]); + }); +}); diff --git a/web/src/lib/__tests__/swipeNav.test.ts b/web/src/lib/__tests__/swipeNav.test.ts new file mode 100644 index 0000000..03041bc --- /dev/null +++ b/web/src/lib/__tests__/swipeNav.test.ts @@ -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(); + }); +}); diff --git a/web/src/lib/__tests__/templatePlaceholders.test.ts b/web/src/lib/__tests__/templatePlaceholders.test.ts new file mode 100644 index 0000000..93ffbfc --- /dev/null +++ b/web/src/lib/__tests__/templatePlaceholders.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from "vitest"; +import { fillPlaceholders, PLACEHOLDER_NAMES, type PlaceholderContext } from "@/lib/templatePlaceholders"; + +const AT = new Date("2026-03-04T15:07:00Z"); + +function ctx(over: Partial = {}): PlaceholderContext { + return { + to: [{ name: "Ada Lovelace", email: "ada@example.com" }], + from: { name: "Grace Hopper", email: "grace@example.com" }, + subject: "Quarterly report", + now: AT, + ...over, + }; +} + +describe("fillPlaceholders", () => { + it("fills the names it knows", () => { + expect(fillPlaceholders("Hi {{recipientFirstName}},", ctx(), { html: true })).toBe("Hi Ada,"); + expect(fillPlaceholders("{{recipientName}} <{{recipientEmail}}>", ctx(), { html: false })).toBe("Ada Lovelace "); + expect(fillPlaceholders("-- {{myName}}", ctx(), { html: true })).toBe("-- Grace Hopper"); + expect(fillPlaceholders("Re: {{subject}}", ctx(), { html: false })).toBe("Re: Quarterly report"); + }); + + it("tolerates spaces inside the braces but not a different case", () => { + expect(fillPlaceholders("{{ myEmail }}", ctx(), { html: false })).toBe("grace@example.com"); + expect(fillPlaceholders("{{MyEmail}}", ctx(), { html: false })).toBe("{{MyEmail}}"); + }); + + it("leaves a placeholder it cannot answer exactly as written", () => { + // The case the design is about: a template inserted before the message is + // addressed. "Hi ," would be wrong; "Hi {{recipientFirstName}}," is unfinished. + const unaddressed = ctx({ to: [] }); + expect(fillPlaceholders("Hi {{recipientFirstName}},", unaddressed, { html: true })).toBe("Hi {{recipientFirstName}},"); + expect(fillPlaceholders("{{recipientEmail}}", unaddressed, { html: false })).toBe("{{recipientEmail}}"); + expect(fillPlaceholders("{{myName}}", ctx({ from: null }), { html: false })).toBe("{{myName}}"); + }); + + it("leaves a name it does not know alone rather than eating it", () => { + expect(fillPlaceholders("{{nonsense}} {{}} {{ }}", ctx(), { html: true })).toBe("{{nonsense}} {{}} {{ }}"); + }); + + it("falls back to the local part when a recipient has no name", () => { + const c = ctx({ to: [{ name: null, email: "ada.lovelace@example.com" }] }); + expect(fillPlaceholders("{{recipientName}}", c, { html: false })).toBe("ada.lovelace"); + expect(fillPlaceholders("{{recipientFirstName}}", c, { html: false })).toBe("ada.lovelace"); + }); + + it("escapes a substituted value on the way into HTML, and not into a subject", () => { + const c = ctx({ to: [{ name: 'Ada ', email: "ada@example.com" }] }); + expect(fillPlaceholders("{{recipientName}}", c, { html: true })).not.toContain("