Make an event out of a message
Asked for in #167: a right-click on a mail that turns it into a calendar entry, the way a bill or a task becomes a reminder. Nothing clever, and deliberately so -- the subject becomes the title, the body becomes the description, and the reader supplies the one thing the message cannot. A due date is exactly that thing. "Due on the 14th" in an invoice is not a date a parser could be trusted with, and a wrong guess quietly scheduled is worse than no guess at all, so the editor opens on the next half hour for an hour and the reader fixes it. Forward rather than now, because a start time that has already passed by the time they press Create is one more thing to correct. The body is capped at 5000 characters. A newsletter is a message too, and its whole body would be stored on the event, synced to every device, and shown in a three-row textarea; what is worth keeping -- the amount, the account, the address -- is near the top. The cut is marked, so a truncated bill is not read as the whole of it. One message only. The list menu acts on the selection everywhere else, but there is no sensible event to make out of five mails, and the mobile entry appears only when exactly one row is held. The editor lives inside CalendarView and the reader is in the mail view when they ask, so the draft waits in the calendar store until that view mounts and takes it -- once, or it would reopen on every later visit. It seeds a form rather than an event: the dialog still says New event and still has to be pressed. Called *Create event…* rather than "appointment", which is the word the issue used: it opens the New event dialog, and each catalogue already has its own settled noun for that -- Termin, événement, 日程. Reachable three ways, since a phone has no right-click: the row context menu, a message's ⋮, and the ⋮ of a held row on mobile. Hidden entirely where the account has no calendar.
This commit is contained in:
@@ -0,0 +1,70 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { appointmentDraft, nextHalfHour } from "@/lib/appointment";
|
||||
import type { Email, EmailBodyPart } from "@/jmap/types";
|
||||
|
||||
/**
|
||||
* A reminder made out of a mail: the subject becomes the title and the body
|
||||
* becomes the description, and the reader supplies the one thing the message
|
||||
* cannot — when it happens. What these pin is that the copy is faithful and
|
||||
* bounded, because everything else about the event is the editor's job.
|
||||
*/
|
||||
|
||||
function part(partId: string, type: string): EmailBodyPart {
|
||||
return { partId, type } as EmailBodyPart;
|
||||
}
|
||||
|
||||
function email(parts: Partial<Email>): Email {
|
||||
return { id: "m1", subject: null, ...parts } as Email;
|
||||
}
|
||||
|
||||
function body(subject: string, type: "text/plain" | "text/html", value: string): Email {
|
||||
const key = type === "text/plain" ? "textBody" : "htmlBody";
|
||||
return email({ subject, [key]: [part("1", type)], bodyValues: { 1: { value, isEncodingProblem: false, isTruncated: false } } });
|
||||
}
|
||||
|
||||
const text = (value: string) => body("Water bill", "text/plain", value);
|
||||
|
||||
describe("the time an appointment starts", () => {
|
||||
it("rounds up to the next half hour", () => {
|
||||
expect(nextHalfHour(new Date("2026-08-31T09:12:40")).toTimeString().slice(0, 5)).toBe("09:30");
|
||||
expect(nextHalfHour(new Date("2026-08-31T09:41:00")).toTimeString().slice(0, 5)).toBe("10:00");
|
||||
});
|
||||
|
||||
it("moves on from a time already on the boundary, rather than starting now", () => {
|
||||
expect(nextHalfHour(new Date("2026-08-31T09:30:00")).toTimeString().slice(0, 5)).toBe("10:00");
|
||||
});
|
||||
|
||||
it("runs for an hour", () => {
|
||||
const d = appointmentDraft(text("anything"), new Date("2026-08-31T09:12:00"));
|
||||
expect(d.end.getTime() - d.start.getTime()).toBe(3600_000);
|
||||
expect(d.allDay).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("what is copied from the message", () => {
|
||||
it("takes the subject as the title and the body as the description", () => {
|
||||
const d = appointmentDraft(text("Due on the 14th.\nAccount 4471.\n"));
|
||||
expect(d.title).toBe("Water bill");
|
||||
expect(d.description).toBe("Due on the 14th.\nAccount 4471.");
|
||||
});
|
||||
|
||||
it("reads an HTML-only message as text, so the description is not markup", () => {
|
||||
const d = appointmentDraft(body("Renewal", "text/html", "<p>Renews <b>Friday</b></p>"));
|
||||
expect(d.description).toBe("Renews Friday");
|
||||
});
|
||||
|
||||
it("leaves the title empty when there is no subject, for the editor to prompt for", () => {
|
||||
expect(appointmentDraft(email({ subject: null })).title).toBe("");
|
||||
});
|
||||
|
||||
/*
|
||||
* A newsletter is a message too. The whole body would be stored on the
|
||||
* event, synced everywhere, and shown in a three-row box, so the tail is
|
||||
* dropped — visibly, so a truncated bill is not read as the whole of it.
|
||||
*/
|
||||
it("truncates a body too long to be a description", () => {
|
||||
const d = appointmentDraft(text("x".repeat(9000)));
|
||||
expect(d.description).toHaveLength(5001);
|
||||
expect(d.description.endsWith("…")).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user