From e1402e472f05831ff03b17ff0833858fcc31b49d Mon Sep 17 00:00:00 2001 From: John Coffey Date: Tue, 1 Sep 2026 10:05:50 -0700 Subject: [PATCH] Schedule against the whole guest list, and say who cannot be read The availability bar showed the guests it had free/busy for and quietly left out the ones it did not. In one row that is survivable. As a grid it would be a lie: a row with nothing in it reads as a diary with nothing in it, and "we cannot see this person's calendar" is the one thing that must not look like "this person is free". So everyone the event concerns now gets a row, and the ones with no free/busy to read are drawn hatched rather than empty, with a line under the grid saying how many and why. You get a row too, first. Scheduling around the other people and not around yourself is how two things end up at the same time, and the organiser was the one calendar the panel never showed. Your own row is never unknown. Where the directory does not list you under the address your identity sends from -- an alias, a login that differs from the address -- the account is still yours to read, and Stalwart answers for it under the account's own id. The window steps backwards and forwards a screenful at a time without touching the event, which is the "movable forwards & backwards" the report asks for, and offers its way back when you have wandered off. And the bars are somewhere to put the event rather than only something to read: the pointer shows the half hour it is over, and a click moves the event there keeping its length. Clicking while stepped away moves the event to where you clicked and returns the view to it, so it lands where you were looking instead of jumping. Free/busy is answered per principal, and only the server's own accounts are principals. Somebody at another domain has none to read -- which is not a gap to be closed, it is what the protocol can see -- so the grid says so rather than drawing them blank. Closes #172 --- .../lib/__tests__/availabilityWindow.test.ts | 23 +++ web/src/lib/availabilityWindow.ts | 12 +- web/src/styles/app.css | 15 ++ web/src/views/calendar/EventEditor.tsx | 165 ++++++++++++++---- 4 files changed, 174 insertions(+), 41 deletions(-) diff --git a/web/src/lib/__tests__/availabilityWindow.test.ts b/web/src/lib/__tests__/availabilityWindow.test.ts index 4871007..3989c68 100644 --- a/web/src/lib/__tests__/availabilityWindow.test.ts +++ b/web/src/lib/__tests__/availabilityWindow.test.ts @@ -88,3 +88,26 @@ describe("the span an availability bar covers", () => { } }); }); + +describe("looking around the event without changing it", () => { + it("slides the whole window forward, keeping its width", () => { + const here = availabilityWindow(at("2026-09-02T09:00:00"), at("2026-09-04T17:00:00")); + const later = availabilityWindow(at("2026-09-02T09:00:00"), at("2026-09-04T17:00:00"), { offsetDays: 3 }); + expect(later.days).toBe(here.days); + expect(later.start.getDate()).toBe(5); + expect(later.end.getDate()).toBe(8); + }); + + it("slides backwards, across the end of a month", () => { + const w = availabilityWindow(at("2026-09-02T09:00:00"), at("2026-09-02T10:00:00"), { offsetDays: -3 }); + expect(w.start.getMonth()).toBe(7); // August + expect(w.start.getDate()).toBe(30); + expect(w.days).toBe(1); + }); + + it("keeps the marks in step with where the window moved to", () => { + const w = availabilityWindow(at("2026-09-02T09:00:00"), at("2026-09-02T10:00:00"), { offsetDays: 1 }); + expect(w.ticks[0]!.time.getDate()).toBe(3); + expect(w.ticks[0]!.at).toBe(0); + }); +}); diff --git a/web/src/lib/availabilityWindow.ts b/web/src/lib/availabilityWindow.ts index 3383764..1082c0e 100644 --- a/web/src/lib/availabilityWindow.ts +++ b/web/src/lib/availabilityWindow.ts @@ -65,13 +65,19 @@ function spacing(days: number): { every: number; label: number } { return { every: 24, label: 24 }; } -export function availabilityWindow(start: Date, end: Date, opts: { maxDays?: number } = {}): AvailabilityWindow { +export function availabilityWindow(start: Date, end: Date, opts: { maxDays?: number; offsetDays?: number } = {}): AvailabilityWindow { const maxDays = opts.maxDays ?? 7; - const from = startOfDay(start); + /* + * Days moved from where the event sits, for looking around it without + * changing it. The whole window slides rather than growing: keeping the span + * fixed means what you compare when you step forward is the same width as + * what you were looking at, which is the point of stepping. + */ + const from = addDays(startOfDay(start), opts.offsetDays ?? 0); // The last day is the one the event ends *on*. An event ending exactly at // midnight ends on the day before, not at the start of a day it never // touches -- that is the whole of what all-day events do. - const lastDay = startOfDay(new Date(Math.max(end.getTime() - 1, start.getTime()))); + const lastDay = addDays(startOfDay(new Date(Math.max(end.getTime() - 1, start.getTime()))), opts.offsetDays ?? 0); const total = Math.max(1, Math.round((lastDay.getTime() - from.getTime()) / DAY_MS) + 1); const days = Math.min(total, maxDays); const to = addDays(from, days); diff --git a/web/src/styles/app.css b/web/src/styles/app.css index 10c609d..84b69a7 100644 --- a/web/src/styles/app.css +++ b/web/src/styles/app.css @@ -918,6 +918,21 @@ select optgroup { background-color: var(--bg-elev); color: var(--fg); } .freebusy .fb-axis-label.end { left: auto; right: 0; transform: none; } .freebusy .fb-tick { position: absolute; top: 0; bottom: 0; width: 1px; background: var(--border); } .freebusy .fb-tick.major { background: var(--fg-faint); } +.freebusy .fb-head { display: flex; align-items: center; gap: 6px; margin-bottom: 2px; font-size: .85em; } +.freebusy .fb-range { font-weight: 600; } +.freebusy .fb-self { font-weight: 600; } +/* A bar is somewhere to put the event, not only something to read. */ +.freebusy .fb-bar { cursor: crosshair; } +.freebusy .fb-guide { position: absolute; top: -2px; bottom: -2px; width: 1px; background: var(--fg); opacity: .55; pointer-events: none; } +/* + * "We cannot say", which is not the same as "free" and must not look like it. + * Somebody outside this server has no free/busy to read, and an empty bar in a + * grid reads as an empty diary -- the one reading that is certainly wrong. + */ +.freebusy .fb-bar.no-data { + background-image: repeating-linear-gradient(135deg, var(--bg-sunken), var(--bg-sunken) 4px, var(--border) 4px, var(--border) 8px); + cursor: crosshair; +} /* ========================================================================== Files diff --git a/web/src/views/calendar/EventEditor.tsx b/web/src/views/calendar/EventEditor.tsx index acf0c46..8f5ee96 100644 --- a/web/src/views/calendar/EventEditor.tsx +++ b/web/src/views/calendar/EventEditor.tsx @@ -1,9 +1,10 @@ import { useEffect, useMemo, useRef, useState } from "react"; -import { Plus, Trash2, Users } from "lucide-react"; +import { ChevronLeft, ChevronRight, Plus, Trash2, Users } from "lucide-react"; import type { BusyPeriod, CalendarEvent, EmailAddress, JSCalendarAlert, JSCalendarParticipant, JSCalendarRecurrenceRule, JSCalendarNDay } from "@/jmap/types"; import { useCalendar, myParticipantKeys, isRecurring, isOccurrence, eventRule, makeParticipant, participantEmail, type EventScope } from "@/store/calendar"; import { useSettings } from "@/store/settings"; import { useSession } from "@/store/session"; +import { CAP } from "@/jmap/client"; import { useContacts } from "@/store/contacts"; import { Dialog } from "@/ui/dialog"; import { ColorSwatches, Switch } from "@/ui/misc"; @@ -11,7 +12,7 @@ import { toast } from "@/ui/toast"; import { RecipientInput } from "../compose/RecipientInput"; import { DateField, DateTimeField } from "@/ui/datefield"; import { browserTimeZone, dateToZonedLocal, formatDuration, fromInputDateTime, listTimeZones, parseDuration, toInputDateTime, toLocalDateOnly, zonedToDate, DAY_MS, humanDuration } from "@/lib/dates"; -import { formatClock, formatNumericDate, formatWeekday } from "@/lib/datetime"; +import { formatClock, formatNumericDate, formatWeekday, formatWeekdayDate } from "@/lib/datetime"; import { WEEKDAYS, describeRule, presetFor, ruleFromPreset, type RecurrencePreset } from "@/lib/recurrence"; import { newKey } from "@/lib/contacts"; import { availabilityWindow } from "@/lib/availabilityWindow"; @@ -148,7 +149,12 @@ function EventForm({ init, base, scope, editing, onClose, settingsTz, defaultAle */ const [sendInvites, setSendInvites] = useState(!init.seed?.attendees?.length); const [busy, setBusy] = useState(false); - const [fb, setFb] = useState>({}); + /** By address: the busy periods, or `null` for "there is no free/busy to read". */ + const [fb, setFb] = useState>({}); + /** Days the panel has been stepped away from the event, for looking around it. */ + const [fbOffset, setFbOffset] = useState(0); + /** Where the pointer is over the grid, so the time a click would set is visible before it does. */ + const [fbHover, setFbHover] = useState(null); const [showMore, setShowMore] = useState(Boolean(ev && (ev.privacy !== "public" || ev.freeBusyStatus === "free" || ev.color || ev.status !== "confirmed" || Object.keys(ev.categories ?? {}).length))); const identity = cal.identities.find((i) => i.isDefault) ?? cal.identities[0]; @@ -160,24 +166,65 @@ function EventForm({ init, base, scope, editing, onClose, settingsTz, defaultAle * it ends. It used to be the start day and nothing else, which meant an event * spanning two days showed availability for one of them without saying so. */ - const fbWindow = useMemo(() => availabilityWindow(start, end), [start, end]); + const fbWindow = useMemo(() => availabilityWindow(start, end, { offsetDays: fbOffset }), [start, end, fbOffset]); + /** Stalwart answers for your own account under its own id, which is the fallback when the directory does not list you. */ + const selfPrincipalId = useSession((st) => st.accountFor(CAP.principals)); - // Free/busy lookup for attendees that are directory principals + /* + * Everyone the event concerns, you first. Scheduling around the other people + * and not around yourself is how two things end up at the same time, and the + * organiser's own calendar was the one row the panel never showed. + */ + const people = useMemo(() => { + const seen = new Set(); + const out: { email: string; name: string; self: boolean }[] = []; + if (myPlainEmail) { + seen.add(myPlainEmail.toLowerCase()); + out.push({ email: myPlainEmail, name: translate("You"), self: true }); + } + for (const a of attendees) { + if (seen.has(a.email.toLowerCase())) continue; + seen.add(a.email.toLowerCase()); + out.push({ email: a.email, name: a.name ?? a.email, self: false }); + } + return out; + }, [myPlainEmail, attendees]); + + /* + * Free/busy, for everyone we can read it for. + * + * Only people the directory knows have any: free/busy is answered per + * principal, and somebody outside the server -- a customer, anyone at another + * domain -- is not one. Those are recorded as `null` rather than left out, + * because a row that is missing from a grid reads as a row with nothing in + * it, which is to say "free", which is the one thing we do not know. + */ useEffect(() => { - if (!attendees.length || !contacts.principalsLoaded) { + if (!people.length || !contacts.principalsLoaded) { if (!contacts.principalsLoaded) void contacts.loadPrincipals(); return; } let cancelled = false; (async () => { - const out: Record = {}; - for (const a of attendees) { - const p = contacts.principals.find((x) => x.email?.toLowerCase() === a.email.toLowerCase()); - if (!p) continue; + const out: Record = {}; + for (const person of people) { + /* + * Your own row is never unknown. If the directory does not list you + * under the address the identity sends from -- an alias, a name that + * differs from the login -- the account is still yours to read, and + * Stalwart answers for it under the account's own id. + */ + const principalId = + contacts.principals.find((x) => x.email?.toLowerCase() === person.email.toLowerCase())?.id ?? + (person.self ? selfPrincipalId : undefined); + if (!principalId) { + out[person.email] = null; + continue; + } try { - out[a.email] = await cal.availability(p.id, fbWindow.start, fbWindow.end); + out[person.email] = await cal.availability(principalId, fbWindow.start, fbWindow.end); } catch { - /* ignore */ + out[person.email] = null; } } if (!cancelled) setFb(out); @@ -186,7 +233,7 @@ function EventForm({ init, base, scope, editing, onClose, settingsTz, defaultAle cancelled = true; }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [attendees.map((a) => a.email).join(","), fbWindow.start.getTime(), fbWindow.end.getTime(), contacts.principalsLoaded]); + }, [people.map((p) => p.email).join(","), fbWindow.start.getTime(), fbWindow.end.getTime(), contacts.principalsLoaded, selfPrincipalId]); const onStartChange = (d: Date) => { if (Number.isNaN(d.getTime())) return; @@ -368,21 +415,40 @@ function EventForm({ init, base, scope, editing, onClose, settingsTz, defaultAle {attendees.length > 0 && ( <> - {Object.keys(fb).length > 0 && (() => { + {people.length > 1 && (() => { /** Everything on a bar is placed as a fraction of the span it covers. */ const pct = (from: number, to: number) => ({ left: `${((from - fbWindow.start.getTime()) / fbWindow.span) * 100}%`, width: `${((to - from) / fbWindow.span) * 100}%`, }); + /** Where along the window a pointer is, as a moment. */ + const timeAt = (e: { clientX: number; currentTarget: Element }) => { + const box = e.currentTarget.getBoundingClientRect(); + const frac = Math.min(Math.max((e.clientX - box.left) / box.width, 0), 1); + // Half-hourly: a bar is a few hundred pixels wide, and a + // minute of it is not something anybody can aim at. + const SNAP = 30 * 60_000; + return new Date(Math.round((fbWindow.start.getTime() + frac * fbWindow.span) / SNAP) * SNAP); + }; + const known = people.filter((p) => fb[p.email]); + // You are not a guest, so you are not counted as one -- and if + // your own row cannot be read, that is not what this sentence + // is about. + const unknown = people.filter((p) => !p.self && fb[p.email] === null); + const rangeLabel = fbWindow.days === 1 + ? formatWeekdayDate(fbWindow.start) + : `${formatNumericDate(fbWindow.start)} – ${formatNumericDate(new Date(fbWindow.end.getTime() - 1))}`; return (
-
- {fbWindow.days === 1 - ? translate("Availability on {date}", { date: formatNumericDate(fbWindow.start) }) - : translate("Availability, {from} to {to}", { from: formatNumericDate(fbWindow.start), to: formatNumericDate(new Date(fbWindow.end.getTime() - 1)) })} +
+ + {rangeLabel} + + {fbOffset !== 0 && } + + {/* The time a click would set, so placing an event is aimed rather than guessed. */} + {fbHover ? (fbWindow.days === 1 ? formatClock(fbHover) : `${formatWeekday(fbHover, "short")} ${formatClock(fbHover)}`) : translate("Click to move the event")}
- {/* The axis answers "what am I looking at" -- without it the bar - could as easily have been working hours as a whole day. */}
@@ -394,28 +460,51 @@ function EventForm({ init, base, scope, editing, onClose, settingsTz, defaultAle {fbWindow.scale === "hours" ? formatClock(fbWindow.end) : formatNumericDate(new Date(fbWindow.end.getTime() - 1))}
- {attendees.filter((a) => fb[a.email]).map((a) => ( -
- {a.name ?? a.email} -
- {/* Drawn under the blocks, so a block can be read against - the hour it starts at rather than guessed at. */} - {fbWindow.ticks.map((tk) => ( - tk.at === 0 ? null : - ))} - {fb[a.email]!.map((b, i) => { - const bs = Math.max(new Date(b.utcStart).getTime(), fbWindow.start.getTime()); - const be = Math.min(new Date(b.utcEnd).getTime(), fbWindow.end.getTime()); - if (be <= bs) return null; - return ; - })} - {!allDay && } + {people.map((person) => { + const periods = fb[person.email]; + const noData = periods === null; + return ( +
+ {person.name} +
setFbHover(timeAt(e))} + onMouseLeave={() => setFbHover(null)} + onClick={(e) => { onStartChange(timeAt(e)); setFbOffset(0); }} + > + {fbWindow.ticks.map((tk) => ( + tk.at === 0 ? null : + ))} + {(periods ?? []).map((b, i) => { + const bs = Math.max(new Date(b.utcStart).getTime(), fbWindow.start.getTime()); + const be = Math.min(new Date(b.utcEnd).getTime(), fbWindow.end.getTime()); + if (be <= bs) return null; + return ; + })} + {fbHover && } + {!allDay && } +
-
- ))} + ); + })} {fbWindow.daysHidden > 0 && (
{plural(fbWindow.daysHidden, { one: "The event runs {n} day longer than this shows.", other: "The event runs {n} days longer than this shows." })}
)} + {/* Said once, under the grid, rather than repeated on every + row that has nothing to show. */} + {unknown.length > 0 && ( +
+ {known.length === 0 + ? translate("Nobody here has free/busy on this server, so none of these rows can say whether anyone is free.") + : plural(unknown.length, { + one: "{n} guest is not on this server, so there is no free/busy to read for them.", + other: "{n} guests are not on this server, so there is no free/busy to read for them.", + })} +
+ )}
); })()}